Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5954ab1a | ||
|
|
13a0c99f57 | ||
|
|
41c8196a97 | ||
|
|
38f7ada8b5 | ||
|
|
a227f8607c | ||
|
|
5db5d957ab | ||
|
|
0dd1d7232b | ||
|
|
282a64b4c3 | ||
|
|
8e26715658 | ||
|
|
e4cc92867e | ||
|
|
609208247a | ||
|
|
69606ce586 | ||
|
|
9081b452f3 | ||
|
|
b9ccafc709 | ||
|
|
b1e1ac1de7 | ||
|
|
f22f211e8a | ||
|
|
76f2635700 | ||
|
|
e2bb299300 | ||
|
|
f5abe9132b | ||
|
|
d55b316d4c | ||
|
|
f7fd30da15 | ||
|
|
d92cfac761 | ||
|
|
57b266a82b | ||
|
|
b7a8624c38 | ||
|
|
1e2f124cd0 | ||
|
|
916fc4bc6b | ||
|
|
a67b707a41 | ||
|
|
156ccae83b | ||
|
|
9fd30d8f28 | ||
|
|
e22322166c | ||
|
|
913f9e8855 | ||
|
|
65173d323c | ||
|
|
72f634dd90 | ||
|
|
4712e768ea | ||
|
|
7985caa9b4 | ||
|
|
e28996cf86 | ||
|
|
d994723a2d | ||
|
|
145346c18a | ||
|
|
f3e1ca13de | ||
|
|
d86ce7766c | ||
|
|
f88d14f362 | ||
|
|
3e2da2bdf8 | ||
|
|
6a60c8d73b | ||
|
|
985bac7022 | ||
|
|
aed0d63703 |
@@ -0,0 +1,20 @@
|
||||
name: Chaos Monkey
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
chaos-monkey-backend:
|
||||
name: Chaos Monkey (backend)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Dagger Remote Engine
|
||||
env:
|
||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
- name: Run backend chaos monkey
|
||||
run: task chaos-monkey-backend
|
||||
@@ -1,11 +1,35 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Dagger Remote Engine
|
||||
env:
|
||||
|
||||
@@ -15,6 +15,23 @@ jobs:
|
||||
linux: ${{ steps.diff.outputs.linux }}
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -34,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})")
|
||||
@@ -141,6 +142,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -175,6 +193,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -203,6 +238,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.linux == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
@@ -236,6 +288,23 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -14,6 +14,23 @@ jobs:
|
||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -50,6 +67,23 @@ jobs:
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -12,12 +12,122 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Detect Website Changes
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect website changes since last deploy
|
||||
id: diff
|
||||
shell: bash
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# On push or workflow_dispatch always deploy
|
||||
if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# 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", "")
|
||||
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, timeout=60) as r:
|
||||
data = json.loads(r.read())
|
||||
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})")
|
||||
print("")
|
||||
PYEOF
|
||||
)
|
||||
|
||||
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||
echo "::warning::Could not determine last successfully deployed SHA — deploying as a precaution"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||
echo "::notice::Website deploy SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff from last successfully deployed commit to catch all changes since
|
||||
# that deploy, not just the most recent commit.
|
||||
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
else
|
||||
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying as a precaution"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
website_re='^(website/|scripts/website-verify\.sh|\.forgejo/workflows/website\.yml)'
|
||||
|
||||
if echo "$CHANGED" | grep -qE "$website_re"; then
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Website deploy TRIGGERED — website-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||
else
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Website deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no website-relevant changes"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
name: Build & Update Website
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
|
||||
steps:
|
||||
- name: Print runner wait time
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
runner_start=$(date +%s)
|
||||
created=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
"${{ 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)"
|
||||
else
|
||||
echo "Runner wait time: unknown (API lookup failed)"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
@@ -13,23 +13,27 @@ Automation is handled by [agentloop](https://github.com/guettli/agentloop) runni
|
||||
| Label | Trigger | Outcome |
|
||||
|---|---|---|
|
||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
|
||||
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
|
||||
|
||||
**State machine:**
|
||||
|
||||
```
|
||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
loop/plan → loop/plan-in-process → loop/plan-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/code → loop/code-in-progress → loop/code-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
loop/code → loop/code-in-process → loop/merge (via route)
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/merge → loop/merge-in-process → loop/merge-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
||||
- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
|
||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
||||
|
||||
@@ -39,9 +43,9 @@ loop/code → loop/code-in-progress → loop/code-done
|
||||
1. Create issue
|
||||
2. Add label loop/plan → agent writes plan as comment
|
||||
3. Review plan, request changes or approve
|
||||
4. Add label loop/code → agent implements + opens PR
|
||||
5. Review PR, merge
|
||||
6. Close issue
|
||||
4. Add label loop/code → agent implements + opens PR + hands off to merge
|
||||
5. (Optional) Review PR before it merges
|
||||
6. Merge agent waits for CI and merges the PR automatically
|
||||
```
|
||||
|
||||
## Code conventions
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Plan: Consolidate Email-List UI
|
||||
|
||||
## Goal
|
||||
|
||||
Three list surfaces — folder view (`EmailListScreen`), combined inbox (`CombinedInboxScreen`), and search results (in `SearchScreen` plus the in-screen search in `EmailListScreen`, plus `AddressEmailsScreen`) — currently duplicate selection state, swipe-dismiss handling, batch actions (archive/delete/spam/move/snooze), and tile rendering. Unify into one widget so behaviour and UI are identical everywhere. Thread detail view is intentionally out of scope.
|
||||
|
||||
## Current duplication
|
||||
|
||||
| Concern | `EmailListScreen` | `CombinedInboxScreen` | `SearchScreen` / `AddressEmailsScreen` |
|
||||
| --- | --- | --- | --- |
|
||||
| Tile widget | `EmailThreadTile` + `ThreadTile` (search) | `EmailThreadTile` | `ThreadTile` / inline `ListTile` |
|
||||
| Selection set | thread + per-email | thread only | none |
|
||||
| Selection AppBar/BottomBar | full set of 5 actions | archive + delete only | n/a |
|
||||
| Swipe dismiss | archive/delete + undo | archive/delete + undo (copy) | none |
|
||||
| Batch actions | archive, delete, spam, move, snooze | archive, delete (re-implemented) | none |
|
||||
|
||||
Tile widgets `lib/ui/widgets/email_thread_tile.dart` and `lib/ui/widgets/thread_tile.dart` render the same fields in slightly different layouts.
|
||||
|
||||
## Target architecture
|
||||
|
||||
### 1. New widget `lib/ui/widgets/email_thread_list.dart`
|
||||
|
||||
A self-contained `ConsumerStatefulWidget` that owns selection state and renders the list. API:
|
||||
|
||||
```dart
|
||||
EmailThreadList({
|
||||
required Stream<List<EmailThread>> threads, // folder + combined inbox
|
||||
// or:
|
||||
required List<EmailThread> staticThreads, // search / address results
|
||||
required EmailListContext listContext, // see below
|
||||
bool showAccountLabel = false, // combined inbox
|
||||
bool showLocationLabel = false, // search / cross-mailbox
|
||||
bool enableSwipe = true,
|
||||
bool enablePagination = true,
|
||||
List<EmailBatchAction> actions = EmailBatchAction.standard,
|
||||
ValueChanged<EmailThread>? onTap, // null → default navigation
|
||||
})
|
||||
```
|
||||
|
||||
`EmailListContext` carries `accountId?` (nullable for combined/global views) and `mailboxPath?` (nullable for combined/global views). Batch actions read these to scope role lookups and undo-action source paths; when null they fall back to per-thread `t.accountId` / `t.mailboxPath` (this is how `CombinedInboxScreen._batchArchive` already groups by account).
|
||||
|
||||
Encapsulated:
|
||||
- `_selectedThreadIds` plus toggle/clear/select-all helpers.
|
||||
- `_currentThreads` (last stream emission).
|
||||
- `_limit` pagination with `Load more`.
|
||||
- Selection-mode `AppBar` and `BottomAppBar` rendering — driven by the host scaffold via two builders the widget exposes (`buildSelectionAppBar`, `buildSelectionBottomBar`) so the host keeps Scaffold ownership but doesn't reimplement them.
|
||||
- Swipe-to-archive / swipe-to-delete + undo push.
|
||||
|
||||
### 2. Shared action layer `lib/ui/screens/email_action_helpers.dart`
|
||||
|
||||
Extend the existing file with the batch ops currently duplicated:
|
||||
|
||||
```dart
|
||||
enum EmailBatchAction { archive, delete, markSpam, move, snooze }
|
||||
|
||||
Future<void> batchMoveToRole(WidgetRef ref, BuildContext ctx, {
|
||||
required List<EmailThread> threads,
|
||||
required String role,
|
||||
required String dialogTitle,
|
||||
required String createFolderName,
|
||||
});
|
||||
|
||||
Future<void> batchDelete(WidgetRef ref, {required List<EmailThread> threads});
|
||||
Future<void> batchMove(WidgetRef ref, BuildContext ctx, {required List<EmailThread> threads});
|
||||
Future<void> batchSnooze(WidgetRef ref, BuildContext ctx, {required List<EmailThread> threads});
|
||||
Future<void> swipeDismiss(WidgetRef ref, EmailThread thread, DismissDirection dir);
|
||||
```
|
||||
|
||||
Each function fetches `originalEmails`, runs the repo calls, and pushes a single `UndoAction`. Grouping by `accountId` lives here so combined-inbox-style multi-account selections work for every action (not only archive/delete). `_batchMoveToRole` from `EmailListScreen` and `_batchArchive` from `CombinedInboxScreen` collapse into one function.
|
||||
|
||||
### 3. Unify the tile widgets
|
||||
|
||||
Keep `ThreadTile` (`lib/ui/widgets/thread_tile.dart`) as the single tile. Move the `Dismissible` wrapper out — `EmailThreadList` owns swipe — and add the optional `showAccount` subtitle currently in `EmailThreadTile`. Delete `lib/ui/widgets/email_thread_tile.dart`.
|
||||
|
||||
## Screen refactors
|
||||
|
||||
- `combined_inbox_screen.dart` — drop selection state, swipe handler, batch methods, `_buildThreadList`, `_selectionBottomBar`. Replace body with `EmailThreadList(stream: emailRepo.observeAllInboxThreads(...), listContext: const EmailListContext.allAccounts(), showAccountLabel: true)`. AppBar/drawer/FAB stay.
|
||||
- `email_list_screen.dart` — keep search-bar, sync banner, folder drawer, `Mark all as read`. Replace `_buildStreamBody` and `_buildEmailList` with `EmailThreadList`. Drop selection state, `_toggleThreadSelection`, `_selectionBottomBar`, `_batch*` methods, `_onSwipeDismissed`. The search path inside this screen (results from `searchEmails`) becomes `EmailThreadList(staticThreads: results.map(EmailThread.fromEmail).toList(), enableSwipe: false)` — the per-email vs per-thread split goes away once everything is treated as a thread of one.
|
||||
- `search_screen.dart` — Messages section uses `EmailThreadList(staticThreads: ..., enableSwipe: false, showLocationLabel: true, actions: EmailBatchAction.standard)`, so global search results now support the same selection + batch actions. Folders and Addresses sections unchanged.
|
||||
- `address_emails_screen.dart` — replace inline `ListView.builder` with `EmailThreadList`, gaining selection/swipe/batch parity.
|
||||
|
||||
## Migration steps
|
||||
|
||||
1. Add `EmailThreadList` widget with selection, swipe, pagination, and AppBar/BottomBar builders. Lift the existing logic verbatim from `EmailListScreen` so behaviour is unchanged.
|
||||
2. Promote the five batch ops + swipe handler into `email_action_helpers.dart`; switch `EmailListScreen` to call them. Keep tile tests passing.
|
||||
3. Fold `showAccount` and `Dismissible`-out into `ThreadTile`; delete `EmailThreadTile`; update imports.
|
||||
4. Migrate `CombinedInboxScreen` to `EmailThreadList`. Combined inbox now supports spam/move/snooze (was missing). Verify multi-account batches still group correctly.
|
||||
5. Migrate the search-result branch inside `EmailListScreen`, then the Messages section of `SearchScreen`, then `AddressEmailsScreen`.
|
||||
6. Run `flutter analyze` and the integration tests under `integration_test/` (folder, combined inbox, and search exercise the same code path now, so a single regression test set covers all surfaces).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `ThreadDetailScreen` — single-thread message list, intentionally different.
|
||||
- Repository / DB code in `lib/core/repositories/` — no changes; the unification is purely on the UI layer.
|
||||
- Folder drawer, sync banner, search bar — remain owned by their hosting screens.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- Combined inbox currently has no `mailboxPath` per selection — confirmed handled by grouping selected threads by `accountId` then looking up archive/junk/etc. per group. The same grouping must work for the move/snooze sheet (the destination picker needs an account; when multiple accounts are selected, prompt per-account or block — recommend block with a SnackBar to mirror existing per-folder constraints; flag for user feedback).
|
||||
- Snooze for cross-account selection: same constraint as above — implementation should iterate accounts.
|
||||
- Swipe-dismiss in search results: currently disabled in `SearchScreen` and `AddressEmailsScreen`. Plan keeps `enableSwipe: false` for those to avoid disorienting users when a swiped item disappears from a filtered list; revisit if user wants parity.
|
||||
@@ -58,6 +58,14 @@ tasks:
|
||||
cmds:
|
||||
- echo "Setup complete."
|
||||
|
||||
generate-icons:
|
||||
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
|
||||
deps: [_pub-get]
|
||||
cmds:
|
||||
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
|
||||
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
|
||||
- fvm flutter pub run flutter_launcher_icons
|
||||
|
||||
generate-changelog:
|
||||
desc: Generate assets/changelog.txt from git history
|
||||
cmds:
|
||||
@@ -521,13 +529,6 @@ tasks:
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
|
||||
build-android-bundle-local:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
@@ -542,6 +543,13 @@ tasks:
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
|
||||
deploy-android:
|
||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||
deps: [check, build-android]
|
||||
@@ -722,6 +730,11 @@ tasks:
|
||||
cmds:
|
||||
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||
|
||||
chaos-monkey-backend:
|
||||
desc: Chaos monkey — random IMAP/SMTP ops against Stalwart (via Dagger, headless)
|
||||
cmds:
|
||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. chaos-monkey-backend
|
||||
|
||||
check:
|
||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||
deps: [analyze, build-linux, test]
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 25 KiB |
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ func (m *Ci) Stalwart() *dagger.Service {
|
||||
return dag.Container().
|
||||
From("stalwartlabs/stalwart:v0.14.1").
|
||||
WithFile("/etc/stalwart/config.toml.orig", config).
|
||||
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' -e 's/bind = \\[\"0.0.0.0:\\([0-9]*\\)\"\\]/bind = [\"0.0.0.0:\\1\", \"[::]:\\1\"]/g' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
||||
WithExec([]string{"/bin/sh", "-c", "sed -e 's/hostname = \"localhost\"/hostname = \"stalwart\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
||||
WithDirectory("/tmp/stalwart", dataDir).
|
||||
WithExposedPort(8080). // JMAP
|
||||
WithExposedPort(1430). // IMAP
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -539,7 +535,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
@@ -565,6 +561,16 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
|
||||
func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Check runs the full check suite.
|
||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
@@ -584,25 +590,33 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
checkSetup := m.setup(m.checkSrc())
|
||||
|
||||
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
||||
return "Format check failed", err
|
||||
}
|
||||
|
||||
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||
if err != nil {
|
||||
return analyze, err
|
||||
}
|
||||
|
||||
mocks, err := m.CheckGenerated(ctx)
|
||||
if err != nil {
|
||||
return mocks, err
|
||||
}
|
||||
|
||||
coverage, err := m.Coverage(ctx)
|
||||
if err != nil {
|
||||
return coverage, err
|
||||
// Run format, analyze, generated-code check, and coverage in parallel —
|
||||
// they all share the same setup base and have no dependencies on each other.
|
||||
var analyze, mocks, coverage string
|
||||
var checkEg errgroup.Group
|
||||
checkEg.Go(func() error {
|
||||
setup := m.setup(m.checkSrc())
|
||||
_, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx)
|
||||
return err
|
||||
})
|
||||
checkEg.Go(func() error {
|
||||
setup := m.setup(m.checkSrc())
|
||||
var err error
|
||||
analyze, err = setup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||
return err
|
||||
})
|
||||
checkEg.Go(func() error {
|
||||
var err error
|
||||
mocks, err = m.CheckGenerated(ctx)
|
||||
return err
|
||||
})
|
||||
checkEg.Go(func() error {
|
||||
var err error
|
||||
coverage, err = m.Coverage(ctx)
|
||||
return err
|
||||
})
|
||||
if err := checkEg.Wait(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Use errgroup.Group (not WithContext) so a failing test does not cancel its
|
||||
|
||||
@@ -48,11 +48,28 @@
|
||||
chmod +x $out/bin/fgj
|
||||
'';
|
||||
};
|
||||
|
||||
# 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 {
|
||||
pname = "dagger";
|
||||
version = "0.21.4";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
||||
};
|
||||
sourceRoot = ".";
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp dagger $out/bin/dagger
|
||||
chmod +x $out/bin/dagger
|
||||
'';
|
||||
};
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dagger CLI
|
||||
dagger.packages.${system}.dagger
|
||||
dagger021
|
||||
|
||||
# Go compiler — for Dagger development
|
||||
go
|
||||
@@ -100,12 +117,16 @@
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||
export IN_NIX_SHELL=1
|
||||
|
||||
# Point Dagger client at the running engine socket
|
||||
export DAGGER_HOST=unix:///run/dagger/engine.sock
|
||||
|
||||
# Disable Flutter telemetry inside dev shell
|
||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 39;
|
||||
const int dbSchemaVersion = 41;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
enum FilterField {
|
||||
from_,
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
size;
|
||||
|
||||
String get label => switch (this) {
|
||||
FilterField.from_ => 'From',
|
||||
FilterField.to => 'To',
|
||||
FilterField.cc => 'CC',
|
||||
FilterField.subject => 'Subject',
|
||||
FilterField.size => 'Size (bytes)',
|
||||
};
|
||||
|
||||
List<FilterComparison> get allowedComparisons => switch (this) {
|
||||
FilterField.size => [FilterComparison.over, FilterComparison.under],
|
||||
_ => [
|
||||
FilterComparison.contains,
|
||||
FilterComparison.is_,
|
||||
FilterComparison.matches,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
enum FilterComparison {
|
||||
contains,
|
||||
is_,
|
||||
matches,
|
||||
over,
|
||||
under;
|
||||
|
||||
String get label => switch (this) {
|
||||
FilterComparison.contains => 'contains',
|
||||
FilterComparison.is_ => 'is',
|
||||
FilterComparison.matches => 'matches',
|
||||
FilterComparison.over => 'over',
|
||||
FilterComparison.under => 'under',
|
||||
};
|
||||
}
|
||||
|
||||
enum FilterOperator { and_, or_ }
|
||||
|
||||
sealed class FilterNode {}
|
||||
|
||||
final class FilterLeaf extends FilterNode {
|
||||
FilterLeaf({
|
||||
required this.field,
|
||||
required this.comparison,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final FilterField field;
|
||||
final FilterComparison comparison;
|
||||
final String value;
|
||||
|
||||
FilterLeaf copyWith({
|
||||
FilterField? field,
|
||||
FilterComparison? comparison,
|
||||
String? value,
|
||||
}) =>
|
||||
FilterLeaf(
|
||||
field: field ?? this.field,
|
||||
comparison: comparison ?? this.comparison,
|
||||
value: value ?? this.value,
|
||||
);
|
||||
}
|
||||
|
||||
final class FilterGroup extends FilterNode {
|
||||
FilterGroup({required this.operator, required this.children});
|
||||
|
||||
final FilterOperator operator;
|
||||
final List<FilterNode> children;
|
||||
|
||||
bool get isEmpty => children.isEmpty;
|
||||
|
||||
FilterGroup copyWith({
|
||||
FilterOperator? operator,
|
||||
List<FilterNode>? children,
|
||||
}) =>
|
||||
FilterGroup(
|
||||
operator: operator ?? this.operator,
|
||||
children: children ?? this.children,
|
||||
);
|
||||
|
||||
static FilterGroup empty() =>
|
||||
FilterGroup(operator: FilterOperator.and_, children: []);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
|
||||
/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions,
|
||||
/// suitable for display in the visual filter editor.
|
||||
///
|
||||
/// Returns null if the script uses features outside the supported subset.
|
||||
class FilterSieveConverter {
|
||||
({FilterGroup group, List<SieveAction> actions})? parse(String script) {
|
||||
try {
|
||||
final s = _Sc(script);
|
||||
s.skip();
|
||||
if (s.peekWord() == 'require') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
_parseStringOrList(s);
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
s.skip();
|
||||
}
|
||||
if (s.peekWord() != 'if') return null;
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final node = _parseTest(s);
|
||||
if (node == null) return null;
|
||||
s.skip();
|
||||
s.expectChar('{');
|
||||
s.skip();
|
||||
final actions = <SieveAction>[];
|
||||
while (s.peek() != '}' && !s.isAtEnd) {
|
||||
final action = _parseAction(s);
|
||||
if (action == null) return null;
|
||||
actions.add(action);
|
||||
s.skip();
|
||||
}
|
||||
s.expectChar('}');
|
||||
final group = switch (node) {
|
||||
final FilterGroup g => g,
|
||||
final FilterLeaf l =>
|
||||
FilterGroup(operator: FilterOperator.and_, children: [l]),
|
||||
};
|
||||
return (group: group, actions: actions);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
FilterNode? _parseTest(_Sc s) {
|
||||
s.skip();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
if (word == 'allof' || word == 'anyof') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
s.expectChar('(');
|
||||
final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_;
|
||||
final children = <FilterNode>[];
|
||||
while (true) {
|
||||
s.skip();
|
||||
if (s.peek() == ')') break;
|
||||
final child = _parseTest(s);
|
||||
if (child == null) return null;
|
||||
children.add(child);
|
||||
s.skip();
|
||||
if (s.peek() == ',') s.advance();
|
||||
}
|
||||
s.expectChar(')');
|
||||
return FilterGroup(operator: op, children: children);
|
||||
}
|
||||
return _parseSingleTest(s);
|
||||
}
|
||||
|
||||
FilterLeaf? _parseSingleTest(_Sc s) {
|
||||
s.skip();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
|
||||
if (word == 'address') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final matchType = s.readTaggedArg();
|
||||
s.skip();
|
||||
final headers = _parseStringOrList(s);
|
||||
s.skip();
|
||||
final values = _parseStringOrList(s);
|
||||
final field = switch (headers.firstOrNull?.toLowerCase()) {
|
||||
'from' => FilterField.from_,
|
||||
'to' => FilterField.to,
|
||||
'cc' => FilterField.cc,
|
||||
_ => null,
|
||||
};
|
||||
if (field == null) return null;
|
||||
final comp = _comp(matchType);
|
||||
if (comp == null) return null;
|
||||
return FilterLeaf(
|
||||
field: field,
|
||||
comparison: comp,
|
||||
value: values.firstOrNull ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
if (word == 'header') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final matchType = s.readTaggedArg();
|
||||
s.skip();
|
||||
final headers = _parseStringOrList(s);
|
||||
s.skip();
|
||||
final values = _parseStringOrList(s);
|
||||
if (headers.firstOrNull?.toLowerCase() != 'subject') return null;
|
||||
final comp = _comp(matchType);
|
||||
if (comp == null) return null;
|
||||
return FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: comp,
|
||||
value: values.firstOrNull ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
if (word == 'size') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final compTag = s.readTaggedArg();
|
||||
s.skip();
|
||||
final numStr = s.readDigits();
|
||||
final comp = switch (compTag.toLowerCase()) {
|
||||
':over' => FilterComparison.over,
|
||||
':under' => FilterComparison.under,
|
||||
_ => null,
|
||||
};
|
||||
if (comp == null) return null;
|
||||
return FilterLeaf(
|
||||
field: FilterField.size,
|
||||
comparison: comp,
|
||||
value: numStr,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) {
|
||||
':contains' => FilterComparison.contains,
|
||||
':is' => FilterComparison.is_,
|
||||
':matches' => FilterComparison.matches,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
SieveAction? _parseAction(_Sc s) {
|
||||
s.skip();
|
||||
final word = s.peekWord()?.toLowerCase();
|
||||
if (word == null) return null;
|
||||
if (word == 'fileinto') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final folder = _parseString(s);
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
return FileIntoAction(folder);
|
||||
}
|
||||
if (word == 'keep') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
return KeepAction();
|
||||
}
|
||||
if (word == 'discard') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
return DiscardAction();
|
||||
}
|
||||
if (word == 'setflag' || word == 'addflag') {
|
||||
s.readWord();
|
||||
s.skip();
|
||||
final flags = _parseStringOrList(s);
|
||||
s.skip();
|
||||
s.expectChar(';');
|
||||
if (flags.any(
|
||||
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
|
||||
)) {
|
||||
return MarkAsSeenAction();
|
||||
}
|
||||
return FlagAction(flags);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _parseStringOrList(_Sc s) {
|
||||
s.skip();
|
||||
if (s.peek() == '[') {
|
||||
s.advance();
|
||||
final items = <String>[];
|
||||
while (true) {
|
||||
s.skip();
|
||||
if (s.peek() == ']') {
|
||||
s.advance();
|
||||
break;
|
||||
}
|
||||
items.add(_parseString(s));
|
||||
s.skip();
|
||||
if (s.peek() == ',') s.advance();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
return [_parseString(s)];
|
||||
}
|
||||
|
||||
String _parseString(_Sc s) {
|
||||
s.skip();
|
||||
return s.readQuotedString();
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal scanner for the supported Sieve subset.
|
||||
class _Sc {
|
||||
_Sc(this._src);
|
||||
final String _src;
|
||||
int _pos = 0;
|
||||
|
||||
bool get isAtEnd => _pos >= _src.length;
|
||||
String? peek() => isAtEnd ? null : _src[_pos];
|
||||
|
||||
String advance() {
|
||||
if (isAtEnd) throw _ScanErr('Unexpected end');
|
||||
return _src[_pos++];
|
||||
}
|
||||
|
||||
void skip() {
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
|
||||
_pos++;
|
||||
} else if (ch == '#') {
|
||||
while (!isAtEnd && _src[_pos] != '\n') {
|
||||
_pos++;
|
||||
}
|
||||
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
|
||||
_pos += 2;
|
||||
while (_pos + 1 < _src.length) {
|
||||
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
|
||||
_pos += 2;
|
||||
break;
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? peekWord() {
|
||||
if (isAtEnd) return null;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) return ch;
|
||||
if (ch == ':') {
|
||||
var end = _pos + 1;
|
||||
while (end < _src.length && _wc(_src[end])) {
|
||||
end++;
|
||||
}
|
||||
return _src.substring(_pos, end).toLowerCase();
|
||||
}
|
||||
if (_wc(ch)) {
|
||||
var end = _pos + 1;
|
||||
while (end < _src.length && _wc(_src[end])) {
|
||||
end++;
|
||||
}
|
||||
return _src.substring(_pos, end).toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String readWord() {
|
||||
final start = _pos;
|
||||
final ch = _src[_pos];
|
||||
if ('{}();[],'.contains(ch)) {
|
||||
_pos++;
|
||||
return ch;
|
||||
}
|
||||
if (ch == ':') {
|
||||
_pos++;
|
||||
while (!isAtEnd && _wc(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
} else {
|
||||
while (!isAtEnd && _wc(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
return _src.substring(start, _pos).toLowerCase();
|
||||
}
|
||||
|
||||
String readTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||
throw _ScanErr('Expected tagged arg at $_pos');
|
||||
}
|
||||
|
||||
String readDigits() {
|
||||
final start = _pos;
|
||||
while (!isAtEnd && _dig(_src[_pos])) {
|
||||
_pos++;
|
||||
}
|
||||
if (_pos == start) throw _ScanErr('Expected digits at $_pos');
|
||||
return _src.substring(start, _pos);
|
||||
}
|
||||
|
||||
String readQuotedString() {
|
||||
if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos');
|
||||
_pos++;
|
||||
final buf = StringBuffer();
|
||||
while (!isAtEnd) {
|
||||
final ch = _src[_pos];
|
||||
if (ch == '"') {
|
||||
_pos++;
|
||||
return buf.toString();
|
||||
}
|
||||
if (ch == '\\' && _pos + 1 < _src.length) {
|
||||
_pos++;
|
||||
buf.write(_src[_pos]);
|
||||
_pos++;
|
||||
} else {
|
||||
buf.write(ch);
|
||||
_pos++;
|
||||
}
|
||||
}
|
||||
throw _ScanErr('Unterminated string');
|
||||
}
|
||||
|
||||
void expectChar(String ch) {
|
||||
skip();
|
||||
if (isAtEnd || _src[_pos] != ch) {
|
||||
throw _ScanErr(
|
||||
'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}',
|
||||
);
|
||||
}
|
||||
_pos++;
|
||||
}
|
||||
|
||||
static bool _wc(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return (c >= 0x41 && c <= 0x5A) ||
|
||||
(c >= 0x61 && c <= 0x7A) ||
|
||||
(c >= 0x30 && c <= 0x39) ||
|
||||
c == 0x5F ||
|
||||
c == 0x2D;
|
||||
}
|
||||
|
||||
static bool _dig(String ch) {
|
||||
final c = ch.codeUnitAt(0);
|
||||
return c >= 0x30 && c <= 0x39;
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanErr implements Exception {
|
||||
_ScanErr(this.message);
|
||||
final String message;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
|
||||
abstract class EmailRepository {
|
||||
@@ -58,9 +59,15 @@ abstract class EmailRepository {
|
||||
);
|
||||
|
||||
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
||||
/// if null) by subject and preview. Fast, works offline.
|
||||
/// if null) by subject, preview, and notes. Fast, works offline.
|
||||
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
|
||||
|
||||
/// Searches the local DB using a structured [FilterGroup]. Fast, works offline.
|
||||
Future<List<Email>> searchEmailsStructured(
|
||||
String? accountId,
|
||||
FilterGroup filter,
|
||||
);
|
||||
|
||||
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
|
||||
/// A lightweight email representation used by [SieveInterpreter].
|
||||
/// Header names are lower-cased.
|
||||
@@ -102,18 +103,11 @@ class SieveInterpreter {
|
||||
return switch (matchType) {
|
||||
':contains' => k.isEmpty || v.contains(k),
|
||||
':is' => v == k,
|
||||
':matches' => _globMatch(v, k),
|
||||
':matches' => globMatch(v, k),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
bool _globMatch(String value, String pattern) {
|
||||
final regexStr = RegExp.escape(
|
||||
pattern,
|
||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$').hasMatch(value);
|
||||
}
|
||||
|
||||
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
||||
for (final action in actions) {
|
||||
switch (action) {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
|
||||
/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script
|
||||
/// (RFC 5228 subset).
|
||||
class SieveSerializer {
|
||||
String serialize(FilterGroup filter, List<SieveAction> actions) {
|
||||
final buf = StringBuffer();
|
||||
final requires = _collectRequires(actions);
|
||||
if (requires.isNotEmpty) {
|
||||
buf.writeln(
|
||||
'require [${requires.map((r) => '"$r"').join(', ')}];',
|
||||
);
|
||||
}
|
||||
if (filter.isEmpty) {
|
||||
for (final a in actions) {
|
||||
buf.writeln(_serializeAction(a));
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
buf.write('if ');
|
||||
buf.write(_serializeNode(filter));
|
||||
buf.writeln(' {');
|
||||
for (final a in actions) {
|
||||
buf.writeln(' ${_serializeAction(a)}');
|
||||
}
|
||||
buf.writeln('}');
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
List<String> _collectRequires(List<SieveAction> actions) {
|
||||
final req = <String>[];
|
||||
for (final a in actions) {
|
||||
if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto');
|
||||
if ((a is FlagAction || a is MarkAsSeenAction) &&
|
||||
!req.contains('imap4flags')) {
|
||||
req.add('imap4flags');
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
String _serializeNode(FilterNode node) => switch (node) {
|
||||
final FilterLeaf leaf => _serializeLeaf(leaf),
|
||||
final FilterGroup group => _serializeGroup(group),
|
||||
};
|
||||
|
||||
String _serializeGroup(FilterGroup group) {
|
||||
if (group.isEmpty) return 'true';
|
||||
if (group.children.length == 1) return _serializeNode(group.children.first);
|
||||
final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof';
|
||||
final parts = group.children.map(_serializeNode).join(',\n ');
|
||||
return '$op(\n $parts\n)';
|
||||
}
|
||||
|
||||
String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) {
|
||||
FilterField.from_ ||
|
||||
FilterField.to ||
|
||||
FilterField.cc =>
|
||||
_serializeAddressLeaf(leaf),
|
||||
FilterField.subject => _serializeHeaderLeaf(leaf),
|
||||
FilterField.size => _serializeSizeLeaf(leaf),
|
||||
};
|
||||
|
||||
String _serializeAddressLeaf(FilterLeaf leaf) {
|
||||
final header = switch (leaf.field) {
|
||||
FilterField.from_ => 'from',
|
||||
FilterField.to => 'to',
|
||||
FilterField.cc => 'cc',
|
||||
_ => throw StateError('not an address field'),
|
||||
};
|
||||
return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"';
|
||||
}
|
||||
|
||||
String _serializeHeaderLeaf(FilterLeaf leaf) =>
|
||||
'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"';
|
||||
|
||||
String _serializeSizeLeaf(FilterLeaf leaf) {
|
||||
final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under';
|
||||
return 'size $comp ${leaf.value}';
|
||||
}
|
||||
|
||||
String _matchType(FilterComparison comp) => switch (comp) {
|
||||
FilterComparison.contains => ':contains',
|
||||
FilterComparison.is_ => ':is',
|
||||
FilterComparison.matches => ':matches',
|
||||
_ => ':contains',
|
||||
};
|
||||
|
||||
String _serializeAction(SieveAction action) => switch (action) {
|
||||
final FileIntoAction a => 'fileinto "${_esc(a.folder)}";',
|
||||
KeepAction() => 'keep;',
|
||||
DiscardAction() => 'discard;',
|
||||
MarkAsSeenAction() => r'setflag "\\Seen";',
|
||||
final FlagAction a =>
|
||||
'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];',
|
||||
};
|
||||
|
||||
String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/// Returns true if [value] matches the glob [pattern].
|
||||
///
|
||||
/// Supports `*` (any number of characters) and `?` (exactly one character).
|
||||
/// The comparison is case-insensitive, which is appropriate for email addresses.
|
||||
bool globMatch(String value, String pattern) {
|
||||
final regexStr =
|
||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$', caseSensitive: false).hasMatch(value);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' show Database;
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
@@ -338,6 +339,17 @@ class EmailNotes extends Table {
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Records the first time the user ran each app version (identified by GIT_HASH).
|
||||
/// Added in schema v40.
|
||||
@DataClassName('InstalledVersionRow')
|
||||
class InstalledVersions extends Table {
|
||||
TextColumn get gitHash => text()();
|
||||
DateTimeColumn get installedAt => dateTime()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {gitHash};
|
||||
}
|
||||
|
||||
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||
@DataClassName('UserPreferencesRow')
|
||||
class UserPreferences extends Table {
|
||||
@@ -384,6 +396,7 @@ class UserPreferences extends Table {
|
||||
UserPreferences,
|
||||
ImageTrustedSenders,
|
||||
EmailNotes,
|
||||
InstalledVersions,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -663,8 +676,140 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 39) {
|
||||
await m.createTable(emailNotes);
|
||||
}
|
||||
if (from < 40) {
|
||||
await m.createTable(installedVersions);
|
||||
}
|
||||
if (from < 41) {
|
||||
// Fix IMAP email IDs to include mailboxPath, preventing UID
|
||||
// collisions across mailboxes (IMAP UIDs are mailbox-scoped).
|
||||
// New format: "accountId:mailboxPath:uid" (was "accountId:uid").
|
||||
//
|
||||
// defer_foreign_keys defers the email_bodies→emails FK check
|
||||
// to COMMIT so the two tables can be updated sequentially inside
|
||||
// the migration transaction without a transient FK violation.
|
||||
await customStatement('PRAGMA defer_foreign_keys = ON');
|
||||
|
||||
// 1. Remap email_bodies.email_id before emails.id changes.
|
||||
await customStatement('''
|
||||
UPDATE email_bodies
|
||||
SET email_id = (
|
||||
SELECT e.account_id || ':' || e.mailbox_path || ':' || CAST(e.uid AS TEXT)
|
||||
FROM emails e
|
||||
JOIN accounts a ON a.id = e.account_id
|
||||
WHERE e.id = email_bodies.email_id
|
||||
AND a.account_type = 'imap'
|
||||
)
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM emails e
|
||||
JOIN accounts a ON a.id = e.account_id
|
||||
WHERE e.id = email_bodies.email_id
|
||||
AND a.account_type = 'imap'
|
||||
)
|
||||
''');
|
||||
|
||||
// 2. Update emails.thread_id where it was set to the email's own
|
||||
// id (fallback for messages with no Message-ID header).
|
||||
await customStatement('''
|
||||
UPDATE emails
|
||||
SET thread_id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
|
||||
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
|
||||
AND thread_id = id
|
||||
''');
|
||||
|
||||
// 3. Update the primary key on emails.
|
||||
await customStatement('''
|
||||
UPDATE emails
|
||||
SET id = account_id || ':' || mailbox_path || ':' || CAST(uid AS TEXT)
|
||||
WHERE account_id IN (
|
||||
SELECT id FROM accounts WHERE account_type = 'imap'
|
||||
)
|
||||
''');
|
||||
|
||||
// 5. Rebuild threads for IMAP accounts from the updated email rows.
|
||||
// The threads table stores denormalised data (latest_email_id,
|
||||
// email_ids_json) that references email IDs, so it is simpler to
|
||||
// delete and reconstruct than to patch the JSON in SQL.
|
||||
await customStatement('''
|
||||
DELETE FROM threads
|
||||
WHERE account_id IN (SELECT id FROM accounts WHERE account_type = 'imap')
|
||||
''');
|
||||
|
||||
final imapAccounts = await (select(accounts)
|
||||
..where((t) => t.accountType.equals('imap')))
|
||||
.get();
|
||||
for (final acct in imapAccounts) {
|
||||
final emailRows = await (select(emails)
|
||||
..where((t) => t.accountId.equals(acct.id)))
|
||||
.get();
|
||||
|
||||
final groups = <String, List<Email>>{};
|
||||
for (final row in emailRows) {
|
||||
final key = '${row.mailboxPath}:${row.threadId ?? row.id}';
|
||||
groups.putIfAbsent(key, () => []).add(row);
|
||||
}
|
||||
|
||||
for (final threadEmails in groups.values) {
|
||||
threadEmails.sort((a, b) {
|
||||
final da = a.sentAt ?? a.receivedAt;
|
||||
final db = b.sentAt ?? b.receivedAt;
|
||||
return da.compareTo(db);
|
||||
});
|
||||
final latest = threadEmails.last;
|
||||
|
||||
final seen = <String>{};
|
||||
final participants = <Map<String, dynamic>>[];
|
||||
for (final e in threadEmails) {
|
||||
final from = jsonDecode(e.fromJson) as List<dynamic>;
|
||||
for (final a in from.cast<Map<String, dynamic>>()) {
|
||||
final email = a['email'] as String;
|
||||
if (seen.add(email)) {
|
||||
participants.add({'name': a['name'], 'email': email});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await into(threads).insert(
|
||||
ThreadsCompanion.insert(
|
||||
id: latest.threadId ?? latest.id,
|
||||
accountId: latest.accountId,
|
||||
mailboxPath: latest.mailboxPath,
|
||||
subject: Value(latest.subject),
|
||||
latestDate: latest.sentAt ?? latest.receivedAt,
|
||||
messageCount: Value(threadEmails.length),
|
||||
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||
participantsJson: Value(jsonEncode(participants)),
|
||||
preview: Value(latest.preview),
|
||||
latestEmailId: latest.id,
|
||||
emailIdsJson: Value(
|
||||
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// Inserts a row for [gitHash] the first time that version is seen.
|
||||
/// Subsequent calls for the same hash are silently ignored so the original
|
||||
/// install timestamp is preserved.
|
||||
Future<void> recordInstalledVersionIfNew(String gitHash) async {
|
||||
if (gitHash.isEmpty) return;
|
||||
await into(installedVersions).insert(
|
||||
InstalledVersionsCompanion.insert(
|
||||
gitHash: gitHash,
|
||||
installedAt: DateTime.now(),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, DateTime>> loadInstalledVersions() async {
|
||||
final rows = await select(installedVersions).get();
|
||||
return {for (final r in rows) r.gitHash: r.installedAt};
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved once in main() via initDatabasePath() before runApp().
|
||||
@@ -759,18 +904,34 @@ Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||
void resetDatabasePathForTesting() => _dbPath = null;
|
||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
||||
|
||||
/// Configures PRAGMAs on a newly opened SQLite connection.
|
||||
///
|
||||
/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY
|
||||
/// instead of immediately failing.
|
||||
///
|
||||
/// journal_mode = WAL is wrapped in a try/catch because a concurrent
|
||||
/// WorkManager background task may already have the DB open when the app
|
||||
/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is
|
||||
/// returned in that situation; it only occurs when the DB is already in WAL
|
||||
/// mode, so the pragma would be a no-op anyway and it is safe to continue.
|
||||
void _setupPragmas(Database db) {
|
||||
db.execute('PRAGMA busy_timeout = 5000;');
|
||||
try {
|
||||
db.execute('PRAGMA journal_mode = WAL;');
|
||||
} on SqliteException catch (e) {
|
||||
// resultCode strips the extended bits: both SQLITE_BUSY (5) and
|
||||
// SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else.
|
||||
if (e.resultCode != 5) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
return LazyDatabase(() async {
|
||||
final file = File(await _resolveDatabasePath());
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (db) {
|
||||
// WAL lets readers and writers proceed concurrently (different account
|
||||
// sync loops share the same DB). busy_timeout makes SQLite retry for
|
||||
// up to 5 s instead of immediately returning SQLITE_BUSY.
|
||||
db.execute('PRAGMA journal_mode = WAL;');
|
||||
db.execute('PRAGMA busy_timeout = 5000;');
|
||||
},
|
||||
);
|
||||
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
|
||||
});
|
||||
}
|
||||
|
||||
// Exposed so tests can run the exact production setup logic on a raw
|
||||
// sqlite3 connection (same pattern as resolveDatabasePathForTesting).
|
||||
void setupPragmasForTesting(Database db) => _setupPragmas(db);
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
@@ -560,7 +561,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
for (final msg in result.messages) {
|
||||
final uid = msg.uid;
|
||||
if (uid == null) continue;
|
||||
final emailId = '${account.id}:$uid';
|
||||
final emailId = '${account.id}:$mailboxPath:$uid';
|
||||
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
||||
EmailsCompanion(
|
||||
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
||||
@@ -615,7 +616,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
continue;
|
||||
}
|
||||
bytes += msg.size ?? 0;
|
||||
final emailId = '${account.id}:$uid';
|
||||
final emailId = '${account.id}:$mailboxPath:$uid';
|
||||
final msgId = envelope.messageId?.trim();
|
||||
final inReplyTo = envelope.inReplyTo?.trim();
|
||||
final refs = msg.getHeaderValue('References')?.trim();
|
||||
@@ -2922,9 +2923,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
final sql = accountId != null
|
||||
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
|
||||
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
|
||||
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
|
||||
final variables = accountId != null
|
||||
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||
: [Variable<String>(ftsQuery)];
|
||||
@@ -2934,18 +2935,151 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
final emailRows = await Future.wait(
|
||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||
);
|
||||
|
||||
final noteRows = await _searchEmailsByNotes(accountId, null, query);
|
||||
|
||||
final seen = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||
if (seen.add(e.id)) merged.add(e);
|
||||
}
|
||||
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// Returns emails whose associated notes contain all words from [query].
|
||||
/// Optionally filtered by [accountId] and [mailboxPath].
|
||||
Future<List<model.Email>> _searchEmailsByNotes(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
String query,
|
||||
) async {
|
||||
final words =
|
||||
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
|
||||
if (words.isEmpty) return [];
|
||||
|
||||
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
|
||||
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
|
||||
|
||||
final extraConditions = StringBuffer();
|
||||
final extraVars = <Variable<String>>[];
|
||||
if (accountId != null) {
|
||||
extraConditions.write(' AND e.account_id = ?');
|
||||
extraVars.add(Variable<String>(accountId));
|
||||
}
|
||||
if (mailboxPath != null) {
|
||||
extraConditions.write(' AND e.mailbox_path = ?');
|
||||
extraVars.add(Variable<String>(mailboxPath));
|
||||
}
|
||||
|
||||
final sql = 'SELECT DISTINCT e.* FROM emails e'
|
||||
' JOIN email_notes n ON n.message_id = e.message_id'
|
||||
' AND n.account_id = e.account_id'
|
||||
' WHERE $noteConditions$extraConditions'
|
||||
' ORDER BY e.received_at DESC LIMIT 50';
|
||||
|
||||
final rows = await _db.customSelect(
|
||||
sql,
|
||||
variables: [...likeVars, ...extraVars],
|
||||
readsFrom: {_db.emails, _db.emailNotes},
|
||||
).get();
|
||||
final emailRows =
|
||||
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
|
||||
return emailRows.map(_toModel).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<model.Email>> searchEmailsStructured(
|
||||
String? accountId,
|
||||
FilterGroup filter,
|
||||
) async {
|
||||
final rows = await (_db.select(_db.emails)
|
||||
..where((t) {
|
||||
final fe = _filterGroup(filter, t);
|
||||
if (accountId == null) return fe;
|
||||
return t.accountId.equals(accountId) & fe;
|
||||
})
|
||||
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
|
||||
..limit(100))
|
||||
.get();
|
||||
return rows.map(_toModel).toList();
|
||||
}
|
||||
|
||||
Expression<bool> _filterGroup(FilterGroup group, $EmailsTable t) {
|
||||
if (group.isEmpty) return const Constant(true);
|
||||
final exprs = group.children.map((c) => _filterNode(c, t)).toList();
|
||||
return switch (group.operator) {
|
||||
FilterOperator.and_ => exprs.reduce((a, b) => a & b),
|
||||
FilterOperator.or_ => exprs.reduce((a, b) => a | b),
|
||||
};
|
||||
}
|
||||
|
||||
Expression<bool> _filterNode(FilterNode node, $EmailsTable t) =>
|
||||
switch (node) {
|
||||
final FilterLeaf l => _filterLeaf(l, t),
|
||||
final FilterGroup g => _filterGroup(g, t),
|
||||
};
|
||||
|
||||
Expression<bool> _filterLeaf(FilterLeaf leaf, $EmailsTable t) {
|
||||
final val = leaf.value.toLowerCase();
|
||||
return switch (leaf.field) {
|
||||
FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val),
|
||||
FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val),
|
||||
FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val),
|
||||
FilterField.subject => _textLike(t.subject, leaf.comparison, val),
|
||||
// Size is not stored in the local cache; skip silently.
|
||||
FilterField.size => const Constant(true),
|
||||
};
|
||||
}
|
||||
|
||||
Expression<bool> _jsonLike(
|
||||
GeneratedColumn<String> col,
|
||||
FilterComparison comp,
|
||||
String val,
|
||||
) =>
|
||||
switch (comp) {
|
||||
FilterComparison.contains => col.like('%$val%'),
|
||||
FilterComparison.is_ => col.like('%"email":"$val"%'),
|
||||
FilterComparison.matches => col.like(_globToLike(val)),
|
||||
_ => const Constant(true),
|
||||
};
|
||||
|
||||
Expression<bool> _textLike(
|
||||
GeneratedColumn<String> col,
|
||||
FilterComparison comp,
|
||||
String val,
|
||||
) =>
|
||||
switch (comp) {
|
||||
FilterComparison.contains => col.like('%$val%'),
|
||||
FilterComparison.is_ => col.like(val),
|
||||
FilterComparison.matches => col.like(_globToLike(val)),
|
||||
_ => const Constant(true),
|
||||
};
|
||||
|
||||
static String _globToLike(String glob) {
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < glob.length; i++) {
|
||||
final ch = glob[i];
|
||||
if (ch == '%' || ch == '_') {
|
||||
buf.write('\\$ch');
|
||||
} else if (ch == '*') {
|
||||
buf.write('%');
|
||||
} else if (ch == '?') {
|
||||
buf.write('_');
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
/// Converts a user query string into an FTS5 match expression.
|
||||
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
||||
/// partial words still match. Special FTS5 characters are stripped.
|
||||
static String _toFtsQuery(String query) {
|
||||
final words = query
|
||||
.trim()
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
||||
.split(RegExp(r'[^\w]+'))
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
if (words.isEmpty) return '';
|
||||
@@ -3047,68 +3181,42 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
// Results are limited to emails already synced into the local SQLite FTS5
|
||||
// index; call syncEmails first to ensure the index is up-to-date.
|
||||
Future<List<model.Email>> searchEmails(
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
final client = await _imapConnect(
|
||||
account,
|
||||
_effectiveUsername(account),
|
||||
password,
|
||||
final ftsQuery = _toFtsQuery(query);
|
||||
if (ftsQuery.isEmpty) return [];
|
||||
|
||||
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
||||
' ORDER BY e.received_at DESC LIMIT 50';
|
||||
final variables = [
|
||||
Variable<String>(ftsQuery),
|
||||
Variable<String>(accountId),
|
||||
Variable<String>(mailboxPath),
|
||||
];
|
||||
|
||||
final queryRows = await _db
|
||||
.customSelect(sql, variables: variables, readsFrom: {_db.emails}).get();
|
||||
final emailRows = await Future.wait(
|
||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(mailboxPath);
|
||||
final terms =
|
||||
query.split(RegExp(r'\s+')).where((t) => t.isNotEmpty).toList();
|
||||
final searchCriteria = terms.map((term) {
|
||||
final escaped = term.replaceAll('"', '\\"');
|
||||
return 'OR SUBJECT "$escaped" TEXT "$escaped"';
|
||||
}).join(' ');
|
||||
final result = await client.uidSearchMessages(
|
||||
searchCriteria: searchCriteria,
|
||||
);
|
||||
final uids = result.matchingSequence?.toList() ?? [];
|
||||
if (uids.isEmpty) return [];
|
||||
|
||||
final fetch = await client.uidFetchMessages(
|
||||
imap.MessageSequence.fromIds(uids, isUid: true),
|
||||
'(UID FLAGS ENVELOPE)',
|
||||
);
|
||||
return fetch.messages
|
||||
.where((msg) => msg.uid != null && msg.envelope != null)
|
||||
.map((msg) {
|
||||
final envelope = msg.envelope!;
|
||||
final uid = msg.uid!;
|
||||
final emailId = '$accountId:$uid';
|
||||
return model.Email(
|
||||
id: emailId,
|
||||
accountId: accountId,
|
||||
mailboxPath: mailboxPath,
|
||||
uid: uid,
|
||||
subject: envelope.subject,
|
||||
sentAt: envelope.date,
|
||||
receivedAt: envelope.date ?? DateTime.now(),
|
||||
from: _toAddressList(envelope.from),
|
||||
to: _toAddressList(envelope.to),
|
||||
cc: _toAddressList(envelope.cc),
|
||||
isSeen: msg.flags?.contains(r'\Seen') ?? false,
|
||||
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
|
||||
hasAttachment: msg.hasAttachments(),
|
||||
);
|
||||
}).toList();
|
||||
} finally {
|
||||
await client.logout();
|
||||
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
||||
|
||||
final seen = <String>{};
|
||||
final merged = <model.Email>[];
|
||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||
if (seen.add(e.id)) merged.add(e);
|
||||
}
|
||||
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
|
||||
return merged;
|
||||
}
|
||||
|
||||
List<model.EmailAddress> _toAddressList(List<imap.MailAddress>? addresses) =>
|
||||
(addresses ?? const [])
|
||||
.map((a) => model.EmailAddress(name: a.personalName, email: a.email))
|
||||
.toList();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Computes a stable threadId from RFC 2822 headers.
|
||||
|
||||
@@ -294,6 +294,10 @@ final noteRepositoryProvider = Provider<NoteRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final installedVersionsProvider = FutureProvider<Map<String, DateTime>>((ref) {
|
||||
return ref.watch(dbProvider).loadInstalledVersions();
|
||||
});
|
||||
|
||||
/// Stream of notes for a specific email, identified by (accountId, messageId).
|
||||
final notesProvider =
|
||||
StreamProvider.autoDispose.family<List<EmailNote>, (String, String)>(
|
||||
|
||||
@@ -86,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||
}
|
||||
|
||||
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
@override
|
||||
void initState() {
|
||||
@@ -93,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||
ref.read(syncManagerProvider).start();
|
||||
ref.read(reliabilityRunnerProvider).start();
|
||||
if (_kGitHash.isNotEmpty) {
|
||||
unawaited(
|
||||
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -102,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -109,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
routerConfig: router,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
|
||||
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
@@ -22,6 +23,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
@@ -55,6 +57,14 @@ final router = GoRouter(
|
||||
GoRoute(
|
||||
path: 'undo-log',
|
||||
builder: (ctx, state) => const UndoLogScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':actionId',
|
||||
builder: (ctx, state) => UndoLogDetailScreen(
|
||||
action: state.extra as UndoAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'changelog',
|
||||
|
||||
@@ -2,21 +2,90 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ChangeLogScreen extends StatelessWidget {
|
||||
class ChangeLogScreen extends ConsumerWidget {
|
||||
const ChangeLogScreen({super.key});
|
||||
|
||||
static const _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
static String _formatInstallDate(DateTime dt) {
|
||||
final h = dt.hour.toString().padLeft(2, '0');
|
||||
final m = dt.minute.toString().padLeft(2, '0');
|
||||
final month = _months[dt.month - 1];
|
||||
return '$h:$m, ${dt.day} $month ${dt.year}';
|
||||
}
|
||||
|
||||
static const _repoUrl = 'https://codeberg.org/guettli/sharedinbox';
|
||||
|
||||
static final _issueRefPattern = RegExp(r'#(\d+)');
|
||||
|
||||
static String _linkifyIssueRefs(String text) {
|
||||
return text.replaceAllMapped(
|
||||
_issueRefPattern,
|
||||
(m) => '[#${m[1]}]($_repoUrl/issues/${m[1]})',
|
||||
);
|
||||
}
|
||||
|
||||
// Changelog lines have the form:
|
||||
// * 2026-06-05 [abc1234](https://...): subject
|
||||
// This pattern captures the short hash inside the markdown link.
|
||||
static final _hashPattern = RegExp(r'\[([0-9a-f]{6,12})\]\(');
|
||||
|
||||
static String _injectInstallMarkers(
|
||||
String changelog,
|
||||
Map<String, DateTime> versions,
|
||||
) {
|
||||
if (versions.isEmpty) return changelog;
|
||||
final lines = changelog.split('\n');
|
||||
final buf = StringBuffer();
|
||||
for (final line in lines) {
|
||||
final match = _hashPattern.firstMatch(line);
|
||||
if (match != null) {
|
||||
final lineHash = match.group(1)!;
|
||||
for (final entry in versions.entries) {
|
||||
final stored = entry.key;
|
||||
final matches = stored == lineHash ||
|
||||
stored.startsWith(lineHash) ||
|
||||
lineHash.startsWith(stored);
|
||||
if (!matches) continue;
|
||||
buf.write(
|
||||
'\n---\n\n**Installed: ${_formatInstallDate(entry.value)}**\n\n',
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
buf.writeln(line);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final installedVersions = ref.watch(installedVersionsProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(
|
||||
context,
|
||||
).loadString('assets/changelog.txt'),
|
||||
future:
|
||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||
installedVersions.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
@@ -24,9 +93,12 @@ class ChangeLogScreen extends StatelessWidget {
|
||||
child: Text('Error loading changelog: ${snapshot.error}'),
|
||||
);
|
||||
}
|
||||
final content = snapshot.data ?? 'No changelog entries found.';
|
||||
final raw = snapshot.data ?? 'No changelog entries found.';
|
||||
final content = _linkifyIssueRefs(raw);
|
||||
final versions = installedVersions.value ?? {};
|
||||
final annotated = _injectInstallMarkers(content, versions);
|
||||
return Markdown(
|
||||
data: content,
|
||||
data: annotated,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(
|
||||
|
||||
@@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Something went wrong'),
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/note.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||
@@ -73,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),
|
||||
@@ -132,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'),
|
||||
@@ -165,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);
|
||||
@@ -208,8 +211,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
final senderEmail = header?.from.isNotEmpty == true
|
||||
? header!.from.first.email.toLowerCase()
|
||||
: null;
|
||||
final isTrusted =
|
||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||
final isTrusted = senderEmail != null &&
|
||||
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||
|
||||
return ListView(
|
||||
|
||||
@@ -50,6 +50,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Pagination: number of threads currently requested from the DB.
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
// Incremented on every search start; stale completions are ignored when the
|
||||
// generation has advanced (prevents out-of-order IMAP responses from
|
||||
// overwriting fresh results with results for an older query).
|
||||
int _searchGeneration = 0;
|
||||
// The query whose results are currently settled in _searchResults.
|
||||
// Used to skip redundant re-runs when the user presses Enter on an
|
||||
// already-settled search (issue #473).
|
||||
String? _lastSettledQuery;
|
||||
bool get _selecting =>
|
||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||
|
||||
@@ -61,6 +70,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
setState(() {
|
||||
_searchResults = null;
|
||||
_searchLoading = false;
|
||||
_lastSettledQuery = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -117,18 +127,35 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _runSearch(String query) async {
|
||||
if (query.trim().isEmpty) {
|
||||
setState(() => _searchResults = null);
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) {
|
||||
setState(() {
|
||||
_searchResults = null;
|
||||
_lastSettledQuery = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Skip if results are already settled for this exact query — prevents the
|
||||
// Enter key from re-triggering a search that already completed.
|
||||
if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) {
|
||||
return;
|
||||
}
|
||||
final generation = ++_searchGeneration;
|
||||
setState(() => _searchLoading = true);
|
||||
try {
|
||||
final results = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||
if (mounted) setState(() => _searchResults = results);
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, q);
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_lastSettledQuery = q;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _searchLoading = false);
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +278,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
),
|
||||
],
|
||||
onChanged: _onSearchChanged,
|
||||
onSubmitted: _runSearch,
|
||||
onSubmitted: (value) {
|
||||
// Only run the search if results haven't settled yet via
|
||||
// onChanged — prevents a second IMAP round-trip from reordering
|
||||
// the already-visible results when the user presses Enter.
|
||||
if (_searchResults == null && !_searchLoading) {
|
||||
unawaited(_runSearch(value));
|
||||
}
|
||||
},
|
||||
textInputAction: TextInputAction.search,
|
||||
),
|
||||
),
|
||||
@@ -541,8 +575,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
if (wasSearching && mounted) {
|
||||
// Filter deleted emails out of the local results immediately.
|
||||
// Calling searchEmails here would hit the IMAP server, which still has
|
||||
// the emails because the delete is only enqueued — not yet applied.
|
||||
// Calling searchEmails here would still return deleted rows because the
|
||||
// delete is only enqueued — not yet applied to the local DB.
|
||||
final deletedIds = ids.toSet();
|
||||
final remaining = (_searchResults ?? [])
|
||||
.where((e) => !deletedIds.contains(e.id))
|
||||
|
||||
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||
|
||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||
@@ -37,6 +39,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
bool _loading = false;
|
||||
bool _fieldFocused = false;
|
||||
|
||||
// Advanced (structured) search state.
|
||||
bool _advancedMode = false;
|
||||
FilterGroup _filterGroup = FilterGroup.empty();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -53,6 +59,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleAdvanced() {
|
||||
setState(() {
|
||||
_advancedMode = !_advancedMode;
|
||||
_results = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
if (value.trim().length < 3) {
|
||||
@@ -135,22 +148,47 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _searchStructured() async {
|
||||
if (_filterGroup.isEmpty) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final emails = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmailsStructured(widget.accountId, _filterGroup);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_results = _SearchResults(
|
||||
mailboxes: const [],
|
||||
addresses: const [],
|
||||
emails: emails,
|
||||
);
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log('Structured search failed: $e');
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search folders, addresses, emails…',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
title: _advancedMode
|
||||
? const Text('Advanced Search')
|
||||
: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search folders, addresses, emails…',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
actions: [
|
||||
if (_ctrl.text.isNotEmpty)
|
||||
if (!_advancedMode && _ctrl.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
@@ -158,6 +196,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
setState(() => _results = null);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_advancedMode ? Icons.search : Icons.tune,
|
||||
color:
|
||||
_advancedMode ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
tooltip: _advancedMode ? 'Simple search' : 'Advanced search',
|
||||
onPressed: _toggleAdvanced,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _buildBody(),
|
||||
@@ -165,6 +212,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_advancedMode) return _buildAdvancedBody();
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_results == null) {
|
||||
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||
@@ -174,7 +222,54 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
final r = _results!;
|
||||
if (r.isEmpty) return const Center(child: Text('No results'));
|
||||
return _buildResultsList(r);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedBody() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilterBuilderWidget(
|
||||
initialValue: _filterGroup,
|
||||
onChanged: (g) => setState(() {
|
||||
_filterGroup = g;
|
||||
_results = null;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _filterGroup.isEmpty ? null : _searchStructured,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Search'),
|
||||
),
|
||||
if (_loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (_results != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (_results!.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('No results'),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildResultsList(_results!),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultsList(_SearchResults r) {
|
||||
return ListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
if (r.mailboxes.isNotEmpty) ...[
|
||||
const _SectionHeader('Folders'),
|
||||
|
||||
@@ -3,8 +3,13 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||
|
||||
class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
const SieveScriptEditScreen({
|
||||
@@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||
_SieveScriptEditScreenState();
|
||||
}
|
||||
|
||||
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TextEditingController _nameController;
|
||||
late final TextEditingController _contentController;
|
||||
late final TabController _tabController;
|
||||
|
||||
bool _loadingContent = false;
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
// Visual-editor state.
|
||||
FilterGroup _filterGroup = FilterGroup.empty();
|
||||
List<SieveAction> _actions = [];
|
||||
bool _visualSupported = true;
|
||||
int _visualLoadCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameController = TextEditingController(text: widget.script?.name ?? '');
|
||||
_contentController = TextEditingController();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_tabController.addListener(_onTabChanged);
|
||||
if (widget.script != null) {
|
||||
unawaited(_loadContent());
|
||||
}
|
||||
@@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_contentController.dispose();
|
||||
_tabController
|
||||
..removeListener(_onTabChanged)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
if (_tabController.indexIsChanging) return;
|
||||
if (_tabController.index == 1) {
|
||||
// Switched to Script tab: serialize visual state.
|
||||
if (_visualSupported) {
|
||||
_contentController.text =
|
||||
SieveSerializer().serialize(_filterGroup, _actions);
|
||||
}
|
||||
} else {
|
||||
// Switched to Visual tab: parse script into visual state.
|
||||
_parseScriptIntoVisual();
|
||||
}
|
||||
}
|
||||
|
||||
void _parseScriptIntoVisual() {
|
||||
final result = FilterSieveConverter().parse(_contentController.text);
|
||||
if (result == null) {
|
||||
setState(() => _visualSupported = false);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_filterGroup = result.group;
|
||||
_actions = List<SieveAction>.from(result.actions);
|
||||
_visualSupported = true;
|
||||
_visualLoadCount++;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
setState(() => _loadingContent = true);
|
||||
try {
|
||||
@@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||
if (mounted) {
|
||||
_contentController.text = content;
|
||||
_parseScriptIntoVisual();
|
||||
setState(() => _loadingContent = false);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
// Sync visual → script if on visual tab.
|
||||
if (_tabController.index == 0 && _visualSupported) {
|
||||
_contentController.text =
|
||||
SieveSerializer().serialize(_filterGroup, _actions);
|
||||
}
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = 'Name is required');
|
||||
@@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(isNew ? 'New script' : 'Edit script'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')],
|
||||
),
|
||||
actions: [
|
||||
if (_saving)
|
||||
const Padding(
|
||||
@@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _contentController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Script',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
enabled: !_saving,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [_buildVisualTab(), _buildScriptTab()],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -182,4 +230,220 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVisualTab() {
|
||||
if (!_visualSupported) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'This script uses features not supported by the visual editor.\n'
|
||||
'Edit as raw Sieve on the Script tab.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilterBuilderWidget(
|
||||
key: ValueKey(_visualLoadCount),
|
||||
initialValue: _filterGroup,
|
||||
onChanged: (g) => setState(() => _filterGroup = g),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ActionEditor(
|
||||
actions: _actions,
|
||||
onChanged: (a) => setState(() => _actions = a),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScriptTab() {
|
||||
return TextField(
|
||||
controller: _contentController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Script',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
enabled: !_saving,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum _ActionType { keep, discard, markAsRead, fileInto }
|
||||
|
||||
class _ActionEditor extends StatelessWidget {
|
||||
const _ActionEditor({required this.actions, required this.onChanged});
|
||||
|
||||
final List<SieveAction> actions;
|
||||
final void Function(List<SieveAction>) onChanged;
|
||||
|
||||
_ActionType _typeOf(SieveAction a) => switch (a) {
|
||||
KeepAction() => _ActionType.keep,
|
||||
DiscardAction() => _ActionType.discard,
|
||||
MarkAsSeenAction() => _ActionType.markAsRead,
|
||||
FileIntoAction() => _ActionType.fileInto,
|
||||
FlagAction() => _ActionType.keep,
|
||||
};
|
||||
|
||||
SieveAction _defaultFor(_ActionType t) => switch (t) {
|
||||
_ActionType.keep => KeepAction(),
|
||||
_ActionType.discard => DiscardAction(),
|
||||
_ActionType.markAsRead => MarkAsSeenAction(),
|
||||
_ActionType.fileInto => FileIntoAction(''),
|
||||
};
|
||||
|
||||
void _changeType(int i, _ActionType t) {
|
||||
final next = List<SieveAction>.from(actions);
|
||||
final current = next[i];
|
||||
if (t == _ActionType.fileInto && current is FileIntoAction) return;
|
||||
next[i] = _defaultFor(t);
|
||||
onChanged(next);
|
||||
}
|
||||
|
||||
void _changeFolder(int i, String folder) {
|
||||
final next = List<SieveAction>.from(actions);
|
||||
next[i] = FileIntoAction(folder);
|
||||
onChanged(next);
|
||||
}
|
||||
|
||||
void _remove(int i) {
|
||||
final next = List<SieveAction>.from(actions)..removeAt(i);
|
||||
onChanged(next);
|
||||
}
|
||||
|
||||
void _add() {
|
||||
onChanged([...actions, KeepAction()]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text('Actions', style: Theme.of(context).textTheme.labelLarge),
|
||||
),
|
||||
for (var i = 0; i < actions.length; i++) _buildRow(context, i),
|
||||
TextButton.icon(
|
||||
onPressed: _add,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add action'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(BuildContext context, int i) {
|
||||
final action = actions[i];
|
||||
final type = _typeOf(action);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
DropdownButton<_ActionType>(
|
||||
value: type,
|
||||
isDense: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
onChanged: (t) {
|
||||
if (t != null) _changeType(i, t);
|
||||
},
|
||||
items: const [
|
||||
DropdownMenuItem(value: _ActionType.keep, child: Text('Keep')),
|
||||
DropdownMenuItem(
|
||||
value: _ActionType.discard,
|
||||
child: Text('Discard'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: _ActionType.markAsRead,
|
||||
child: Text('Mark as read'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: _ActionType.fileInto,
|
||||
child: Text('File into'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (type == _ActionType.fileInto) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _FolderField(
|
||||
value: (action as FileIntoAction).folder,
|
||||
onChanged: (v) => _changeFolder(i, v),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _remove(i),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FolderField extends StatefulWidget {
|
||||
const _FolderField({required this.value, required this.onChanged});
|
||||
final String value;
|
||||
final void Function(String) onChanged;
|
||||
|
||||
@override
|
||||
State<_FolderField> createState() => _FolderFieldState();
|
||||
}
|
||||
|
||||
class _FolderFieldState extends State<_FolderField> {
|
||||
late final TextEditingController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = TextEditingController(text: widget.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FolderField old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.value != _ctrl.text) _ctrl.text = widget.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: _ctrl,
|
||||
onChanged: widget.onChanged,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'folder',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
@@ -118,8 +119,8 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
final senderEmail = widget.email.from.isNotEmpty
|
||||
? widget.email.from.first.email.toLowerCase()
|
||||
: null;
|
||||
final isTrusted =
|
||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||
final isTrusted = senderEmail != null &&
|
||||
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
|
||||
@@ -16,6 +16,11 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
tooltip: 'Add address',
|
||||
onPressed: () => _showAddDialog(context, ref),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: trustedSendersAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) =>
|
||||
@@ -26,7 +31,8 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'No addresses added yet. '
|
||||
'Tap "Load remote images" in an email to add the sender.',
|
||||
'Tap + to add an address or pattern (e.g. *@example.com), '
|
||||
'or tap "Load remote images" in an email to add the sender automatically.',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -60,4 +66,61 @@ class TrustedImageSendersScreen extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
|
||||
final controller = TextEditingController();
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('Add allowed address'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email address or pattern',
|
||||
hintText: '*@example.com',
|
||||
helperText: '* matches any characters, e.g. *@example.com',
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
onSubmitted: (value) {
|
||||
if (value.trim().isNotEmpty) {
|
||||
_addSender(ref, value);
|
||||
Navigator.of(ctx).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: controller.text.trim().isEmpty
|
||||
? null
|
||||
: () {
|
||||
_addSender(ref, controller.text);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _addSender(WidgetRef ref, String value) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.addTrustedImageSender(value.trim()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
class UndoLogDetailScreen extends ConsumerWidget {
|
||||
const UndoLogDetailScreen({super.key, required this.action});
|
||||
|
||||
final UndoAction action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Undo Log Detail'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.undo(actionId: action.id);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Undo'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
_SectionHeader(text: 'Transaction', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: const Text('Account'),
|
||||
subtitle: Text(action.accountId),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
: (action.type == UndoType.snooze
|
||||
? Icons.access_time
|
||||
: Icons.move_to_inbox),
|
||||
color: action.type == UndoType.delete
|
||||
? Colors.redAccent
|
||||
: (action.type == UndoType.snooze
|
||||
? Colors.orangeAccent
|
||||
: Colors.blueAccent),
|
||||
),
|
||||
title: const Text('Action'),
|
||||
subtitle: Text(action.type.name.toUpperCase()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: const Text('Timestamp'),
|
||||
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
|
||||
),
|
||||
_SectionHeader(text: 'Folders', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_open),
|
||||
title: const Text('Source'),
|
||||
subtitle: Text(action.sourceMailboxPath),
|
||||
),
|
||||
if (action.type == UndoType.move &&
|
||||
action.destinationMailboxPath != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.drive_file_move),
|
||||
title: const Text('Destination'),
|
||||
subtitle: Text(action.destinationMailboxPath!),
|
||||
),
|
||||
_SectionHeader(
|
||||
text: 'Emails (${action.emailIds.length})',
|
||||
theme: theme,
|
||||
),
|
||||
if (action.originalEmails.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'${action.emailIds.length} email(s) — details not available',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text, required this.theme});
|
||||
|
||||
final String text;
|
||||
final ThemeData theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailTile extends StatelessWidget {
|
||||
const _EmailTile({required this.email});
|
||||
|
||||
final Email email;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sender = email.from.isNotEmpty
|
||||
? (email.from.first.name ?? email.from.first.email)
|
||||
: '(Unknown Sender)';
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.email_outlined),
|
||||
title: Text(email.subject ?? '(No Subject)'),
|
||||
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
||||
|
||||
return ListTile(
|
||||
onTap: () => context.go(
|
||||
'/accounts/undo-log/${action.id}',
|
||||
extra: action,
|
||||
),
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
|
||||
/// A widget that lets the user build a structured [FilterGroup] interactively.
|
||||
///
|
||||
/// Use a [ValueKey] on this widget when replacing [initialValue] from the
|
||||
/// outside (e.g., after loading a Sieve script) to force a full rebuild.
|
||||
class FilterBuilderWidget extends StatefulWidget {
|
||||
const FilterBuilderWidget({
|
||||
super.key,
|
||||
required this.initialValue,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final FilterGroup initialValue;
|
||||
final void Function(FilterGroup) onChanged;
|
||||
|
||||
@override
|
||||
State<FilterBuilderWidget> createState() => _FilterBuilderWidgetState();
|
||||
}
|
||||
|
||||
class _FilterBuilderWidgetState extends State<FilterBuilderWidget> {
|
||||
late FilterGroup _group;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_group = widget.initialValue;
|
||||
}
|
||||
|
||||
void _update(FilterGroup g) {
|
||||
setState(() => _group = g);
|
||||
widget.onChanged(g);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _GroupEditor(
|
||||
group: _group,
|
||||
onChanged: _update,
|
||||
depth: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _GroupEditor extends StatelessWidget {
|
||||
const _GroupEditor({
|
||||
super.key,
|
||||
required this.group,
|
||||
required this.onChanged,
|
||||
required this.depth,
|
||||
this.onRemoveGroup,
|
||||
});
|
||||
|
||||
final FilterGroup group;
|
||||
final void Function(FilterGroup) onChanged;
|
||||
final int depth;
|
||||
final VoidCallback? onRemoveGroup;
|
||||
|
||||
static const _maxDepth = 1;
|
||||
|
||||
void _setOperator(FilterOperator op) =>
|
||||
onChanged(group.copyWith(operator: op));
|
||||
|
||||
void _addLeaf() {
|
||||
final leaf = FilterLeaf(
|
||||
field: FilterField.from_,
|
||||
comparison: FilterComparison.contains,
|
||||
value: '',
|
||||
);
|
||||
onChanged(group.copyWith(children: [...group.children, leaf]));
|
||||
}
|
||||
|
||||
void _addSubGroup() {
|
||||
final sub = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [],
|
||||
);
|
||||
onChanged(group.copyWith(children: [...group.children, sub]));
|
||||
}
|
||||
|
||||
void _replaceChild(int index, FilterNode node) {
|
||||
final next = List<FilterNode>.from(group.children);
|
||||
next[index] = node;
|
||||
onChanged(group.copyWith(children: next));
|
||||
}
|
||||
|
||||
void _removeChild(int index) {
|
||||
final next = List<FilterNode>.from(group.children)..removeAt(index);
|
||||
onChanged(group.copyWith(children: next));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isRoot = depth == 0;
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_OperatorRow(
|
||||
operator: group.operator,
|
||||
onChanged: _setOperator,
|
||||
onRemove: onRemoveGroup,
|
||||
),
|
||||
for (var i = 0; i < group.children.length; i++) _buildChild(context, i),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _addLeaf,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('Add condition'),
|
||||
),
|
||||
if (depth < _maxDepth)
|
||||
TextButton.icon(
|
||||
onPressed: _addSubGroup,
|
||||
icon: const Icon(Icons.playlist_add, size: 16),
|
||||
label: const Text('Add group'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
if (isRoot) return content;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChild(BuildContext context, int i) {
|
||||
final child = group.children[i];
|
||||
return switch (child) {
|
||||
final FilterLeaf leaf => _LeafRow(
|
||||
key: ValueKey(i),
|
||||
leaf: leaf,
|
||||
onChanged: (l) => _replaceChild(i, l),
|
||||
onDelete: () => _removeChild(i),
|
||||
),
|
||||
final FilterGroup sub => _GroupEditor(
|
||||
key: ValueKey(i),
|
||||
group: sub,
|
||||
onChanged: (g) => _replaceChild(i, g),
|
||||
depth: depth + 1,
|
||||
onRemoveGroup: () => _removeChild(i),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operator row (AND / OR toggle)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _OperatorRow extends StatelessWidget {
|
||||
const _OperatorRow({
|
||||
required this.operator,
|
||||
required this.onChanged,
|
||||
this.onRemove,
|
||||
});
|
||||
|
||||
final FilterOperator operator;
|
||||
final void Function(FilterOperator) onChanged;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SegmentedButton<FilterOperator>(
|
||||
segments: const [
|
||||
ButtonSegment(value: FilterOperator.and_, label: Text('AND')),
|
||||
ButtonSegment(value: FilterOperator.or_, label: Text('OR')),
|
||||
],
|
||||
selected: {operator},
|
||||
onSelectionChanged: (s) => onChanged(s.first),
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (onRemove != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
tooltip: 'Remove group',
|
||||
onPressed: onRemove,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaf row (field | comparison | value | delete)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _LeafRow extends StatefulWidget {
|
||||
const _LeafRow({
|
||||
super.key,
|
||||
required this.leaf,
|
||||
required this.onChanged,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
final FilterLeaf leaf;
|
||||
final void Function(FilterLeaf) onChanged;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
State<_LeafRow> createState() => _LeafRowState();
|
||||
}
|
||||
|
||||
class _LeafRowState extends State<_LeafRow> {
|
||||
late final TextEditingController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = TextEditingController(text: widget.leaf.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_LeafRow old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.leaf.value != _ctrl.text) {
|
||||
_ctrl.text = widget.leaf.value;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFieldChanged(FilterField? f) {
|
||||
if (f == null) return;
|
||||
final allowed = f.allowedComparisons;
|
||||
final comp = allowed.contains(widget.leaf.comparison)
|
||||
? widget.leaf.comparison
|
||||
: allowed.first;
|
||||
widget.onChanged(widget.leaf.copyWith(field: f, comparison: comp));
|
||||
}
|
||||
|
||||
void _onCompChanged(FilterComparison? c) {
|
||||
if (c == null) return;
|
||||
widget.onChanged(widget.leaf.copyWith(comparison: c));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
DropdownButton<FilterField>(
|
||||
value: widget.leaf.field,
|
||||
onChanged: _onFieldChanged,
|
||||
isDense: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: FilterField.values
|
||||
.map(
|
||||
(f) => DropdownMenuItem(value: f, child: Text(f.label)),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DropdownButton<FilterComparison>(
|
||||
value: widget.leaf.comparison,
|
||||
onChanged: _onCompChanged,
|
||||
isDense: true,
|
||||
underline: const SizedBox.shrink(),
|
||||
items: widget.leaf.field.allowedComparisons
|
||||
.map(
|
||||
(c) => DropdownMenuItem(value: c, child: Text(c.label)),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
onChanged: (v) =>
|
||||
widget.onChanged(widget.leaf.copyWith(value: v)),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'value',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||
tooltip: 'Remove',
|
||||
onPressed: widget.onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
|
||||
DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
@@ -31,6 +31,8 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
|
||||
|
||||
// Show AFTER adding FlView so GTK's first layout pass allocates the full
|
||||
// window content area (1280×800) to FlView, not the default 1×1.
|
||||
gtk_widget_show_all(GTK_WIDGET(window));
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -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:
|
||||
@@ -371,6 +371,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -383,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:
|
||||
@@ -423,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:
|
||||
@@ -439,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:
|
||||
@@ -518,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:
|
||||
@@ -534,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:
|
||||
@@ -562,6 +578,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -659,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:
|
||||
@@ -691,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:
|
||||
@@ -707,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:
|
||||
@@ -997,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:
|
||||
@@ -1072,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:
|
||||
@@ -1088,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:
|
||||
@@ -1272,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:
|
||||
@@ -1288,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:
|
||||
@@ -1365,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"
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies:
|
||||
|
||||
# Local persistence (offline-first)
|
||||
drift: ^2.20.3
|
||||
sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas)
|
||||
sqlite3_flutter_libs: ^0.6.0+eol
|
||||
path_provider: ^2.1.5
|
||||
path: ^1.9.1
|
||||
@@ -27,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
|
||||
@@ -36,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
|
||||
|
||||
@@ -55,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
|
||||
@@ -78,9 +79,17 @@ dev_dependencies:
|
||||
mockito: ^5.4.4
|
||||
fake_async: ^1.3.1
|
||||
path_provider_platform_interface: ^2.1.2
|
||||
sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close()
|
||||
url_launcher_platform_interface: ^2.3.2
|
||||
plugin_platform_interface: ^2.1.8
|
||||
flutter_launcher_icons: ^0.14.0
|
||||
|
||||
flutter_icons:
|
||||
android: "ic_launcher"
|
||||
ios: false
|
||||
image_path: "icon.png"
|
||||
linux:
|
||||
generate: true
|
||||
image_path: "icon.png"
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
}
|
||||
],
|
||||
"customManagers": [
|
||||
{
|
||||
"customType": "regex",
|
||||
"fileMatch": ["^\\.fvmrc$"],
|
||||
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
|
||||
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
|
||||
"datasourceTemplate": "docker",
|
||||
"versioningTemplate": "semver"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||
|
||||
@@ -57,6 +57,7 @@ const _excluded = {
|
||||
'lib/ui/screens/sieve_scripts_screen.dart',
|
||||
'lib/ui/screens/sync_log_screen.dart',
|
||||
'lib/ui/screens/thread_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_detail_screen.dart',
|
||||
'lib/ui/screens/undo_log_screen.dart',
|
||||
'lib/ui/widgets/folder_drawer.dart',
|
||||
'lib/ui/widgets/secure_email_webview.dart',
|
||||
@@ -86,6 +87,7 @@ const _excluded = {
|
||||
'lib/ui/widgets/email_thread_tile.dart',
|
||||
'lib/ui/screens/trusted_image_senders_screen.dart',
|
||||
'lib/data/repositories/note_repository_impl.dart',
|
||||
'lib/ui/widgets/filter_builder.dart',
|
||||
'lib/ui/widgets/thread_tile.dart',
|
||||
};
|
||||
|
||||
|
||||
@@ -17,12 +17,25 @@ sops --decrypt --output-type json secrets.enc.yaml > "$SECRETS_JSON"
|
||||
DAGGER_SSH_KEY=$(jq -r '.DAGGER_SSH_KEY' "$SECRETS_JSON")
|
||||
DAGGER_ENGINE_HOST=$(jq -r '.DAGGER_ENGINE_HOST' "$SECRETS_JSON")
|
||||
|
||||
# Register inline secrets for log redaction. Multiline values (e.g. SSH keys)
|
||||
# must be masked line-by-line because ::add-mask:: covers one line at a time.
|
||||
printf '::add-mask::%s\n' "$DAGGER_ENGINE_HOST"
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||
done <<< "$DAGGER_SSH_KEY"
|
||||
|
||||
# Export all CI secrets to the GitHub Actions environment so subsequent steps
|
||||
# can use them without referencing Forgejo secrets directly.
|
||||
export_secret() {
|
||||
local name="$1"
|
||||
local value
|
||||
value=$(jq -r --arg k "$name" '.[$k] // empty' "$SECRETS_JSON")
|
||||
# Register each non-empty line for log redaction in the Actions runner.
|
||||
if [ -n "$value" ] && [ -n "${GITHUB_ENV:-}" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && printf '::add-mask::%s\n' "$line"
|
||||
done <<< "$value"
|
||||
fi
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
# Use heredoc syntax for multiline-safe export.
|
||||
# Avoid adding a second trailing newline for values that already end with one
|
||||
@@ -63,11 +76,12 @@ if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond"
|
||||
fi
|
||||
|
||||
# Create a background SSH tunnel to the Dagger engine.
|
||||
# We map local port 8080 to remote port 1774 (where our socat bridge is listening).
|
||||
# Create a background SSH tunnel to the Dagger engine Unix socket.
|
||||
# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host,
|
||||
# eliminating the need for a socat bridge on the server side.
|
||||
echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..."
|
||||
_t0=$SECONDS
|
||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST"
|
||||
timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST"
|
||||
_elapsed=$(( SECONDS - _t0 ))
|
||||
if [ "$_elapsed" -gt 10 ]; then
|
||||
echo "::warning::SSH tunnel setup took ${_elapsed}s"
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||
import 'package:flutter_test/flutter_test.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/core/models/mailbox.dart';
|
||||
@@ -272,6 +273,13 @@ class _FakeEmails implements EmailRepository {
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsStructured(
|
||||
String? a,
|
||||
FilterGroup f,
|
||||
) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
// Chaos monkey test — drives the email repository through random operations
|
||||
// against a live Stalwart instance to surface crashes and data-corruption bugs.
|
||||
//
|
||||
// Run via: stalwart-dev/test.sh
|
||||
//
|
||||
// Environment variables:
|
||||
// STALWART_IMAP_HOST, STALWART_IMAP_PORT
|
||||
// STALWART_SMTP_HOST, STALWART_SMTP_PORT
|
||||
// STALWART_USER_B / STALWART_PASS_B (alice@example.com)
|
||||
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
|
||||
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
|
||||
|
||||
@Tags(['nightly'])
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:enough_mail/enough_mail.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart' as email_model;
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Account;
|
||||
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../unit/account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import '../unit/db_test_helper.dart';
|
||||
|
||||
String _env(String key, [String fallback = '']) =>
|
||||
Platform.environment[key] ?? fallback;
|
||||
|
||||
Future<ImapClient> _imapConnectPlain(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final client =
|
||||
ImapClient(defaultResponseTimeout: const Duration(seconds: 20));
|
||||
await client.connectToServer(
|
||||
account.imapHost,
|
||||
account.imapPort,
|
||||
isSecure: false,
|
||||
);
|
||||
await client.login(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<SmtpClient> _smtpConnectPlain(
|
||||
Account account,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
final atIndex = account.email.lastIndexOf('@');
|
||||
final domain =
|
||||
atIndex != -1 ? account.email.substring(atIndex + 1) : account.smtpHost;
|
||||
final client = SmtpClient(domain);
|
||||
await client.connectToServer(
|
||||
account.smtpHost,
|
||||
account.smtpPort,
|
||||
isSecure: false,
|
||||
);
|
||||
await client.ehlo();
|
||||
await client.authenticate(username, password);
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> _clearMailbox(
|
||||
Account account,
|
||||
String userEmail,
|
||||
String userPass,
|
||||
String mailboxPath,
|
||||
) async {
|
||||
final client = await _imapConnectPlain(account, userEmail, userPass);
|
||||
try {
|
||||
final box = await client.selectMailboxByPath(mailboxPath);
|
||||
if (box.messagesExists == 0) return;
|
||||
final result = await client.uidSearchMessages(searchCriteria: 'ALL');
|
||||
final uids = result.matchingSequence?.toList() ?? [];
|
||||
if (uids.isEmpty) return;
|
||||
final seq = MessageSequence.fromIds(uids, isUid: true);
|
||||
await client.uidMarkDeleted(seq);
|
||||
await client.uidExpunge(seq);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late String imapHost;
|
||||
late int imapPort;
|
||||
late String smtpHost;
|
||||
late int smtpPort;
|
||||
late String userEmail;
|
||||
late String userPass;
|
||||
late Account account;
|
||||
late AppDatabase db;
|
||||
late EmailRepositoryImpl emails;
|
||||
|
||||
setUpAll(configureSqliteForTests);
|
||||
|
||||
setUp(() async {
|
||||
imapHost = _env('STALWART_IMAP_HOST', '127.0.0.1');
|
||||
imapPort = int.parse(_env('STALWART_IMAP_PORT', '1430'));
|
||||
smtpHost = _env('STALWART_SMTP_HOST', '127.0.0.1');
|
||||
smtpPort = int.parse(_env('STALWART_SMTP_PORT', '1025'));
|
||||
userEmail = _env('STALWART_USER_B', 'alice@example.com');
|
||||
userPass = _env('STALWART_PASS_B', 'secret');
|
||||
|
||||
account = Account(
|
||||
id: 'chaos',
|
||||
displayName: 'Chaos',
|
||||
email: userEmail,
|
||||
imapHost: imapHost,
|
||||
imapPort: imapPort,
|
||||
imapSsl: false,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
);
|
||||
|
||||
db = openTestDatabase();
|
||||
final secureStorage = MapSecureStorage();
|
||||
final accounts = AccountRepositoryImpl(db, secureStorage);
|
||||
await accounts.addAccount(account, userPass);
|
||||
emails = EmailRepositoryImpl(
|
||||
db,
|
||||
accounts,
|
||||
imapConnect: _imapConnectPlain,
|
||||
smtpConnect: _smtpConnectPlain,
|
||||
);
|
||||
|
||||
await _clearMailbox(account, userEmail, userPass, 'INBOX');
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
test('chaos monkey — random operations do not crash the repository',
|
||||
timeout: Timeout.none, () async {
|
||||
final seedStr = _env('CHAOS_SEED');
|
||||
final seed = seedStr.isEmpty
|
||||
? DateTime.now().millisecondsSinceEpoch
|
||||
: int.parse(seedStr);
|
||||
final rounds = int.parse(_env('CHAOS_ROUNDS', '30'));
|
||||
final rng = Random(seed);
|
||||
|
||||
stdout.writeln('chaos-monkey: seed=$seed rounds=$rounds');
|
||||
|
||||
// Seed INBOX with a few messages so early rounds have something to act on.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await emails.sendEmail(
|
||||
account.id,
|
||||
email_model.EmailDraft(
|
||||
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
|
||||
to: [email_model.EmailAddress(email: userEmail)],
|
||||
cc: [],
|
||||
subject: 'seed-$i',
|
||||
body: 'Seed email $i.',
|
||||
),
|
||||
);
|
||||
}
|
||||
await emails.syncEmails(account.id, 'INBOX');
|
||||
|
||||
for (var round = 0; round < rounds; round++) {
|
||||
final action = rng.nextInt(8);
|
||||
stdout.writeln('chaos-monkey: round=$round action=$action');
|
||||
|
||||
switch (action) {
|
||||
case 0: // sync INBOX
|
||||
await emails.syncEmails(account.id, 'INBOX');
|
||||
|
||||
case 1: // sync Sent
|
||||
await emails.syncEmails(account.id, 'Sent');
|
||||
|
||||
case 2: // send email to self
|
||||
final subject = 'chaos-$round-${rng.nextInt(9999)}';
|
||||
await emails.sendEmail(
|
||||
account.id,
|
||||
email_model.EmailDraft(
|
||||
from: email_model.EmailAddress(name: 'Chaos', email: userEmail),
|
||||
to: [email_model.EmailAddress(email: userEmail)],
|
||||
cc: [],
|
||||
subject: subject,
|
||||
body: 'Round $round. Value: ${rng.nextInt(1000000)}.',
|
||||
),
|
||||
);
|
||||
|
||||
case 3: // mark random email seen
|
||||
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||
if (inbox.isEmpty) break;
|
||||
final e = inbox[rng.nextInt(inbox.length)];
|
||||
await emails.setFlag(e.id, seen: true);
|
||||
|
||||
case 4: // mark random email unseen
|
||||
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||
if (inbox.isEmpty) break;
|
||||
final e = inbox[rng.nextInt(inbox.length)];
|
||||
await emails.setFlag(e.id, seen: false);
|
||||
|
||||
case 5: // toggle flagged on random email
|
||||
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||
if (inbox.isEmpty) break;
|
||||
final e = inbox[rng.nextInt(inbox.length)];
|
||||
await emails.setFlag(e.id, flagged: !e.isFlagged);
|
||||
|
||||
case 6: // flush pending changes to server
|
||||
final flushed =
|
||||
await emails.flushPendingChanges(account.id, userPass);
|
||||
stdout.writeln('chaos-monkey: flushed $flushed pending changes');
|
||||
|
||||
case 7: // delete random email
|
||||
final inbox = await emails.observeEmails(account.id, 'INBOX').first;
|
||||
if (inbox.isEmpty) break;
|
||||
final e = inbox[rng.nextInt(inbox.length)];
|
||||
await emails.deleteEmail(e.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Final flush and sync to confirm the server is in a consistent state.
|
||||
final flushed = await emails.flushPendingChanges(account.id, userPass);
|
||||
stdout.writeln('chaos-monkey: final flush flushed=$flushed');
|
||||
final result = await emails.syncEmails(account.id, 'INBOX');
|
||||
stdout.writeln('chaos-monkey: final sync fetched=${result.fetched}');
|
||||
});
|
||||
}
|
||||
@@ -421,6 +421,7 @@ void main() {
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final results = await r.emails.searchEmails('test', 'INBOX', uniqueWord);
|
||||
expect(results, hasLength(1));
|
||||
@@ -432,6 +433,7 @@ void main() {
|
||||
|
||||
final r = makeRepo();
|
||||
await r.accounts.addAccount(account, userPass);
|
||||
await r.emails.syncEmails('test', 'INBOX');
|
||||
|
||||
final results = await r.emails.searchEmails(
|
||||
'test',
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart' show MissingPluginException;
|
||||
import 'package:mockito/annotations.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/core/models/mailbox.dart';
|
||||
@@ -137,6 +138,12 @@ class FakeEmailRepository implements EmailRepository {
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||
@override
|
||||
Future<List<Email>> searchEmailsStructured(
|
||||
String? a,
|
||||
FilterGroup f,
|
||||
) async =>
|
||||
[];
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
|
||||
@override
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'dart:async' as _i5;
|
||||
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i7;
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i10;
|
||||
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
||||
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
||||
@@ -545,6 +546,22 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||
) as _i5.Future<List<_i3.Email>>);
|
||||
|
||||
@override
|
||||
_i5.Future<List<_i3.Email>> searchEmailsStructured(
|
||||
String? accountId,
|
||||
_i10.FilterGroup? filter,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#searchEmailsStructured,
|
||||
[
|
||||
accountId,
|
||||
filter,
|
||||
],
|
||||
),
|
||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||
) as _i5.Future<List<_i3.Email>>);
|
||||
|
||||
@override
|
||||
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
|
||||
@@ -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;
|
||||
@@ -262,6 +263,50 @@ void main() {
|
||||
expect(emails.map((e) => e.uid).toList(), [3, 2, 1]);
|
||||
});
|
||||
|
||||
test('same UID in different mailboxes yields independent emails', () async {
|
||||
// Regression test for the UID collision bug: IMAP UIDs are mailbox-scoped,
|
||||
// so UID 50 in INBOX and UID 50 in Archive must get distinct local IDs.
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// New ID format: accountId:mailboxPath:uid
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:INBOX:50',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 50,
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:Archive:50',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Archive',
|
||||
uid: 50,
|
||||
receivedAt: DateTime(2024, 1, 2),
|
||||
),
|
||||
);
|
||||
|
||||
final inboxEmail = await r.emails.getEmail('acc-1:INBOX:50');
|
||||
expect(inboxEmail, isNotNull);
|
||||
expect(inboxEmail!.mailboxPath, 'INBOX');
|
||||
|
||||
final archiveEmail = await r.emails.getEmail('acc-1:Archive:50');
|
||||
expect(archiveEmail, isNotNull);
|
||||
expect(archiveEmail!.mailboxPath, 'Archive');
|
||||
|
||||
final inboxEmails = await r.emails.observeEmails('acc-1', 'INBOX').first;
|
||||
expect(inboxEmails, hasLength(1));
|
||||
expect(inboxEmails.first.id, 'acc-1:INBOX:50');
|
||||
|
||||
final archiveEmails =
|
||||
await r.emails.observeEmails('acc-1', 'Archive').first;
|
||||
expect(archiveEmails, hasLength(1));
|
||||
expect(archiveEmails.first.id, 'acc-1:Archive:50');
|
||||
});
|
||||
|
||||
test('syncEmails propagates IMAP error', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
@@ -453,6 +498,276 @@ void main() {
|
||||
expect(results.first.subject, 'foobar baz');
|
||||
});
|
||||
|
||||
test('searchEmails filters by mailboxPath using local FTS5', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Insert matching email in INBOX.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
subject: const Value('Meeting agenda'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Insert matching email in a different mailbox — must not appear.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Sent',
|
||||
uid: 2,
|
||||
subject: const Value('Meeting follow-up'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Meeting agenda');
|
||||
expect(results.first.mailboxPath, 'INBOX');
|
||||
});
|
||||
|
||||
test('searchEmailsGlobal includes emails matched by note text', () async {
|
||||
final r = _makeRepos();
|
||||
await r.accounts.addAccount(_account, 'pw');
|
||||
|
||||
// Email whose subject does NOT match — but its note does.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:1',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'INBOX',
|
||||
uid: 1,
|
||||
messageId: const Value('<msg1@example.com>'),
|
||||
subject: const Value('Weekly report'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Add a note referencing the email's messageId.
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-1',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg1@example.com>',
|
||||
noteText: 'Urgent follow-up needed',
|
||||
serverId: '42',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Weekly report');
|
||||
});
|
||||
|
||||
test('searchEmails includes emails matched by note text in mailbox',
|
||||
() 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,
|
||||
messageId: const Value('<msg1@example.com>'),
|
||||
subject: const Value('Project update'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
// Email in a different mailbox — its note must not appear in INBOX search.
|
||||
await r.db.into(r.db.emails).insert(
|
||||
EmailsCompanion.insert(
|
||||
id: 'acc-1:2',
|
||||
accountId: 'acc-1',
|
||||
mailboxPath: 'Sent',
|
||||
uid: 2,
|
||||
messageId: const Value('<msg2@example.com>'),
|
||||
subject: const Value('Other email'),
|
||||
receivedAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-1',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg1@example.com>',
|
||||
noteText: 'remember to call client',
|
||||
serverId: '42',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
await r.db.into(r.db.emailNotes).insert(
|
||||
EmailNotesCompanion.insert(
|
||||
id: 'note-2',
|
||||
accountId: 'acc-1',
|
||||
messageId: '<msg2@example.com>',
|
||||
noteText: 'remember to call client',
|
||||
serverId: '43',
|
||||
createdAt: DateTime(2024),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
|
||||
expect(results, hasLength(1));
|
||||
expect(results.first.subject, 'Project update');
|
||||
expect(results.first.mailboxPath, 'INBOX');
|
||||
});
|
||||
|
||||
test('searchEmailsGlobal 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 report'),
|
||||
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 report'),
|
||||
receivedAt: DateTime(2024, 6),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmailsGlobal(null, 'report');
|
||||
expect(results, hasLength(2));
|
||||
expect(results[0].subject, 'Newer report');
|
||||
expect(results[1].subject, 'Older report');
|
||||
});
|
||||
|
||||
test('searchEmails 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 meeting'),
|
||||
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 meeting'),
|
||||
receivedAt: DateTime(2024, 6),
|
||||
),
|
||||
);
|
||||
|
||||
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
|
||||
expect(results, hasLength(2));
|
||||
expect(results[0].subject, 'Newer 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(
|
||||
'searchAddresses returns results sorted by most recently used',
|
||||
() async {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
|
||||
|
||||
void main() {
|
||||
group('FilterGroup', () {
|
||||
test('empty() creates an empty group', () {
|
||||
final g = FilterGroup.empty();
|
||||
expect(g.isEmpty, isTrue);
|
||||
expect(g.children, isEmpty);
|
||||
expect(g.operator, FilterOperator.and_);
|
||||
});
|
||||
|
||||
test('non-empty group is not isEmpty', () {
|
||||
final g = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.from_,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'test',
|
||||
),
|
||||
],
|
||||
);
|
||||
expect(g.isEmpty, isFalse);
|
||||
});
|
||||
|
||||
test('copyWith changes operator', () {
|
||||
final g = FilterGroup.empty().copyWith(operator: FilterOperator.or_);
|
||||
expect(g.operator, FilterOperator.or_);
|
||||
});
|
||||
|
||||
test('copyWith changes children', () {
|
||||
final leaf = FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'hello',
|
||||
);
|
||||
final g = FilterGroup.empty().copyWith(children: [leaf]);
|
||||
expect(g.children, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('FilterLeaf', () {
|
||||
test('copyWith changes field', () {
|
||||
final leaf = FilterLeaf(
|
||||
field: FilterField.from_,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'x',
|
||||
);
|
||||
final updated = leaf.copyWith(field: FilterField.to);
|
||||
expect(updated.field, FilterField.to);
|
||||
expect(updated.comparison, FilterComparison.contains);
|
||||
expect(updated.value, 'x');
|
||||
});
|
||||
|
||||
test('copyWith changes value', () {
|
||||
final leaf = FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.is_,
|
||||
value: 'old',
|
||||
);
|
||||
final updated = leaf.copyWith(value: 'new');
|
||||
expect(updated.value, 'new');
|
||||
expect(updated.field, FilterField.subject);
|
||||
});
|
||||
|
||||
test('size field allows over/under comparisons', () {
|
||||
expect(
|
||||
FilterField.size.allowedComparisons,
|
||||
containsAll([FilterComparison.over, FilterComparison.under]),
|
||||
);
|
||||
});
|
||||
|
||||
test('address fields do not allow over/under', () {
|
||||
for (final f in [FilterField.from_, FilterField.to, FilterField.cc]) {
|
||||
expect(f.allowedComparisons, isNot(contains(FilterComparison.over)));
|
||||
expect(f.allowedComparisons, isNot(contains(FilterComparison.under)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('SieveSerializer', () {
|
||||
final ser = SieveSerializer();
|
||||
|
||||
test('empty filter with keep action', () {
|
||||
final script = ser.serialize(FilterGroup.empty(), [KeepAction()]);
|
||||
expect(script, contains('keep;'));
|
||||
expect(script, isNot(contains('if ')));
|
||||
});
|
||||
|
||||
test('single from-contains condition', () {
|
||||
final group = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.from_,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'alice',
|
||||
),
|
||||
],
|
||||
);
|
||||
final script = ser.serialize(group, [FileIntoAction('Work')]);
|
||||
expect(script, contains('require'));
|
||||
expect(script, contains('fileinto'));
|
||||
expect(script, contains('"Work"'));
|
||||
expect(script, contains(':contains'));
|
||||
expect(script, contains('"from"'));
|
||||
expect(script, contains('"alice"'));
|
||||
});
|
||||
|
||||
test('AND group serialises as allof', () {
|
||||
final group = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'invoice',
|
||||
),
|
||||
FilterLeaf(
|
||||
field: FilterField.from_,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'supplier',
|
||||
),
|
||||
],
|
||||
);
|
||||
final script = ser.serialize(group, [KeepAction()]);
|
||||
expect(script, contains('allof'));
|
||||
});
|
||||
|
||||
test('OR group serialises as anyof', () {
|
||||
final group = FilterGroup(
|
||||
operator: FilterOperator.or_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'a',
|
||||
),
|
||||
FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'b',
|
||||
),
|
||||
],
|
||||
);
|
||||
final script = ser.serialize(group, [DiscardAction()]);
|
||||
expect(script, contains('anyof'));
|
||||
expect(script, contains('discard;'));
|
||||
});
|
||||
|
||||
test('size over condition', () {
|
||||
final group = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.size,
|
||||
comparison: FilterComparison.over,
|
||||
value: '1000000',
|
||||
),
|
||||
],
|
||||
);
|
||||
final script = ser.serialize(group, [DiscardAction()]);
|
||||
expect(script, contains('size :over 1000000'));
|
||||
});
|
||||
|
||||
test('mark-as-seen action emits setflag', () {
|
||||
final group = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'newsletter',
|
||||
),
|
||||
],
|
||||
);
|
||||
final script = ser.serialize(group, [MarkAsSeenAction()]);
|
||||
expect(script, contains('setflag'));
|
||||
expect(script, contains(r'\Seen'));
|
||||
});
|
||||
|
||||
test('escapes quotes in values', () {
|
||||
final group = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'say "hello"',
|
||||
),
|
||||
],
|
||||
);
|
||||
final script = ser.serialize(group, [KeepAction()]);
|
||||
expect(script, contains(r'say \"hello\"'));
|
||||
});
|
||||
});
|
||||
|
||||
group('FilterSieveConverter', () {
|
||||
final conv = FilterSieveConverter();
|
||||
|
||||
test('returns null for empty script', () {
|
||||
expect(conv.parse(''), isNull);
|
||||
});
|
||||
|
||||
test('parses simple address test', () {
|
||||
const script = '''
|
||||
if address :contains "from" "alice@example.com" {
|
||||
keep;
|
||||
}''';
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.group.children, hasLength(1));
|
||||
final leaf = result.group.children.first as FilterLeaf;
|
||||
expect(leaf.field, FilterField.from_);
|
||||
expect(leaf.comparison, FilterComparison.contains);
|
||||
expect(leaf.value, 'alice@example.com');
|
||||
expect(result.actions, hasLength(1));
|
||||
expect(result.actions.first, isA<KeepAction>());
|
||||
});
|
||||
|
||||
test('parses subject header test', () {
|
||||
const script = '''
|
||||
if header :is "subject" "Hello" {
|
||||
fileinto "Inbox";
|
||||
}''';
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
final leaf = result!.group.children.first as FilterLeaf;
|
||||
expect(leaf.field, FilterField.subject);
|
||||
expect(leaf.comparison, FilterComparison.is_);
|
||||
expect(leaf.value, 'Hello');
|
||||
final action = result.actions.first as FileIntoAction;
|
||||
expect(action.folder, 'Inbox');
|
||||
});
|
||||
|
||||
test('parses allof group as AND', () {
|
||||
const script = '''
|
||||
if allof(
|
||||
address :contains "from" "alice",
|
||||
header :contains "subject" "invoice"
|
||||
) {
|
||||
keep;
|
||||
}''';
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.group.operator, FilterOperator.and_);
|
||||
expect(result.group.children, hasLength(2));
|
||||
});
|
||||
|
||||
test('parses anyof group as OR', () {
|
||||
const script = '''
|
||||
if anyof(
|
||||
address :contains "from" "a",
|
||||
address :contains "from" "b"
|
||||
) {
|
||||
discard;
|
||||
}''';
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.group.operator, FilterOperator.or_);
|
||||
expect(result.actions.first, isA<DiscardAction>());
|
||||
});
|
||||
|
||||
test('parses size over test', () {
|
||||
const script = '''
|
||||
if size :over 500000 {
|
||||
discard;
|
||||
}''';
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
final leaf = result!.group.children.first as FilterLeaf;
|
||||
expect(leaf.field, FilterField.size);
|
||||
expect(leaf.comparison, FilterComparison.over);
|
||||
expect(leaf.value, '500000');
|
||||
});
|
||||
|
||||
test('parses setflag \\\\Seen as MarkAsSeenAction', () {
|
||||
const script = r'''
|
||||
if header :contains "subject" "newsletter" {
|
||||
setflag "\\Seen";
|
||||
}''';
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.actions.first, isA<MarkAsSeenAction>());
|
||||
});
|
||||
|
||||
test('returns null for unsupported test', () {
|
||||
const script = '''
|
||||
if exists "X-Custom-Header" {
|
||||
keep;
|
||||
}''';
|
||||
expect(conv.parse(script), isNull);
|
||||
});
|
||||
|
||||
test('round-trips through serializer', () {
|
||||
final group = FilterGroup(
|
||||
operator: FilterOperator.and_,
|
||||
children: [
|
||||
FilterLeaf(
|
||||
field: FilterField.from_,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'alice@example.com',
|
||||
),
|
||||
FilterLeaf(
|
||||
field: FilterField.subject,
|
||||
comparison: FilterComparison.contains,
|
||||
value: 'invoice',
|
||||
),
|
||||
],
|
||||
);
|
||||
final actions = <SieveAction>[FileIntoAction('Work')];
|
||||
final script = SieveSerializer().serialize(group, actions);
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
expect(result!.group.operator, FilterOperator.and_);
|
||||
expect(result.group.children, hasLength(2));
|
||||
expect(result.actions, hasLength(1));
|
||||
expect((result.actions.first as FileIntoAction).folder, 'Work');
|
||||
});
|
||||
|
||||
test('parses require block and ignores it', () {
|
||||
const script = '''
|
||||
require ["fileinto"];
|
||||
if address :contains "from" "bob" {
|
||||
fileinto "Archive";
|
||||
}''';
|
||||
final result = conv.parse(script);
|
||||
expect(result, isNotNull);
|
||||
final leaf = result!.group.children.first as FilterLeaf;
|
||||
expect(leaf.value, 'bob');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||
|
||||
void main() {
|
||||
group('globMatch', () {
|
||||
test('exact match (no wildcards)', () {
|
||||
expect(globMatch('alice@example.com', 'alice@example.com'), isTrue);
|
||||
expect(globMatch('alice@example.com', 'bob@example.com'), isFalse);
|
||||
});
|
||||
|
||||
test('* matches any domain wildcard', () {
|
||||
expect(globMatch('alice@example.com', '*@example.com'), isTrue);
|
||||
expect(globMatch('bob@example.com', '*@example.com'), isTrue);
|
||||
expect(globMatch('alice@other.com', '*@example.com'), isFalse);
|
||||
});
|
||||
|
||||
test('* matches zero or more characters', () {
|
||||
expect(
|
||||
globMatch('newsletter@news.example.com', '*@*.example.com'),
|
||||
isTrue,
|
||||
);
|
||||
expect(globMatch('alice@example.com', 'alice*'), isTrue);
|
||||
expect(globMatch('alice@example.com', '*example*'), isTrue);
|
||||
});
|
||||
|
||||
test('? matches exactly one character', () {
|
||||
expect(globMatch('alice@example.com', 'alice@exampl?.com'), isTrue);
|
||||
expect(globMatch('alice@example.com', 'alice@exampl??.com'), isFalse);
|
||||
});
|
||||
|
||||
test('case-insensitive comparison', () {
|
||||
expect(globMatch('Alice@Example.COM', '*@example.com'), isTrue);
|
||||
expect(globMatch('alice@example.com', '*@EXAMPLE.COM'), isTrue);
|
||||
});
|
||||
|
||||
test('no wildcards — mismatch is false', () {
|
||||
expect(globMatch('alice@example.com', 'alice@other.com'), isFalse);
|
||||
});
|
||||
|
||||
test('bare * matches everything', () {
|
||||
expect(globMatch('alice@example.com', '*'), isTrue);
|
||||
expect(globMatch('', '*'), isTrue);
|
||||
});
|
||||
|
||||
test('empty pattern only matches empty string', () {
|
||||
expect(globMatch('', ''), isTrue);
|
||||
expect(globMatch('alice@example.com', ''), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 39);
|
||||
expect(db.schemaVersion, 41);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -427,12 +427,192 @@ void main() {
|
||||
// v39: email_notes table.
|
||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||
|
||||
// v40: installed_versions table.
|
||||
await db.customSelect('SELECT count(*) FROM installed_versions').get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
},
|
||||
);
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 39', () async {
|
||||
test('v40→v41: IMAP email IDs gain mailboxPath segment', () async {
|
||||
final dbFile = File('test_migration_v40.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
|
||||
final rawDb = sqlite.sqlite3.open(dbFile.path);
|
||||
rawDb.execute('''
|
||||
CREATE TABLE accounts (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
imap_host TEXT NOT NULL DEFAULT '',
|
||||
imap_port INTEGER NOT NULL DEFAULT 993,
|
||||
imap_ssl INTEGER NOT NULL DEFAULT 1,
|
||||
smtp_host TEXT NOT NULL DEFAULT '',
|
||||
smtp_port INTEGER NOT NULL DEFAULT 465,
|
||||
smtp_ssl INTEGER NOT NULL DEFAULT 1,
|
||||
account_type TEXT NOT NULL DEFAULT 'imap',
|
||||
jmap_url TEXT NULL,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
verbose INTEGER NOT NULL DEFAULT 0,
|
||||
manage_sieve_host TEXT NOT NULL DEFAULT '',
|
||||
manage_sieve_port INTEGER NOT NULL DEFAULT 4190,
|
||||
manage_sieve_ssl INTEGER NOT NULL DEFAULT 1,
|
||||
manage_sieve_available INTEGER NULL
|
||||
)
|
||||
''');
|
||||
rawDb.execute('''
|
||||
CREATE TABLE emails (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||
mailbox_path TEXT NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
subject TEXT NULL,
|
||||
sent_at INTEGER NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
from_json TEXT NOT NULL DEFAULT '[]',
|
||||
to_addresses TEXT NOT NULL DEFAULT '[]',
|
||||
cc_json TEXT NOT NULL DEFAULT '[]',
|
||||
preview TEXT NULL,
|
||||
is_seen INTEGER NOT NULL DEFAULT 0,
|
||||
is_flagged INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachment INTEGER NOT NULL DEFAULT 0,
|
||||
thread_id TEXT NULL,
|
||||
message_id TEXT NULL,
|
||||
in_reply_to TEXT NULL,
|
||||
"references" TEXT NULL,
|
||||
snoozed_until INTEGER NULL,
|
||||
snoozed_from_mailbox_path TEXT NULL,
|
||||
list_unsubscribe_header TEXT NULL
|
||||
)
|
||||
''');
|
||||
rawDb.execute('''
|
||||
CREATE TABLE email_bodies (
|
||||
email_id TEXT NOT NULL PRIMARY KEY REFERENCES emails (id) ON DELETE CASCADE,
|
||||
text_body TEXT NULL,
|
||||
html_body TEXT NULL,
|
||||
attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||
cached_at INTEGER NULL,
|
||||
headers_json TEXT NULL,
|
||||
mime_tree_json TEXT NULL
|
||||
)
|
||||
''');
|
||||
rawDb.execute('''
|
||||
CREATE TABLE threads (
|
||||
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||
mailbox_path TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
subject TEXT NULL,
|
||||
latest_date INTEGER NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 1,
|
||||
has_unread INTEGER NOT NULL DEFAULT 0,
|
||||
is_flagged INTEGER NOT NULL DEFAULT 0,
|
||||
participants_json TEXT NOT NULL DEFAULT '[]',
|
||||
preview TEXT NULL,
|
||||
latest_email_id TEXT NOT NULL,
|
||||
email_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
PRIMARY KEY (account_id, mailbox_path, id)
|
||||
)
|
||||
''');
|
||||
rawDb.execute('''
|
||||
CREATE TABLE pending_changes (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE CASCADE,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
// Insert an IMAP account.
|
||||
rawDb.execute(
|
||||
"INSERT INTO accounts (id, display_name, email) VALUES ('acc-1', 'Alice', 'alice@example.com')",
|
||||
);
|
||||
|
||||
// Two emails with the same UID but in different mailboxes — old format.
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
rawDb.execute(
|
||||
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
|
||||
"VALUES ('acc-1:50', 'acc-1', 'INBOX', 50, $now, 'acc-1:50')",
|
||||
);
|
||||
rawDb.execute(
|
||||
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at) '
|
||||
"VALUES ('acc-1:50-arch', 'acc-1', 'Archive', 50, $now)",
|
||||
);
|
||||
// A third email with a Message-ID-based thread_id (should not be changed).
|
||||
rawDb.execute(
|
||||
'INSERT INTO emails (id, account_id, mailbox_path, uid, received_at, thread_id) '
|
||||
"VALUES ('acc-1:99', 'acc-1', 'INBOX', 99, $now, '<original@example.com>')",
|
||||
);
|
||||
|
||||
// Email body for the first email.
|
||||
rawDb.execute(
|
||||
"INSERT INTO email_bodies (email_id, text_body) VALUES ('acc-1:50', 'body text')",
|
||||
);
|
||||
|
||||
// Thread for the first email (old-format IDs).
|
||||
rawDb.execute(
|
||||
'INSERT INTO threads (account_id, mailbox_path, id, latest_date, latest_email_id, email_ids_json) '
|
||||
"VALUES ('acc-1', 'INBOX', 'acc-1:50', $now, 'acc-1:50', '[\"acc-1:50\"]')",
|
||||
);
|
||||
|
||||
// A pending change referencing the first email's old ID.
|
||||
rawDb.execute(
|
||||
'INSERT INTO pending_changes (account_id, resource_type, resource_id, change_type, payload, created_at) '
|
||||
"VALUES ('acc-1', 'Email', 'acc-1:50', 'flag_seen', '{\"seen\":true}', $now)",
|
||||
);
|
||||
|
||||
rawDb.execute('PRAGMA user_version = 40');
|
||||
rawDb.close();
|
||||
|
||||
// Open with Drift to trigger the migration.
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
// emails.id should now use the accountId:mailboxPath:uid format.
|
||||
final emailRows = await db.select(db.emails).get();
|
||||
final emailIds = emailRows.map((r) => r.id).toSet();
|
||||
expect(emailIds, contains('acc-1:INBOX:50'));
|
||||
expect(emailIds, contains('acc-1:Archive:50'));
|
||||
expect(emailIds, contains('acc-1:INBOX:99'));
|
||||
// Old-format IDs must be gone.
|
||||
expect(emailIds, isNot(contains('acc-1:50')));
|
||||
expect(emailIds, isNot(contains('acc-1:99')));
|
||||
|
||||
// email_bodies.email_id must be updated.
|
||||
final bodyRows = await db.select(db.emailBodies).get();
|
||||
expect(bodyRows, hasLength(1));
|
||||
expect(bodyRows.first.emailId, 'acc-1:INBOX:50');
|
||||
|
||||
// thread_id where it was the email's own ID should be updated.
|
||||
final inboxEmail = emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:50');
|
||||
expect(inboxEmail.threadId, 'acc-1:INBOX:50');
|
||||
|
||||
// thread_id based on a real Message-ID must be left unchanged.
|
||||
final inboxEmail99 =
|
||||
emailRows.firstWhere((r) => r.id == 'acc-1:INBOX:99');
|
||||
expect(inboxEmail99.threadId, '<original@example.com>');
|
||||
|
||||
// threads must be rebuilt with new-format IDs.
|
||||
final threadRows = await db.select(db.threads).get();
|
||||
final thread = threadRows.firstWhere((t) => t.mailboxPath == 'INBOX');
|
||||
expect(thread.latestEmailId, 'acc-1:INBOX:50');
|
||||
expect(thread.emailIdsJson, contains('acc-1:INBOX:50'));
|
||||
|
||||
// pending_changes.resource_id is not updated by the migration
|
||||
// (IMAP operations use payload uid/mailboxPath, so this is safe).
|
||||
final changeRows = await db.select(db.pendingChanges).get();
|
||||
expect(changeRows, hasLength(1));
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 41', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -462,6 +642,7 @@ void main() {
|
||||
'user_preferences', // v34
|
||||
'image_trusted_senders', // v37
|
||||
'email_notes', // v39
|
||||
'installed_versions', // v40
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -500,7 +681,46 @@ void main() {
|
||||
// v39: email_notes table.
|
||||
await db.customSelect('SELECT count(*) FROM email_notes').get();
|
||||
|
||||
// v40: installed_versions table.
|
||||
await db.customSelect('SELECT count(*) FROM installed_versions').get();
|
||||
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for https://codeberg.org/guettli/sharedinbox/issues/508:
|
||||
// _openConnection's setup callback must not crash when PRAGMA journal_mode =
|
||||
// WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5)
|
||||
// because a WorkManager background task already has the DB open in WAL mode.
|
||||
group('WAL setup (#508)', () {
|
||||
test(
|
||||
'setupPragmasForTesting does not throw when WAL is already active and '
|
||||
'another connection holds an open read transaction',
|
||||
() {
|
||||
final dbFile = File('test_wal_busy_508.db');
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
addTearDown(() {
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
// conn1: enable WAL and keep a read transaction open — simulates a
|
||||
// WorkManager background task that opened the DB before the foreground
|
||||
// app starts.
|
||||
final conn1 = sqlite.sqlite3.open(dbFile.path);
|
||||
conn1.execute('PRAGMA journal_mode = WAL;');
|
||||
conn1.execute('BEGIN;');
|
||||
conn1.select('SELECT 1;');
|
||||
|
||||
// conn2: run the exact production setup through setupPragmasForTesting.
|
||||
// This must not throw even though conn1 holds an open transaction and
|
||||
// the DB is already in WAL mode.
|
||||
final conn2 = sqlite.sqlite3.open(dbFile.path);
|
||||
expect(() => setupPragmasForTesting(conn2), returnsNormally);
|
||||
|
||||
conn1.execute('ROLLBACK;');
|
||||
conn1.close();
|
||||
conn2.close();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// checked the _running flag (only true after start() is called).
|
||||
|
||||
import 'package:flutter_test/flutter_test.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/core/models/mailbox.dart';
|
||||
@@ -144,6 +145,12 @@ class _FakeEmails implements EmailRepository {
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||
@override
|
||||
Future<List<Email>> searchEmailsStructured(
|
||||
String? a,
|
||||
FilterGroup f,
|
||||
) async =>
|
||||
[];
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
||||
@override
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.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/core/models/mailbox.dart';
|
||||
@@ -140,6 +141,12 @@ class _CountingEmails implements EmailRepository {
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
|
||||
@override
|
||||
Future<List<Email>> searchEmailsStructured(
|
||||
String? a,
|
||||
FilterGroup f,
|
||||
) async =>
|
||||
[];
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
|
||||
@override
|
||||
Future<List<EmailAddress>> searchAddresses(
|
||||
|
||||
@@ -7,10 +7,11 @@ import 'dart:async' as _i4;
|
||||
|
||||
import 'package:mockito/mockito.dart' as _i1;
|
||||
import 'package:mockito/src/dummies.dart' as _i5;
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i6;
|
||||
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
||||
import 'package:sharedinbox/core/models/undo_action.dart' as _i7;
|
||||
import 'package:sharedinbox/core/models/undo_action.dart' as _i8;
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
|
||||
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6;
|
||||
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7;
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: avoid_redundant_argument_values
|
||||
@@ -342,6 +343,22 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> searchEmailsStructured(
|
||||
String? accountId,
|
||||
_i6.FilterGroup? filter,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#searchEmailsStructured,
|
||||
[
|
||||
accountId,
|
||||
filter,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
@@ -558,13 +575,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
|
||||
/// A class which mocks [UndoRepository].
|
||||
///
|
||||
/// See the documentation for Mockito's code generation for more information.
|
||||
class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
|
||||
class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository {
|
||||
MockUndoRepository() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i4.Future<void> saveAction(_i7.UndoAction? action) => (super.noSuchMethod(
|
||||
_i4.Future<void> saveAction(_i8.UndoAction? action) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#saveAction,
|
||||
[action],
|
||||
@@ -584,15 +601,15 @@ class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
|
||||
) as _i4.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i7.UndoAction>> getHistory({int? limit = 10}) =>
|
||||
_i4.Future<List<_i8.UndoAction>> getHistory({int? limit = 10}) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getHistory,
|
||||
[],
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue: _i4.Future<List<_i7.UndoAction>>.value(<_i7.UndoAction>[]),
|
||||
) as _i4.Future<List<_i7.UndoAction>>);
|
||||
returnValue: _i4.Future<List<_i8.UndoAction>>.value(<_i8.UndoAction>[]),
|
||||
) as _i4.Future<List<_i8.UndoAction>>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> clearHistory() => (super.noSuchMethod(
|
||||
|
||||
@@ -50,7 +50,10 @@ Widget _buildScreen({List<Account> accounts = const []}) {
|
||||
FakeAccountRepository(accounts),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(home: AboutScreen()),
|
||||
child: MaterialApp(
|
||||
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||
home: const AboutScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||
|
||||
class _FakeAssetBundle extends CachingAssetBundle {
|
||||
@@ -19,16 +23,33 @@ class _FakeAssetBundle extends CachingAssetBundle {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildScreen({
|
||||
required Map<String, String> assets,
|
||||
Map<String, DateTime> installedVersions = const {},
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
dbProvider.overrideWith((ref) {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
ref.onDispose(db.close);
|
||||
return db;
|
||||
}),
|
||||
installedVersionsProvider.overrideWith((ref) async => installedVersions),
|
||||
],
|
||||
child: DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle(assets),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const _fakeChangelog =
|
||||
'* 2024-01-01 feat: initial release\n* 2024-01-02 fix: resolve crash\n';
|
||||
|
||||
void main() {
|
||||
testWidgets('ChangeLogScreen shows changelog content', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({'assets/changelog.txt': _fakeChangelog}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
_buildScreen(assets: {'assets/changelog.txt': _fakeChangelog}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -41,14 +62,58 @@ void main() {
|
||||
testWidgets('ChangeLogScreen shows error when asset is missing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: _FakeAssetBundle({}),
|
||||
child: const MaterialApp(home: ChangeLogScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(_buildScreen(assets: {}));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Error loading changelog'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen injects install marker for a known hash', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog =
|
||||
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||
final installedAt = DateTime(2024, 6, 15, 14, 32);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(
|
||||
assets: {'assets/changelog.txt': changelog},
|
||||
installedVersions: {'abc1234': installedAt},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Installed: 14:32'), findsOneWidget);
|
||||
expect(find.textContaining('15 Jun 2024'), findsOneWidget);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen shows no markers when no version recorded', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog =
|
||||
'* 2024-01-01 [abc1234](https://example.com/abc1234): feat: initial release\n';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Installed:'), findsNothing);
|
||||
expect(find.textContaining('initial release'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ChangeLogScreen renders #NNN as a tappable link', (
|
||||
tester,
|
||||
) async {
|
||||
const changelog = '* 2024-03-01 fix: resolve crash, see #42\n';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildScreen(assets: {'assets/changelog.txt': changelog}),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The link text "#42" must be visible in the rendered output.
|
||||
expect(find.textContaining('#42'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -102,30 +104,6 @@ void main() {
|
||||
expect(find.byIcon(Icons.star), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tapping search icon shows search bar', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(FakeEmailRepository()),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.search));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Search…'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submitting a search query shows "No results" when empty', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -430,6 +408,230 @@ void main() {
|
||||
expect(find.text('Result email'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping first of multiple search results opens the first email',
|
||||
(tester) async {
|
||||
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
||||
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
searchResults: [email1, email2],
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Match');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alpha Match'), findsOneWidget);
|
||||
expect(find.text('Beta Match'), findsOneWidget);
|
||||
|
||||
// Tap the first result.
|
||||
await tester.tap(find.text('Alpha Match'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(EmailDetailScreen), findsOneWidget);
|
||||
// The detail body header shows the first email's subject.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(EmailDetailScreen),
|
||||
matching: find.text('Alpha Match'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
// The second email's subject must not appear in the detail view.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(EmailDetailScreen),
|
||||
matching: find.text('Beta Match'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'stale search results from a slower concurrent search are discarded',
|
||||
(tester) async {
|
||||
// Reproduces: user types quickly, triggering multiple concurrent IMAP
|
||||
// searches. An older, slower search must not overwrite the results for
|
||||
// the user's current query (issue #467).
|
||||
final staleEmail = testEmail(id: 'acc-1:1', subject: 'Stale Result');
|
||||
final freshEmail = testEmail(id: 'acc-1:2', subject: 'Fresh Result');
|
||||
|
||||
// The first search call is held open by a Completer; all subsequent
|
||||
// calls resolve immediately with freshEmail.
|
||||
final staleCompleter = Completer<List<Email>>();
|
||||
var firstCall = true;
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
onSearch: (_) {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return staleCompleter.future;
|
||||
}
|
||||
return Future.value([freshEmail]);
|
||||
},
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Trigger the first (slow) search.
|
||||
await tester.enterText(find.byType(TextField), 'slow');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
// Do not pumpAndSettle yet — the slow search is still in flight.
|
||||
|
||||
// Trigger the second (fast) search by changing the query.
|
||||
await tester.enterText(find.byType(TextField), 'fast');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle(); // fast searches settle immediately
|
||||
|
||||
// The fresh results must be shown.
|
||||
expect(find.text('Fresh Result'), findsOneWidget);
|
||||
expect(find.text('Stale Result'), findsNothing);
|
||||
|
||||
// Now let the stale search complete.
|
||||
staleCompleter.complete([staleEmail]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The stale results must NOT replace the fresh ones.
|
||||
expect(find.text('Fresh Result'), findsOneWidget);
|
||||
expect(find.text('Stale Result'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'pressing Enter on already-settled search does not re-run search (issue #473)',
|
||||
(tester) async {
|
||||
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Match');
|
||||
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Match');
|
||||
|
||||
var searchCallCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
onSearch: (_) async {
|
||||
searchCallCount++;
|
||||
return [email1, email2];
|
||||
},
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Run the initial search.
|
||||
await tester.enterText(find.byType(TextField), 'Match');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alpha Match'), findsOneWidget);
|
||||
expect(find.text('Beta Match'), findsOneWidget);
|
||||
|
||||
final countAfterFirstSearch = searchCallCount;
|
||||
|
||||
// Re-focus the search bar (simulates user tapping back into the field
|
||||
// with the keyboard still visible) and press Enter again on the same,
|
||||
// already-settled query.
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pump();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The search must NOT re-run; call count must not increase.
|
||||
expect(
|
||||
searchCallCount,
|
||||
countAfterFirstSearch,
|
||||
reason:
|
||||
'Enter on settled results must not re-run the search (issue #473)',
|
||||
);
|
||||
// Results must still be visible — no loading spinner.
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.text('Alpha Match'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'folder search returns results from local cache without any network call',
|
||||
(tester) async {
|
||||
// Verifies that searchEmails is backed by local SQLite (not IMAP).
|
||||
// The repository throws if a network call is attempted, yet search
|
||||
// must still return results.
|
||||
final email = testEmail(subject: 'Cached subject');
|
||||
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
onSearch: (_) async {
|
||||
// Local DB: return cached results immediately.
|
||||
return [email];
|
||||
},
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'Cached');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Cached subject'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('deleting all search results pops back to previous screen', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -596,6 +798,67 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'pressing Enter after search settles does not reorder results',
|
||||
(tester) async {
|
||||
// Reproduces: user types a query → onChanged fires → results settle.
|
||||
// Then user presses Enter → onSubmitted fires a second search → the
|
||||
// second IMAP response may return results in a different order, so the
|
||||
// tile the user is about to tap is no longer the email they expect.
|
||||
final email1 = testEmail(id: 'acc-1:1', subject: 'Alpha Foo');
|
||||
final email2 = testEmail(id: 'acc-1:2', subject: 'Beta Foo');
|
||||
var callCount = 0;
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
|
||||
overrides: [
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
FakeAccountRepository([kTestAccount]),
|
||||
),
|
||||
mailboxRepositoryProvider.overrideWithValue(
|
||||
FakeMailboxRepository(),
|
||||
),
|
||||
emailRepositoryProvider.overrideWithValue(
|
||||
FakeEmailRepository(
|
||||
onSearch: (_) async {
|
||||
callCount++;
|
||||
// First call: [Alpha, Beta]. Second call: reversed.
|
||||
return callCount == 1 ? [email1, email2] : [email2, email1];
|
||||
},
|
||||
emailBody: const EmailBody(emailId: '', attachments: []),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Typing triggers onChanged → first search → results settle.
|
||||
await tester.enterText(find.byType(TextField), 'foo');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alpha Foo'), findsOneWidget);
|
||||
expect(find.text('Beta Foo'), findsOneWidget);
|
||||
// Alpha must appear above Beta (it is first in the list).
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Alpha Foo')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
|
||||
);
|
||||
|
||||
// Pressing Enter triggers onSubmitted — must NOT re-run the search.
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Order must be unchanged: pressing Enter must not reorder results.
|
||||
expect(find.text('Alpha Foo'), findsOneWidget);
|
||||
expect(find.text('Beta Foo'), findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(find.text('Alpha Foo')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Beta Foo')).dy),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows preview snippet when email has preview', (tester) async {
|
||||
final email = Email(
|
||||
id: 'acc-1:99',
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/discovery_result.dart';
|
||||
import 'package:sharedinbox/core/models/draft.dart';
|
||||
@@ -43,6 +44,7 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/search_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -216,12 +218,17 @@ class FakeEmailRepository implements EmailRepository {
|
||||
|
||||
final List<Email> _searchResults;
|
||||
|
||||
/// Optional override: when set, [searchEmails] calls this instead of
|
||||
/// returning [_searchResults]. Useful for testing race-condition fixes.
|
||||
final Future<List<Email>> Function(String query)? onSearch;
|
||||
|
||||
FakeEmailRepository({
|
||||
List<Email>? emails,
|
||||
Email? emailDetail,
|
||||
EmailBody? emailBody,
|
||||
List<Email>? searchResults,
|
||||
String rawRfc822 = '',
|
||||
this.onSearch,
|
||||
}) : _emails = emails ?? [],
|
||||
_emailDetail = emailDetail,
|
||||
_searchResults = searchResults ?? [],
|
||||
@@ -274,7 +281,15 @@ class FakeEmailRepository implements EmailRepository {
|
||||
Stream.value(_emails.where((e) => e.threadId == threadId).toList());
|
||||
|
||||
@override
|
||||
Future<Email?> getEmail(String emailId) async => _emailDetail;
|
||||
Future<Email?> getEmail(String emailId) async {
|
||||
for (final e in _searchResults) {
|
||||
if (e.id == emailId) return e;
|
||||
}
|
||||
for (final e in _emails) {
|
||||
if (e.id == emailId) return e;
|
||||
}
|
||||
return _emailDetail;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmailBody> getEmailBody(String emailId) async => _emailBody;
|
||||
@@ -340,8 +355,10 @@ class FakeEmailRepository implements EmailRepository {
|
||||
String accountId,
|
||||
String mailboxPath,
|
||||
String query,
|
||||
) async =>
|
||||
_searchResults;
|
||||
) async {
|
||||
if (onSearch != null) return onSearch!(query);
|
||||
return _searchResults;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsGlobal(
|
||||
@@ -350,6 +367,13 @@ class FakeEmailRepository implements EmailRepository {
|
||||
) async =>
|
||||
_searchResults;
|
||||
|
||||
@override
|
||||
Future<List<Email>> searchEmailsStructured(
|
||||
String? accountId,
|
||||
FilterGroup filter,
|
||||
) async =>
|
||||
[];
|
||||
|
||||
@override
|
||||
Future<List<Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
@@ -461,6 +485,12 @@ Widget buildApp({
|
||||
path: 'preferences',
|
||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'trusted-senders',
|
||||
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||
highlightedSender: state.extra as String?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
@@ -565,6 +595,7 @@ Widget buildApp({
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -572,6 +603,7 @@ Widget buildApp({
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -671,6 +703,9 @@ class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
AfterMailViewAction afterMailViewAction;
|
||||
final List<String> _trustedImageSenders;
|
||||
|
||||
List<String> get trustedImageSendersForTest =>
|
||||
List.unmodifiable(_trustedImageSenders);
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() => Stream.value(
|
||||
UserPreferences(
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('TrustedImageSendersScreen', () {
|
||||
testWidgets('shows empty state with glob hint when no senders', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('*@example.com'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.add), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('lists existing senders', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository(
|
||||
trustedImageSenders: ['alice@example.com', '*@work.com'],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('alice@example.com'), findsOneWidget);
|
||||
expect(find.text('*@work.com'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('add dialog shows glob hint text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Add allowed address'), findsOneWidget);
|
||||
expect(find.textContaining('*@example.com'), findsWidgets);
|
||||
expect(find.textContaining('* matches any characters'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Add button is disabled when input is empty', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final addButton = find.widgetWithText(TextButton, 'Add');
|
||||
final button = tester.widget<TextButton>(addButton);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
testWidgets('typing in dialog enables Add button and adds sender', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = FakeUserPreferencesRepository();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), '*@example.com');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final addButton = find.widgetWithText(TextButton, 'Add');
|
||||
final button = tester.widget<TextButton>(addButton);
|
||||
expect(button.onPressed, isNotNull);
|
||||
|
||||
await tester.tap(addButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.trustedImageSendersForTest, contains('*@example.com'));
|
||||
});
|
||||
|
||||
testWidgets('cancel closes dialog without adding', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository();
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'someone@test.com');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(repo.trustedImageSendersForTest, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('delete button removes a sender', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository(
|
||||
trustedImageSenders: ['alice@example.com'],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.trustedImageSendersForTest, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('lists existing glob patterns', (tester) async {
|
||||
final repo = FakeUserPreferencesRepository(
|
||||
trustedImageSenders: ['*@example.com', 'alice@other.com'],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/trusted-senders',
|
||||
overrides: baseOverrides(),
|
||||
userPreferences: repo,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('*@example.com'), findsOneWidget);
|
||||
expect(find.text('alice@other.com'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||