Compare commits

...
Author SHA1 Message Date
agentloop 05c0334532 plan: refresh plan for issue #484 2026-06-08 14:19:39 +00:00
Bot of Thomas Güttlerandguettli 8592bba9e3 chore(dagger): align Dagger versions to v0.21.4 and add lint (#544)
## Summary

Closes #542.

- Bumped `ci/dagger.json` `engineVersion`, the Forgejo runner Dockerfile (`.forgejo/Dockerfile`), and the example `dagger-engine.service` unit in `DAGGER.md` from `0.20.8` -> `0.21.4` so they match the running engine and the CLI already pinned by `flake.nix`.
- Added `scripts/check_dagger_versions.sh` which parses the four pinned references and fails if any drift apart.
- Wired the lint into `Taskfile.yml` (`task check-dagger-versions`) and `.pre-commit-config.yaml` (triggered when any of the four pinned files change).

## Verification

- `./scripts/check_dagger_versions.sh` -> passes, all four references at `v0.21.4`.
- Temporarily edited `ci/dagger.json` to `v0.21.3` and re-ran the script: exits non-zero with a clear "out of sync" error.

Generated with Claude Code.

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/544
2026-06-08 16:11:17 +02:00
Bot of Thomas Güttler 13a0c99f57 test(search): cover sort order of searchEmailsStructured and getEmailsByAddress (#534) 2026-06-07 20:24:25 +02:00
Bot of Thomas Güttlerandguettli 41c8196a97 feat(detail): drop AppBar subject, surface Mark as spam icon (#531)
## Summary
- Drop the truncated subject preview from the single-mail AppBar title; the full subject is already shown in the body header.
- Replace the popup-menu entry for **Mark as spam** with a direct `IconButton` (`Icons.report_outlined`) in the AppBar actions so the action is reachable without opening the `⋯` menu.
- Update affected widget tests for the new layout (subject is only in the body header; spam action is now a standalone button rather than a popup item).

Closes #528

## Test plan
- [x] `dart format --output=none --set-exit-if-changed lib test` — 0 changed
- [x] `dart analyze --fatal-infos lib test` — no issues
- [x] `flutter test test/widget/email_detail_screen_test.dart test/widget/email_list_screen_test.dart` — 42/42 passing
- [x] Full widget suite (`flutter test test/widget/`) — 172/172 passing

Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/531
2026-06-07 20:05:57 +02:00
Bot of Thomas Güttler 38f7ada8b5 chore(deps): bump go_router, file_picker, flutter_local_notifications (#532) 2026-06-07 19:45:22 +02:00
Bot of Thomas Güttler a227f8607c fix(ci): use endpoints that exist in Forgejo for wait-time + LAST_DEPLOYED_SHA (#529) 2026-06-07 14:02:01 +02:00
Bot of Thomas Güttler 5db5d957ab fix(ci): use /actions/runs endpoint in remaining wait-time steps (#524) 2026-06-07 06:59:00 +02:00
Bot of Thomas Güttler 0dd1d7232b fix(ci): use /actions/runs endpoint in deploy.yml wait-time steps (#522) 2026-06-07 06:33:57 +02:00
15 changed files with 421 additions and 192 deletions
+6 -6
View File
@@ -19,14 +19,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
+43 -59
View File
@@ -21,14 +21,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
@@ -51,43 +51,27 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD) HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent workflow run where deploy-playstore actually succeeded # Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
# (not merely skipped). Bug fix: previous code used commit_sha (always None in # does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on # (per-job records) directly and filter for the task we care about. Filtering at the
# every run and the fallback diff to only cover HEAD~1..HEAD. # task level also distinguishes runs where the Play Store job actually ran from runs
# where it was skipped — at the run level both show status=success.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF' LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "") token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "") repo = os.environ.get("GITHUB_REPOSITORY", "")
base_api = f"{server}/api/v1/repos/{repo}/actions" url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try: try:
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read()) data = json.loads(r.read())
runs = [ for t in data.get("workflow_runs", []):
r for r in data.get("workflow_runs", []) if (t.get("workflow_id") == "deploy.yml"
if r.get("status") == "success" and t.get("name") == "Build & Deploy to Play Store"
] and t.get("status") == "success"):
# Walk runs newest-first; pick the first one where deploy-playstore print(t.get("head_sha") or "")
# actually ran (conclusion=success), not just skipped. sys.exit(0)
for run in runs:
run_id = run.get("id")
jobs_url = f"{base_api}/runs/{run_id}/jobs"
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(jobs_req) as jr:
jobs_data = json.loads(jr.read())
for job in jobs_data.get("workflow_jobs", []):
if "Deploy to Play Store" in job.get("name", "") and (
job.get("conclusion") == "success" or
job.get("status") == "success"
):
print(run.get("head_sha") or "")
sys.exit(0)
except Exception:
pass # skip this run if jobs API fails
print("") print("")
except Exception as e: except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})") print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
@@ -164,14 +148,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
@@ -215,14 +199,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
@@ -260,14 +244,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
@@ -310,14 +294,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
+12 -12
View File
@@ -20,14 +20,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
@@ -73,14 +73,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
+19 -32
View File
@@ -38,40 +38,27 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD) HEAD_SHA=$(git rev-parse HEAD)
# Find the most recent successful website.yml run where the deploy job # Find the most recent successful "Build & Update Website" task. Forgejo's API
# actually ran (not merely skipped). Uses head_sha (not commit_sha which # does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
# is always None in Forgejo's API). # (per-job records) directly and filter for the task we care about. Filtering at the
# task level also distinguishes runs where the deploy job actually ran from runs
# where it was skipped — at the run level both show status=success.
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF' LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "") token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "") repo = os.environ.get("GITHUB_REPOSITORY", "")
base_api = f"{server}/api/v1/repos/{repo}/actions" url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
url = f"{base_api}/runs?workflow_id=website.yml&status=success&limit=10"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try: try:
with urllib.request.urlopen(req) as r: with urllib.request.urlopen(req, timeout=60) as r:
data = json.loads(r.read()) data = json.loads(r.read())
runs = [ for t in data.get("workflow_runs", []):
r for r in data.get("workflow_runs", []) if (t.get("workflow_id") == "website.yml"
if r.get("status") == "success" and t.get("name") == "Build & Update Website"
] and t.get("status") == "success"):
for run in runs: print(t.get("head_sha") or "")
run_id = run.get("id") sys.exit(0)
jobs_url = f"{base_api}/runs/{run_id}/jobs"
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(jobs_req) as jr:
jobs_data = json.loads(jr.read())
for job in jobs_data.get("workflow_jobs", []):
if "Build & Update Website" in job.get("name", "") and (
job.get("conclusion") == "success" or
job.get("status") == "success"
):
print(run.get("head_sha") or "")
sys.exit(0)
except Exception:
pass # skip this run if jobs API fails
print("") print("")
except Exception as e: except Exception as e:
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})") print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
@@ -130,14 +117,14 @@ jobs:
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
run: | run: |
runner_start=$(date +%s) runner_start=$(date +%s)
created_at=$(curl -sf \ created=$(curl -sf --max-time 30 \
-H "Authorization: token $FORGEJO_TOKEN" \ -H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null) | python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
if [ -n "$created_at" ]; then if [ -n "$created" ]; then
queued_epoch=$(date -d "$created_at" +%s) queued_epoch=$(date -d "$created" +%s)
wait_seconds=$((runner_start - queued_epoch)) wait_seconds=$((runner_start - queued_epoch))
echo "Runner wait time: ${wait_seconds}s (queued at $created_at)" echo "Runner wait time: ${wait_seconds}s (queued at $created)"
else else
echo "Runner wait time: unknown (API lookup failed)" echo "Runner wait time: unknown (API lookup failed)"
fi fi
+6
View File
@@ -53,3 +53,9 @@ repos:
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images' entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
pass_filenames: false pass_filenames: false
files: ^(ci/main\.go|\.fvmrc)$ files: ^(ci/main\.go|\.fvmrc)$
- id: dagger-versions-aligned
name: verify Dagger version is consistent across dagger.json, flake.nix, Dockerfile and DAGGER.md
language: system
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_dagger_versions.sh'
pass_filenames: false
files: ^(ci/dagger\.json|flake\.nix|\.forgejo/Dockerfile|DAGGER\.md)$
+100
View File
@@ -0,0 +1,100 @@
## Root cause analysis
The "Load remote images" button is rendered in two places: `lib/ui/screens/email_detail_screen.dart:228-262` (single mail view) and `lib/ui/screens/thread_detail_screen.dart:203-237` (thread view). Both call the same pattern:
```dart
onPressed: () {
setState(() => _loadRemoteImages = true); // 1. schedule rebuild
if (senderEmail != null) {
unawaited(...addTrustedImageSender(senderEmail)); // 2. fire-and-forget DB write
ScaffoldMessenger.of(ctx).showSnackBar(SnackBar( // 3. queue snack bar
duration: const Duration(seconds: 3),
...
));
}
}
```
Although `duration: 3s` is already set, the snack bar fails to auto-dismiss. This mirrors the bug fixed in PR #401 (issue #399): there, a snack bar fired during a navigation transition and the duration timer "didn't start correctly" because the snack bar was queued on an unstable scaffold.
Here, the analogous instability comes from three rebuilds that all land between `showSnackBar` and the moment the SnackBar's enter-animation would normally complete and start its dismiss timer:
1. The synchronous `setState` flips `_loadRemoteImages``true`, which immediately removes the "Load remote images" button (the very widget whose `onPressed` was running) and swaps the `SecureEmailWebView` into the rebuilt subtree with `loadRemoteImages: true`. The WebView's `didUpdateWidget` then triggers an async `loadHtmlString` reload (see `lib/ui/widgets/secure_email_webview.dart:100-106`), which subsequently calls `setState(() => _height = h)` inside `_measureHeight`.
2. The fire-and-forget `addTrustedImageSender` write resolves a moment later, the `trustedImageSendersProvider` stream emits, and `ref.watch(trustedImageSendersProvider)` in `email_detail_screen.dart:197` causes another rebuild of the whole screen body — including the `Scaffold`'s body subtree that hosts the snack bar overlay's host context.
3. These rebuilds happen during the SnackBar's enter animation, so the `_SnackBarState` ends up holding stale animation state and the per-snack-bar timer that schedules `hideCurrentSnackBar` after `duration` never fires.
## Plan
### Fix
Queue the snack bar **before** mutating state, so it reaches `ScaffoldMessenger` while the Scaffold subtree is still stable, and defer the state change to a post-frame callback so the snack bar's enter-animation can finish before the WebView reload and the provider-driven rebuild run.
In `lib/ui/screens/email_detail_screen.dart`, replace the body of `OutlinedButton.icon.onPressed` at lines 231-261 with:
```dart
onPressed: () {
if (senderEmail != null) {
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.addTrustedImageSender(senderEmail),
);
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
duration: const Duration(seconds: 3),
content: const Text(
'Images will be loaded automatically for this sender.',
),
action: SnackBarAction(
label: 'View',
onPressed: () {
if (mounted) {
unawaited(
context.push(
'/accounts/trusted-senders',
extra: senderEmail,
),
);
}
},
),
),
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _loadRemoteImages = true);
});
},
```
Apply the same reordering to `lib/ui/screens/thread_detail_screen.dart:206-236`.
The key changes:
- `showSnackBar` runs first, on the still-stable scaffold subtree.
- `setState` (which triggers WebView swap-in and subsequent rebuilds) is deferred to a post-frame callback.
- When `senderEmail == null` (no trusted-sender to register, so no snack bar), the post-frame callback still flips `_loadRemoteImages` to true — preserving existing behavior of the button working even for unknown senders.
### Tests
Add a widget test in `test/widget/email_detail_screen_test.dart` that:
1. Pumps an `EmailDetailScreen` with an HTML body and a non-empty `From` header.
2. Taps the "Load remote images" button.
3. Verifies the snack bar with text "Images will be loaded automatically for this sender." appears.
4. Calls `tester.pump(const Duration(seconds: 4))` (or uses `tester.pumpAndSettle` after a 3.5s pump).
5. Verifies the snack bar is gone (`expect(find.byType(SnackBar), findsNothing)`).
6. Verifies `_loadRemoteImages` did flip, by checking that the "Load remote images" button is no longer present.
Add an analogous test in `test/widget/thread_detail_screen_test.dart` (or wherever thread tests live; create the file if it does not exist yet — use the email_detail test as a template).
### Out of scope
- The "First update agent loop, fix search bug" line in the issue body is two unrelated todo notes the reporter jotted down (the search bug is tracked separately). This plan does not address them.
- Other `showSnackBar` call sites in `email_detail_screen.dart` (download success/failure, copy-to-clipboard, raw-email errors, etc.) are not affected by the same rebuild pattern and stay unchanged.
### Verification checklist
- [ ] `dart test` (or the project's `task test` equivalent) passes, including the two new widget tests.
- [ ] Manual: open a single mail in `EmailDetailScreen` with HTML body from a sender not yet trusted; tap "Load remote images"; verify snack bar appears, images load, and snack bar disappears after ~3 seconds.
- [ ] Manual: tap "View" on the snack bar before it dismisses; verify it navigates to `/accounts/trusted-senders` and that the snack bar is dismissed by the navigation as expected.
- [ ] Manual: repeat in `ThreadDetailScreen`.
+5
View File
@@ -712,6 +712,11 @@ tasks:
cmds: cmds:
- scripts/check_ci_images.sh - scripts/check_ci_images.sh
check-dagger-versions:
desc: Verify ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md pin the same Dagger version
cmds:
- scripts/check_dagger_versions.sh
_integrations: _integrations:
internal: true internal: true
run: once run: once
+9 -7
View File
@@ -49,14 +49,16 @@
''; '';
}; };
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec # The dagger/nix flake's Nix wrapper is a broken self-exec loop, so we
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run. # fetch the CLI binary directly. Keep this version in lockstep with
dagger021 = pkgs.stdenv.mkDerivation { # ci/dagger.json (engineVersion) and .forgejo/Dockerfile (DAGGER_VERSION) —
# scripts/check_dagger_versions.sh enforces this.
daggerCli = pkgs.stdenv.mkDerivation {
pname = "dagger"; pname = "dagger";
version = "0.21.4"; version = "0.20.8";
src = pkgs.fetchurl { src = pkgs.fetchurl {
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz"; url = "https://dl.dagger.io/dagger/releases/0.20.8/dagger_v0.20.8_linux_amd64.tar.gz";
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd"; sha256 = "1ns6wq2z1skd2fq9lbrcali0s8kn24p3haamnjjgchg6zlv6b960";
}; };
sourceRoot = "."; sourceRoot = ".";
installPhase = '' installPhase = ''
@@ -69,7 +71,7 @@
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# Dagger CLI # Dagger CLI
dagger021 daggerCli
# Go compiler — for Dagger development # Go compiler — for Dagger development
go go
+9 -7
View File
@@ -74,10 +74,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !isMobile, automaticallyImplyLeading: !isMobile,
title: Text(
header?.subject ?? '(loading…)',
overflow: TextOverflow.ellipsis,
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.reply), icon: const Icon(Icons.reply),
@@ -133,12 +129,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (mounted) setState(() => _isFlagged = next); if (mounted) setState(() => _isFlagged = next);
}, },
), ),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem(value: 'forward', child: Text('Forward')), const PopupMenuItem(value: 'forward', child: Text('Forward')),
const PopupMenuItem(value: 'move', child: Text('Move to folder')), const PopupMenuItem(value: 'move', child: Text('Move to folder')),
const PopupMenuItem(value: 'snooze', child: Text('Snooze')), const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
const PopupMenuItem( const PopupMenuItem(
value: 'mark_unread', value: 'mark_unread',
child: Text('Mark as unread'), child: Text('Mark as unread'),
@@ -166,8 +170,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
unawaited(_moveTo(context, header)); unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) { } else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header)); unawaited(_snooze(context, header));
} else if (value == 'spam' && header != null) {
unawaited(_markAsSpam(context, header));
} else if (value == 'mark_unread') { } else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header); final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false); await repo.setFlag(widget.emailId, seen: false);
+59 -51
View File
@@ -5,18 +5,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "93.0.0" version: "99.0.0"
analyzer: analyzer:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.1" version: "12.1.0"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -165,10 +165,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: code_assets name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.2.1"
code_builder: code_builder:
dependency: transitive dependency: transitive
description: description:
@@ -237,18 +237,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: dart_style
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.7" version: "3.1.8"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
name: dbus name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.14"
device_info_plus: device_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -349,10 +349,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46" sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.0.0-beta.4" version: "12.0.0-beta.5"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -391,34 +391,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1" sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "21.0.0" version: "22.0.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.0" version: "8.0.1"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307 sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.0.0" version: "12.0.0"
flutter_local_notifications_web:
dependency: transitive
description:
name: flutter_local_notifications_web
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_local_notifications_windows: flutter_local_notifications_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_windows name: flutter_local_notifications_windows
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c" sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.1.0"
flutter_markdown_plus: flutter_markdown_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -431,10 +439,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.34" version: "2.0.35"
flutter_riverpod: flutter_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -447,10 +455,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261 sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.3.0" version: "10.3.1"
flutter_secure_storage_darwin: flutter_secure_storage_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -526,10 +534,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "17.2.3" version: "17.3.0"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@@ -542,10 +550,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: hooks name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "2.0.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -675,10 +683,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -707,10 +715,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: native_toolchain_c name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.6" version: "0.19.1"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description:
@@ -723,10 +731,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: objective_c name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.3.0" version: "9.4.1"
open_filex: open_filex:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1013,13 +1021,13 @@ packages:
source: hosted source: hosted
version: "1.10.2" version: "1.10.2"
sqlite3: sqlite3:
dependency: "direct dev" dependency: "direct main"
description: description:
name: sqlite3 name: sqlite3
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5" sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.1" version: "3.3.2"
sqlite3_flutter_libs: sqlite3_flutter_libs:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1088,10 +1096,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: synchronized name: synchronized
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.0+1" version: "3.4.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@@ -1104,26 +1112,26 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.31.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.17"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@@ -1288,10 +1296,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_android name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688 sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.12.0" version: "4.13.0"
webview_flutter_platform_interface: webview_flutter_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -1304,10 +1312,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: webview_flutter_wkwebview name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2" sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.25.1" version: "3.26.0"
win32: win32:
dependency: transitive dependency: transitive
description: description:
@@ -1381,5 +1389,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.12.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.44.0"
+3 -3
View File
@@ -28,7 +28,7 @@ dependencies:
flutter_riverpod: ^3.0.0 flutter_riverpod: ^3.0.0
# Navigation # Navigation
go_router: ^17.2.3 go_router: ^17.3.0
# Secure credential storage (passwords) # Secure credential storage (passwords)
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
@@ -37,7 +37,7 @@ dependencies:
intl: ^0.20.2 intl: ^0.20.2
# File picking (compose attachments) and opening downloaded attachments # File picking (compose attachments) and opening downloaded attachments
file_picker: ^12.0.0-beta.4 file_picker: ^12.0.0-beta.5
open_filex: ^4.6.0 open_filex: ^4.6.0
mime: ^2.0.0 mime: ^2.0.0
@@ -56,7 +56,7 @@ dependencies:
flutter_markdown_plus: ^1.0.7 flutter_markdown_plus: ^1.0.7
# Background sync and local notifications # Background sync and local notifications
flutter_local_notifications: ^21.0.0 flutter_local_notifications: ^22.0.0
workmanager: ^0.9.0 workmanager: ^0.9.0
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace # Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Verify that the Dagger version is consistent across the project.
#
# The Dagger CLI must speak the same protocol as the engine it talks to. We
# pin the version in four places (engine image in DAGGER.md, the CLI in
# flake.nix, the CLI in the Forgejo runner Dockerfile, and the module
# engineVersion in ci/dagger.json). This script fails if any of them drift.
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
# ci/dagger.json — strip leading "v" for comparison.
dagger_json=$(grep -oE '"engineVersion"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/ci/dagger.json" \
| sed -E 's/.*"v?([^"]+)"$/\1/')
# flake.nix — the dagger021 derivation's CLI download URL.
flake_nix=$(grep -oE 'dagger_v[0-9]+\.[0-9]+\.[0-9]+_linux' "$ROOT/flake.nix" \
| head -n1 \
| sed -E 's/dagger_v([0-9.]+)_linux/\1/')
# .forgejo/Dockerfile — DAGGER_VERSION env on the install line.
dockerfile=$(grep -oE 'DAGGER_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/.forgejo/Dockerfile" \
| head -n1 \
| cut -d= -f2)
# DAGGER.md — engine image tag in the example systemd unit.
dagger_md=$(grep -oE 'dagger/nix/v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/DAGGER.md" \
| head -n1 \
| sed -E 's@.*/v@@')
printf 'ci/dagger.json engineVersion = v%s\n' "$dagger_json"
printf 'flake.nix dagger021 = %s\n' "$flake_nix"
printf '.forgejo/Dockerf. DAGGER_VERSION= %s\n' "$dockerfile"
printf 'DAGGER.md engine tag = v%s\n' "$dagger_md"
for v in "$flake_nix" "$dockerfile" "$dagger_md"; do
if [ -z "$v" ]; then
echo "ERROR: failed to parse a Dagger version reference." >&2
exit 1
fi
if [ "$v" != "$dagger_json" ]; then
echo "" >&2
echo "ERROR: Dagger versions are out of sync." >&2
echo " Align ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
exit 1
fi
done
echo "Dagger versions aligned (v$dagger_json)."
+86
View File
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/testing.dart'; import 'package:http/testing.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/data/db/database.dart' hide Account; import 'package:sharedinbox/data/db/database.dart' hide Account;
@@ -682,6 +683,91 @@ void main() {
expect(results[1].subject, 'Older meeting'); expect(results[1].subject, 'Older meeting');
}); });
test(
'searchEmailsStructured returns results sorted by receivedAt descending',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Older invoice'),
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('Newer invoice'),
receivedAt: DateTime(2024, 6),
),
);
final filter = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'invoice',
),
],
);
final results = await r.emails.searchEmailsStructured(null, filter);
expect(results, hasLength(2));
expect(results[0].subject, 'Newer invoice');
expect(results[1].subject, 'Older invoice');
},
);
test(
'getEmailsByAddress returns results sorted by receivedAt descending',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Older hello'),
receivedAt: DateTime(2024),
fromJson: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('Newer hello'),
receivedAt: DateTime(2024, 6),
fromJson: const Value(
'[{"name":"Bob","email":"bob@example.com"}]',
),
),
);
final results =
await r.emails.getEmailsByAddress(null, 'bob@example.com');
expect(results, hasLength(2));
expect(results[0].subject, 'Newer hello');
expect(results[1].subject, 'Older hello');
},
);
test( test(
'searchAddresses returns results sorted by most recently used', 'searchAddresses returns results sorted by most recently used',
() async { () async {
+13 -13
View File
@@ -81,7 +81,7 @@ void main() {
expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget);
}); });
testWidgets('shows subject in app bar after data loads', (tester) async { testWidgets('shows subject in email header section', (tester) async {
final email = testEmail(subject: 'Project update'); final email = testEmail(subject: 'Project update');
const body = EmailBody( const body = EmailBody(
emailId: 'acc-1:42', emailId: 'acc-1:42',
@@ -106,8 +106,8 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Subject appears in both the app bar and the email header section. // Subject appears only in the email header section, not in the app bar.
expect(find.text('Project update'), findsAtLeastNWidgets(1)); expect(find.text('Project update'), findsOneWidget);
expect(find.text('See attached slides.'), findsOneWidget); expect(find.text('See attached slides.'), findsOneWidget);
}); });
@@ -266,7 +266,7 @@ void main() {
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1)); expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
}); });
testWidgets('Mark as spam is in popup menu, not a standalone button', ( testWidgets('Mark as spam is a standalone button, not in popup menu', (
tester, tester,
) async { ) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -279,19 +279,19 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// No standalone icon button for mark as spam. // Standalone icon button for mark as spam is in the app bar.
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam', (w) => w is Tooltip && w.message == 'Mark as spam',
), ),
findsNothing, findsOneWidget,
); );
// It appears in the popup menu. // It does NOT appear in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>)); await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget); expect(find.text('Mark as spam'), findsNothing);
}); });
testWidgets('Mark as spam shows dialog when no junk folder', ( testWidgets('Mark as spam shows dialog when no junk folder', (
@@ -309,11 +309,11 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Open the popup menu first, then tap Mark as spam. await tester.tap(
await tester.tap(find.byType(PopupMenuButton<String>)); find.byWidgetPredicate(
await tester.pumpAndSettle(); (w) => w is Tooltip && w.message == 'Mark as spam',
),
await tester.tap(find.text('Mark as spam')); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget); expect(find.text('No spam folder found'), findsOneWidget);
+2 -2
View File
@@ -446,10 +446,10 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(EmailDetailScreen), findsOneWidget); expect(find.byType(EmailDetailScreen), findsOneWidget);
// The detail AppBar title shows the first email's subject. // The detail body header shows the first email's subject.
expect( expect(
find.descendant( find.descendant(
of: find.byType(AppBar), of: find.byType(EmailDetailScreen),
matching: find.text('Alpha Match'), matching: find.text('Alpha Match'),
), ),
findsOneWidget, findsOneWidget,