Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c0334532 | ||
|
|
8592bba9e3 | ||
|
|
13a0c99f57 | ||
|
|
41c8196a97 | ||
|
|
38f7ada8b5 | ||
|
|
a227f8607c | ||
|
|
5db5d957ab | ||
|
|
0dd1d7232b | ||
|
|
282a64b4c3 | ||
|
|
8e26715658 |
@@ -19,14 +19,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
|
||||
@@ -21,14 +21,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
@@ -51,43 +51,27 @@ jobs:
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Find the most recent workflow run where deploy-playstore actually succeeded
|
||||
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
|
||||
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
|
||||
# every run and the fallback diff to only cover HEAD~1..HEAD.
|
||||
# Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
|
||||
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
|
||||
# (per-job records) directly and filter for the task we care about. Filtering at the
|
||||
# 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'
|
||||
import json, os, sys, urllib.request
|
||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
||||
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
|
||||
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("status") == "success"
|
||||
]
|
||||
# Walk runs newest-first; pick the first one where deploy-playstore
|
||||
# actually ran (conclusion=success), not just skipped.
|
||||
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
|
||||
for t in data.get("workflow_runs", []):
|
||||
if (t.get("workflow_id") == "deploy.yml"
|
||||
and t.get("name") == "Build & Deploy to Play Store"
|
||||
and t.get("status") == "success"):
|
||||
print(t.get("head_sha") or "")
|
||||
sys.exit(0)
|
||||
print("")
|
||||
except Exception as e:
|
||||
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||
@@ -164,14 +148,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
@@ -215,14 +199,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
@@ -260,14 +244,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
@@ -310,14 +294,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
|
||||
@@ -20,14 +20,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
@@ -73,14 +73,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
|
||||
@@ -38,40 +38,27 @@ jobs:
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Find the most recent successful website.yml run where the deploy job
|
||||
# actually ran (not merely skipped). Uses head_sha (not commit_sha which
|
||||
# is always None in Forgejo's API).
|
||||
# Find the most recent successful "Build & Update Website" task. Forgejo's API
|
||||
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
|
||||
# (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'
|
||||
import json, os, sys, urllib.request
|
||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
||||
url = f"{base_api}/runs?workflow_id=website.yml&status=success&limit=10"
|
||||
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("status") == "success"
|
||||
]
|
||||
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 "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
|
||||
for t in data.get("workflow_runs", []):
|
||||
if (t.get("workflow_id") == "website.yml"
|
||||
and t.get("name") == "Build & Update Website"
|
||||
and t.get("status") == "success"):
|
||||
print(t.get("head_sha") or "")
|
||||
sys.exit(0)
|
||||
print("")
|
||||
except Exception as e:
|
||||
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||
@@ -130,14 +117,14 @@ jobs:
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created_at=$(curl -sf \
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
|
||||
| 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)
|
||||
if [ -n "$created_at" ]; then
|
||||
queued_epoch=$(date -d "$created_at" +%s)
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||
| 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" ]; then
|
||||
queued_epoch=$(date -d "$created" +%s)
|
||||
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
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
|
||||
@@ -53,3 +53,9 @@ repos:
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
||||
pass_filenames: false
|
||||
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
@@ -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`.
|
||||
@@ -712,6 +712,11 @@ tasks:
|
||||
cmds:
|
||||
- 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:
|
||||
internal: true
|
||||
run: once
|
||||
|
||||
+10
-14
@@ -503,23 +503,19 @@ func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// comparing two freshly-generated outputs.
|
||||
// It reuses the codegenBase() output instead of running build_runner a second time,
|
||||
// diffing committed generated files against the freshly built ones.
|
||||
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
||||
fresh := m.codegenBase().Directory("/src")
|
||||
return m.pubGetLayer().
|
||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
WithExec([]string{"git", "init"}).
|
||||
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
|
||||
WithExec([]string{"git", "config", "user.name", "CI"}).
|
||||
WithExec([]string{"git", "add", "."}).
|
||||
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
|
||||
WithDirectory("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
|
||||
`stale=$(find /committed -name '*.g.dart' -o -name '*.mocks.dart' | ` +
|
||||
`while IFS= read -r f; do rel="${f#/committed/}"; diff -q "$f" "/generated/$rel" >/dev/null 2>&1 || echo "$rel"; done); ` +
|
||||
`if [ -n "$stale" ]; then ` +
|
||||
`echo "ERROR: Generated files are out of date — run: dart run build_runner build"; echo "$stale"; exit 1; ` +
|
||||
`else echo "Generated files are up to date."; fi`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,14 +49,16 @@
|
||||
'';
|
||||
};
|
||||
|
||||
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
|
||||
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
||||
dagger021 = pkgs.stdenv.mkDerivation {
|
||||
# The dagger/nix flake's Nix wrapper is a broken self-exec loop, so we
|
||||
# fetch the CLI binary directly. Keep this version in lockstep with
|
||||
# ci/dagger.json (engineVersion) and .forgejo/Dockerfile (DAGGER_VERSION) —
|
||||
# scripts/check_dagger_versions.sh enforces this.
|
||||
daggerCli = pkgs.stdenv.mkDerivation {
|
||||
pname = "dagger";
|
||||
version = "0.21.4";
|
||||
version = "0.20.8";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
||||
url = "https://dl.dagger.io/dagger/releases/0.20.8/dagger_v0.20.8_linux_amd64.tar.gz";
|
||||
sha256 = "1ns6wq2z1skd2fq9lbrcali0s8kn24p3haamnjjgchg6zlv6b960";
|
||||
};
|
||||
sourceRoot = ".";
|
||||
installPhase = ''
|
||||
@@ -69,7 +71,7 @@
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dagger CLI
|
||||
dagger021
|
||||
daggerCli
|
||||
|
||||
# Go compiler — for Dagger development
|
||||
go
|
||||
|
||||
@@ -74,10 +74,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !isMobile,
|
||||
title: Text(
|
||||
header?.subject ?? '(loading…)',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply),
|
||||
@@ -133,12 +129,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
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>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
||||
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
||||
const PopupMenuItem(
|
||||
value: 'mark_unread',
|
||||
child: Text('Mark as unread'),
|
||||
@@ -166,8 +170,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(_moveTo(context, header));
|
||||
} else if (value == 'snooze' && header != null) {
|
||||
unawaited(_snooze(context, header));
|
||||
} else if (value == 'spam' && header != null) {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
} else if (value == 'mark_unread') {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
|
||||
+59
-51
@@ -5,18 +5,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
||||
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "93.0.0"
|
||||
version: "99.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
||||
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.1"
|
||||
version: "12.1.0"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -165,10 +165,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.2.1"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -237,18 +237,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
||||
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.7"
|
||||
version: "3.1.8"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.14"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -349,10 +349,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
|
||||
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.0-beta.4"
|
||||
version: "12.0.0-beta.5"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -391,34 +391,42 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
||||
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "21.0.0"
|
||||
version: "22.0.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
||||
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.0"
|
||||
version: "8.0.1"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
||||
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
||||
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.1.0"
|
||||
flutter_markdown_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -431,10 +439,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
||||
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.34"
|
||||
version: "2.0.35"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -447,10 +455,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
|
||||
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.3.0"
|
||||
version: "10.3.1"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -526,10 +534,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
||||
sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.3"
|
||||
version: "17.3.0"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -542,10 +550,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "2.0.2"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -675,10 +683,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:
|
||||
@@ -707,10 +715,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
version: "0.19.1"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -723,10 +731,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
version: "9.4.1"
|
||||
open_filex:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1013,13 +1021,13 @@ packages:
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: "direct dev"
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
version: "3.3.2"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1088,10 +1096,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: synchronized
|
||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
||||
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0+1"
|
||||
version: "3.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1104,26 +1112,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:
|
||||
@@ -1288,10 +1296,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_android
|
||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
||||
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.0"
|
||||
version: "4.13.0"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1304,10 +1312,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_wkwebview
|
||||
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
|
||||
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.25.1"
|
||||
version: "3.26.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1381,5 +1389,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.44.0"
|
||||
|
||||
+3
-3
@@ -28,7 +28,7 @@ dependencies:
|
||||
flutter_riverpod: ^3.0.0
|
||||
|
||||
# Navigation
|
||||
go_router: ^17.2.3
|
||||
go_router: ^17.3.0
|
||||
|
||||
# Secure credential storage (passwords)
|
||||
flutter_secure_storage: ^10.0.0
|
||||
@@ -37,7 +37,7 @@ dependencies:
|
||||
intl: ^0.20.2
|
||||
|
||||
# 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
|
||||
mime: ^2.0.0
|
||||
|
||||
@@ -56,7 +56,7 @@ dependencies:
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
|
||||
# Background sync and local notifications
|
||||
flutter_local_notifications: ^21.0.0
|
||||
flutter_local_notifications: ^22.0.0
|
||||
workmanager: ^0.9.0
|
||||
|
||||
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
|
||||
|
||||
Executable
+49
@@ -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)."
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
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/email.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
@@ -682,6 +683,91 @@ void main() {
|
||||
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(
|
||||
'searchAddresses returns results sorted by most recently used',
|
||||
() async {
|
||||
|
||||
@@ -81,7 +81,7 @@ void main() {
|
||||
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');
|
||||
const body = EmailBody(
|
||||
emailId: 'acc-1:42',
|
||||
@@ -106,8 +106,8 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Subject appears in both the app bar and the email header section.
|
||||
expect(find.text('Project update'), findsAtLeastNWidgets(1));
|
||||
// Subject appears only in the email header section, not in the app bar.
|
||||
expect(find.text('Project update'), findsOneWidget);
|
||||
expect(find.text('See attached slides.'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -266,7 +266,7 @@ void main() {
|
||||
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,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -279,19 +279,19 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// No standalone icon button for mark as spam.
|
||||
// Standalone icon button for mark as spam is in the app bar.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(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.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', (
|
||||
@@ -309,11 +309,11 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Open the popup menu first, then tap Mark as spam.
|
||||
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Mark as spam'));
|
||||
await tester.tap(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Tooltip && w.message == 'Mark as spam',
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No spam folder found'), findsOneWidget);
|
||||
|
||||
@@ -446,10 +446,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
of: find.byType(EmailDetailScreen),
|
||||
matching: find.text('Alpha Match'),
|
||||
),
|
||||
findsOneWidget,
|
||||
|
||||
Reference in New Issue
Block a user