Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c0334532 | ||
|
|
8592bba9e3 | ||
|
|
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 | ||
|
|
8446b05601 | ||
|
|
bcece9f0af | ||
|
|
3bd404f0cf | ||
|
|
9ca7089c50 | ||
|
|
adef2e9f80 | ||
|
|
2788a43dda | ||
|
|
71dac3cbb2 | ||
|
|
913e5493f5 | ||
|
|
2612f4dbcd | ||
|
|
cca0e5d461 | ||
|
|
8718339b4e | ||
|
|
ccefccf6a6 | ||
|
|
7a4defbab4 | ||
|
|
31c0479fc9 | ||
|
|
bde782f511 | ||
|
|
0cefc8f8e7 | ||
|
|
3db1bd8ac2 | ||
|
|
515b12dd0f | ||
|
|
2ceabcacf0 | ||
|
|
a56eca0851 | ||
|
|
85c9df604b | ||
|
|
68950e6888 | ||
|
|
59a9ed9109 | ||
|
|
3d2288ab9f | ||
|
|
4ef441ab1b | ||
|
|
f28630fd7e | ||
|
|
6177605f22 | ||
|
|
ccfdfdb92e | ||
|
|
b631bdae24 | ||
|
|
4a07a175b9 | ||
|
|
2137d25d6d | ||
|
|
d03ee8b555 | ||
|
|
a82927cae8 | ||
|
|
6b1627b4c9 | ||
|
|
ef3255cd2b | ||
|
|
1aa2926f30 | ||
|
|
771ac691d9 | ||
|
|
65ac023622 | ||
|
|
838eee66bd | ||
|
|
6b4c2939ab | ||
|
|
0195f6e75c | ||
|
|
cd8c930000 | ||
|
|
b0354c7423 | ||
|
|
582f6764eb | ||
|
|
674d402ff9 | ||
|
|
09e20dd85f | ||
|
|
c1d314a621 | ||
|
|
fa5938c7bd | ||
|
|
f92f3debd7 | ||
|
|
692fa14d4d | ||
|
|
5e029a1365 | ||
|
|
87244de7da | ||
|
|
6d1df2d213 | ||
|
|
29c2c7e96c | ||
|
|
6a097976d3 | ||
|
|
d847d40ab0 | ||
|
|
761378f583 | ||
|
|
63da36c18a | ||
|
|
d3bd8dba92 | ||
|
|
9605c5e3b7 | ||
|
|
1681fb9202 | ||
|
|
d7a9c2b4f8 | ||
|
|
2747c4e63d | ||
|
|
dbc9d4dac8 | ||
|
|
34351d65a2 | ||
|
|
b0a09939c9 | ||
|
|
8ea8d71f42 | ||
|
|
3520f161e3 | ||
|
|
ed247baaac | ||
|
|
69bd7f5962 | ||
|
|
e0ecac20aa | ||
|
|
f9e0fadb68 | ||
|
|
aebc1e508e | ||
|
|
375fd18f9f | ||
|
|
ba21b802eb | ||
|
|
7974c28102 | ||
|
|
6303cc5ac1 | ||
|
|
9744fe1379 | ||
|
|
39a65b97e9 | ||
|
|
e5c5dc9db8 | ||
|
|
6703ffd69b | ||
|
|
43eafbd4c2 | ||
|
|
ee1fccf340 | ||
|
|
5757176937 | ||
|
|
180035ec55 | ||
|
|
68dabc56d0 | ||
|
|
8ee411d1c8 | ||
|
|
ec3ebfa4a3 | ||
|
|
d206c5aa79 | ||
|
|
1e2d1b6063 | ||
|
|
9290d87a7f | ||
|
|
264ce7e349 | ||
|
|
b3f5ad4110 | ||
|
|
7e3308cb94 | ||
|
|
c6e7c035f2 | ||
|
|
71ec760365 | ||
|
|
2a9a5f339a | ||
|
|
ea5d119706 |
@@ -4,14 +4,18 @@
|
|||||||
# In systemd service:
|
# In systemd service:
|
||||||
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||||
|
|
||||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||||
|
|
||||||
# Infrastructure tools required by CI workflows
|
# Infrastructure tools required by CI workflows
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
stunnel4 \
|
jq \
|
||||||
netcat-openbsd \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# SOPS
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64 \
|
||||||
|
&& chmod +x /usr/local/bin/sops
|
||||||
|
|
||||||
# Dagger CLI — pinned to match the engine version on the runner host
|
# Dagger CLI — pinned to match the engine version on the runner host
|
||||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|||||||
@@ -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,159 +1,39 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
paths:
|
- main
|
||||||
- 'lib/**'
|
|
||||||
- 'test/**'
|
|
||||||
- 'integration_test/**'
|
|
||||||
- 'android/**'
|
|
||||||
- 'linux/**'
|
|
||||||
- 'assets/**'
|
|
||||||
- '!assets/changelog.txt'
|
|
||||||
- 'pubspec.yaml'
|
|
||||||
- 'pubspec.lock'
|
|
||||||
- 'analysis_options.yaml'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'stalwart-dev/**'
|
|
||||||
- 'ci/**'
|
|
||||||
- 'Taskfile.yml'
|
|
||||||
- 'drift_schemas/**'
|
|
||||||
- '.forgejo/workflows/ci.yml'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
concurrency:
|
||||||
- 'lib/**'
|
group: ci-${{ github.ref }}
|
||||||
- 'test/**'
|
cancel-in-progress: true
|
||||||
- 'integration_test/**'
|
|
||||||
- 'android/**'
|
|
||||||
- 'linux/**'
|
|
||||||
- 'assets/**'
|
|
||||||
- '!assets/changelog.txt'
|
|
||||||
- 'pubspec.yaml'
|
|
||||||
- 'pubspec.lock'
|
|
||||||
- 'analysis_options.yaml'
|
|
||||||
- 'scripts/**'
|
|
||||||
- 'stalwart-dev/**'
|
|
||||||
- 'ci/**'
|
|
||||||
- 'Taskfile.yml'
|
|
||||||
- 'drift_schemas/**'
|
|
||||||
- '.forgejo/workflows/ci.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
name: Full Project Check
|
name: Full Project Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Print runner wait time
|
||||||
with:
|
|
||||||
fetch-depth: 50
|
|
||||||
|
|
||||||
- name: Check runner tools
|
|
||||||
run: |
|
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
|
||||||
env:
|
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
|
||||||
|
|
||||||
- name: Locate Docker daemon for local Dagger engine
|
|
||||||
run: |
|
|
||||||
# Skip if remote Dagger engine is already configured (preferred path)
|
|
||||||
if [ -n "${_DAGGER_RUNNER_HOST:-}" ]; then
|
|
||||||
echo "Remote Dagger engine configured, no local Docker needed."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try host Docker socket (DooD) if runner mounts it
|
|
||||||
if [ -S /var/run/docker.sock ]; then
|
|
||||||
if DOCKER_HOST=unix:///var/run/docker.sock docker info >/dev/null 2>&1; then
|
|
||||||
echo "Docker available via host socket."
|
|
||||||
echo "DOCKER_HOST=unix:///var/run/docker.sock" >> "$GITHUB_ENV"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "WARNING: No remote Dagger engine and no local Docker found." >&2
|
|
||||||
echo " - Remote engine: check DAGGER_STUNNEL_URL secret and that the host proxy is running." >&2
|
|
||||||
echo " - Local Docker: runner does not expose /var/run/docker.sock." >&2
|
|
||||||
echo "CI will likely fail at the Dagger step." >&2
|
|
||||||
|
|
||||||
- name: Prune Dagger cache before check
|
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
# prune(maxUsedSpace) also reclaims named cache volumes (gradle-cache, go-build-cache, etc.)
|
|
||||||
# when total cache exceeds the limit; without args only unreferenced entries are removed.
|
|
||||||
run: |
|
|
||||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
|
||||||
|
|
||||||
- name: Run Full Check Suite
|
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: task check-dagger
|
|
||||||
|
|
||||||
- name: Prune Dagger cache after check
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
DAGGER_NO_NAG: "1"
|
|
||||||
run: |
|
|
||||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }' || true
|
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
merge-renovate:
|
|
||||||
name: Auto-merge Renovate PR
|
|
||||||
needs: [check]
|
|
||||||
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Merge if automerge label is set
|
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
RUN_NUMBER: ${{ github.run_number }}
|
||||||
run: |
|
run: |
|
||||||
python3 - << 'PYEOF'
|
runner_start=$(date +%s)
|
||||||
import os, json, urllib.request, urllib.error, sys
|
created=$(curl -sf --max-time 30 \
|
||||||
|
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||||
token = os.environ["FORGEJO_TOKEN"]
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
||||||
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
if [ -n "$created" ]; then
|
||||||
pr_number = os.environ["PR_NUMBER"]
|
queued_epoch=$(date -d "$created" +%s)
|
||||||
api = f"{url_base}/api/v1/repos/{repo}"
|
wait_seconds=$((runner_start - queued_epoch))
|
||||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
||||||
|
else
|
||||||
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
|
echo "Runner wait time: unknown (API lookup failed)"
|
||||||
with urllib.request.urlopen(req) as r:
|
fi
|
||||||
labels = [l["name"] for l in json.loads(r.read())]
|
- uses: actions/checkout@v4
|
||||||
|
- name: Setup Dagger Remote Engine
|
||||||
if "automerge" not in labels:
|
env:
|
||||||
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
sys.exit(0)
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
- name: Run Full Check Suite
|
||||||
body = json.dumps({"Do": "merge"}).encode()
|
run: task check-dagger
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{api}/pulls/{pr_number}/merge",
|
|
||||||
data=body, headers=headers, method="POST"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req) as r:
|
|
||||||
print(f"PR #{pr_number} merged successfully")
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
err = e.read().decode()
|
|
||||||
if "already been merged" in err or "has been merged" in err:
|
|
||||||
print(f"PR #{pr_number} already merged — OK")
|
|
||||||
else:
|
|
||||||
print(f"Merge failed: {err}")
|
|
||||||
sys.exit(1)
|
|
||||||
PYEOF
|
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ jobs:
|
|||||||
linux: ${{ steps.diff.outputs.linux }}
|
linux: ${{ steps.diff.outputs.linux }}
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -34,46 +51,61 @@ jobs:
|
|||||||
|
|
||||||
HEAD_SHA=$(git rev-parse HEAD)
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Skip if this exact commit was already successfully deployed (prevents
|
# Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
|
||||||
# hourly schedule from redeploying the same commit on every tick).
|
# 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'
|
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||||
import json, os, sys, urllib.request
|
import json, os, sys, urllib.request
|
||||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
url = f"{server}/api/v1/repos/{repo}/actions/tasks?status=success&limit=100"
|
||||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
runs = [
|
for t in data.get("workflow_runs", []):
|
||||||
r for r in data.get("workflow_runs", [])
|
if (t.get("workflow_id") == "deploy.yml"
|
||||||
if r.get("status") == "success"
|
and t.get("name") == "Build & Deploy to Play Store"
|
||||||
]
|
and t.get("status") == "success"):
|
||||||
print(runs[0].get("commit_sha") or "")
|
print(t.get("head_sha") or "")
|
||||||
|
sys.exit(0)
|
||||||
|
print("")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"API check failed: {e}", file=sys.stderr)
|
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
||||||
print("")
|
print("")
|
||||||
PYEOF
|
PYEOF
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
||||||
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
|
||||||
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||||
|
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Diff from the last successfully deployed commit to catch all changes since
|
# Diff from the last successfully deployed commit to catch all changes since
|
||||||
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
|
# that deploy, not just the most recent commit. Deploy all targets when the
|
||||||
# LAST_DEPLOYED_SHA is unknown or not in local history.
|
# SHA is not in local history (shallow clone or very old deploy).
|
||||||
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
||||||
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
|
||||||
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|
||||||
|| git show --name-only --format= HEAD)
|
|| git show --name-only --format= HEAD)
|
||||||
else
|
else
|
||||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
echo "::warning::Last deployed SHA $LAST_DEPLOYED_SHA not in local history — deploying all targets as a precaution"
|
||||||
|| git show --name-only --format= HEAD)
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
@@ -82,13 +114,25 @@ jobs:
|
|||||||
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
android_re='^(android/|integration_test/|lib/|pubspec\.yaml|pubspec\.lock|drift_schemas/|scripts/deploy_playstore\.py)'
|
||||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$android_re" \
|
if echo "$CHANGED" | grep -qE "$android_re"; then
|
||||||
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||||
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Android deploy: SKIPPED (no android-relevant files changed)"
|
||||||
|
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "$CHANGED" | grep -qE "$linux_re" \
|
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
||||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||||
|
else
|
||||||
|
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
|
||||||
|
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
|
||||||
|
fi
|
||||||
|
|
||||||
deploy-playstore:
|
deploy-playstore:
|
||||||
name: Build & Deploy to Play Store
|
name: Build & Deploy to Play Store
|
||||||
@@ -98,6 +142,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.android == 'true'
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -106,28 +167,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Publish Android to Play Store
|
- name: Publish Android to Play Store
|
||||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
|
||||||
env:
|
env:
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
PLAY_STORE_CONFIG_JSON: ${{ secrets.PLAY_STORE_CONFIG_JSON }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-android
|
run: task publish-android
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
- name: Verify Play Store deployment
|
||||||
if: always()
|
run: |
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
python3 -m venv /tmp/playstore-venv
|
||||||
|
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
|
||||||
|
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
|
||||||
|
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
name: Build & Deploy APK to Server
|
name: Build & Deploy APK to Server
|
||||||
@@ -137,6 +193,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.android == 'true'
|
if: needs.check-changes.outputs.android == 'true'
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -145,31 +218,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy APK to server
|
- name: Build & Deploy APK to server
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-apk
|
run: task deploy-apk
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
name: Build Linux Release
|
name: Build Linux Release
|
||||||
@@ -179,6 +238,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.linux == 'true'
|
if: needs.check-changes.outputs.linux == 'true'
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
@@ -187,29 +263,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Deploy Linux to server
|
- name: Build & Deploy Linux to server
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task deploy-linux
|
run: task deploy-linux
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
label-deploy-health:
|
label-deploy-health:
|
||||||
name: Update Deploy Health Label
|
name: Update Deploy Health Label
|
||||||
@@ -224,6 +288,23 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
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
|
- name: Set CI/Full-Pass or CI/Full-Fail label on tracking issue
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ jobs:
|
|||||||
has_changes: ${{ steps.diff.outputs.has_changes }}
|
has_changes: ${{ steps.diff.outputs.has_changes }}
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -50,6 +67,23 @@ jobs:
|
|||||||
if: needs.check-changes.outputs.has_changes == 'true'
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
@@ -58,28 +92,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Run Android Tests on Firebase Test Lab
|
- name: Run Android Tests on Firebase Test Lab
|
||||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
|
||||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task test-android-firebase
|
run: task test-android-firebase
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|
||||||
- name: Create issue on test failure
|
- name: Create issue on test failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -18,22 +18,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Run Renovate
|
- name: Run Renovate
|
||||||
env:
|
env:
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
RENOVATE_FORGEJO_TOKEN: ${{ secrets.RENOVATE_FORGEJO_TOKEN }}
|
|
||||||
run: task renovate
|
run: task renovate
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|||||||
@@ -12,12 +12,122 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
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:
|
deploy:
|
||||||
name: Build & Update Website
|
name: Build & Update Website
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
|
needs: [check-changes]
|
||||||
|
if: needs.check-changes.outputs.has_changes == 'true'
|
||||||
|
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
@@ -26,32 +136,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
|
||||||
|
|
||||||
- name: Setup Dagger Remote Engine (via stunnel)
|
- name: Setup Dagger Remote Engine
|
||||||
env:
|
env:
|
||||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
|
||||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
|
||||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
|
||||||
run: scripts/setup_dagger_remote.sh
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Update Website
|
- name: Build & Update Website
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
DAGGER_NO_NAG: "1"
|
DAGGER_NO_NAG: "1"
|
||||||
run: task publish-website
|
run: task publish-website
|
||||||
|
|
||||||
- name: Verify Website
|
- name: Verify Website
|
||||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
||||||
run: scripts/website-verify.sh
|
run: scripts/website-verify.sh
|
||||||
|
|
||||||
- name: Cleanup TLS credentials
|
|
||||||
if: always()
|
|
||||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ jobs:
|
|||||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||||
name: Build & Deploy Windows (Nightly)
|
name: Build & Deploy Windows (Nightly)
|
||||||
runs-on: windows-runner
|
runs-on: windows-runner
|
||||||
|
timeout-minutes: 90
|
||||||
if: false
|
if: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze-and-test:
|
|
||||||
name: Analyze & unit test
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
run: dart format --set-exit-if-changed .
|
|
||||||
|
|
||||||
- name: Analyze
|
|
||||||
run: flutter analyze --fatal-infos
|
|
||||||
|
|
||||||
- name: Unit + widget tests with coverage
|
|
||||||
run: flutter test test/unit/ test/widget/ --coverage
|
|
||||||
|
|
||||||
- name: Coverage gate
|
|
||||||
run: dart run scripts/check_coverage.dart
|
|
||||||
|
|
||||||
integration:
|
|
||||||
name: Integration tests (Stalwart)
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
# Run integration tests only on push to main, not on every PR.
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Run integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
integration-ui:
|
|
||||||
name: UI Integration tests (Stalwart + Xvfb)
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/nix-installer-action@v14
|
|
||||||
|
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@v8
|
|
||||||
|
|
||||||
- name: Install Flutter Linux build dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev
|
|
||||||
|
|
||||||
- name: Cache FVM Flutter SDK
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.fvm
|
|
||||||
key: fvm-${{ hashFiles('.fvm/fvm_config.json') }}
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Cache Linux debug build
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
build/linux
|
|
||||||
.dart_tool/flutter_build
|
|
||||||
key: linux-debug-${{ hashFiles('pubspec.lock', 'lib/**/*.dart', 'integration_test/**/*.dart') }}
|
|
||||||
restore-keys: linux-debug-
|
|
||||||
|
|
||||||
- name: Run UI integration tests
|
|
||||||
run: |
|
|
||||||
nix develop --command bash -c "
|
|
||||||
fvm install --skip-pub-get &&
|
|
||||||
fvm flutter pub get &&
|
|
||||||
fvm flutter pub run build_runner build --delete-conflicting-outputs &&
|
|
||||||
stalwart-dev/integration_ui_test.sh
|
|
||||||
"
|
|
||||||
|
|
||||||
build-linux:
|
|
||||||
name: Build Linux desktop
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
needs: analyze-and-test
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install GTK3, build tools and libsecret
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Build Linux release
|
|
||||||
run: flutter build linux --release
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy Linux build & publish website
|
|
||||||
runs-on: sharedinbox-runner
|
|
||||||
needs: build-linux
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
env:
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install build & deploy dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -q
|
|
||||||
sudo apt-get install -y --no-install-recommends \
|
|
||||||
libgtk-3-dev pkg-config cmake ninja-build clang \
|
|
||||||
libsecret-1-dev hugo rsync
|
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
flutter-version: "3.41.6"
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Cache pub packages
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.pub-cache
|
|
||||||
key: pub-${{ hashFiles('pubspec.lock') }}
|
|
||||||
restore-keys: pub-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Generate Drift code
|
|
||||||
run: flutter pub run build_runner build --delete-conflicting-outputs
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
run: |
|
|
||||||
mkdir -p assets
|
|
||||||
git log -n 50 \
|
|
||||||
--pretty=format:'* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s' \
|
|
||||||
--date=short > assets/changelog.txt
|
|
||||||
|
|
||||||
- name: Setup SSH
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Build Linux release
|
|
||||||
run: |
|
|
||||||
HASH=$(git rev-parse --short HEAD)
|
|
||||||
flutter build linux --release --no-pub --dart-define=GIT_HASH=$HASH
|
|
||||||
|
|
||||||
- name: Deploy Linux build to server
|
|
||||||
run: |
|
|
||||||
HASH=$(git rev-parse --short HEAD)
|
|
||||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
|
||||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
|
||||||
TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz"
|
|
||||||
tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle
|
|
||||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
|
||||||
scp /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
|
||||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
|
||||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" \
|
|
||||||
"cat public_html/latest.json 2>/dev/null || echo '{}'")
|
|
||||||
WINDOWS_URL=$(echo "$EXISTING" | \
|
|
||||||
python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('windows',''))" \
|
|
||||||
2>/dev/null || true)
|
|
||||||
if [ -n "$WINDOWS_URL" ]; then
|
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\",\"windows\":\"$WINDOWS_URL\"}" | \
|
|
||||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
|
||||||
else
|
|
||||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
|
||||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Generate build history pages
|
|
||||||
run: python3 scripts/generate_build_history.py
|
|
||||||
|
|
||||||
- name: Build website
|
|
||||||
env:
|
|
||||||
HUGO_PARAMS_GITVERSION: ${{ github.sha }}
|
|
||||||
run: hugo --source website --minify
|
|
||||||
|
|
||||||
- name: Deploy website
|
|
||||||
run: |
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude='*.apk' \
|
|
||||||
--exclude='*.tar.gz' \
|
|
||||||
website/public/ \
|
|
||||||
"$SSH_USER@$SSH_HOST:public_html/"
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# --- Flutter/Dart ---
|
# --- Flutter/Dart ---
|
||||||
coverage/
|
coverage/
|
||||||
|
screenshots/
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.dart-tool/
|
.dart-tool/
|
||||||
.packages
|
.packages
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ repos:
|
|||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
|
- repo: https://github.com/guettli/sync-branch
|
||||||
|
rev: v0.0.11
|
||||||
|
hooks:
|
||||||
|
- id: sync-branch
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-no-binary
|
- id: check-no-binary
|
||||||
@@ -27,7 +32,7 @@ repos:
|
|||||||
- id: dart-check
|
- id: dart-check
|
||||||
name: dart format (autofix) + check-fast (parallel)
|
name: dart format (autofix) + check-fast (parallel)
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
@@ -42,3 +47,15 @@ repos:
|
|||||||
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
|
- id: ci-image-exists
|
||||||
|
name: verify container images in ci/main.go are reachable
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/main\.go|\.fvmrc)$
|
||||||
|
- id: dagger-versions-aligned
|
||||||
|
name: verify Dagger version is consistent across dagger.json, flake.nix, Dockerfile and DAGGER.md
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_dagger_versions.sh'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/dagger\.json|flake\.nix|\.forgejo/Dockerfile|DAGGER\.md)$
|
||||||
|
|||||||
@@ -13,23 +13,27 @@ Automation is handled by [agentloop](https://github.com/guettli/agentloop) runni
|
|||||||
| Label | Trigger | Outcome |
|
| 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/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:**
|
**State machine:**
|
||||||
|
|
||||||
```
|
```
|
||||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
loop/plan → loop/plan-in-process → loop/plan-done
|
||||||
↘ NeedSupervisor (on failure)
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
loop/code → loop/code-in-progress → loop/code-done
|
loop/code → loop/code-in-process → loop/merge (via route)
|
||||||
↘ NeedSupervisor (on failure)
|
↘ NeedSupervisor (on failure)
|
||||||
|
|
||||||
|
loop/merge → loop/merge-in-process → loop/merge-done
|
||||||
|
↘ NeedSupervisor (on failure)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
**Rules:**
|
||||||
|
|
||||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
- 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.
|
- 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.
|
- 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.
|
- `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
|
1. Create issue
|
||||||
2. Add label loop/plan → agent writes plan as comment
|
2. Add label loop/plan → agent writes plan as comment
|
||||||
3. Review plan, request changes or approve
|
3. Review plan, request changes or approve
|
||||||
4. Add label loop/code → agent implements + opens PR
|
4. Add label loop/code → agent implements + opens PR + hands off to merge
|
||||||
5. Review PR, merge
|
5. (Optional) Review PR before it merges
|
||||||
6. Close issue
|
6. Merge agent waits for CI and merges the PR automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code conventions
|
## Code conventions
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
|
|||||||
# Replace 1003 with the actual UID of dagger-svc
|
# Replace 1003 with the actual UID of dagger-svc
|
||||||
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
||||||
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
Environment=XDG_RUNTIME_DIR=/run/user/1003
|
||||||
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
|
ExecStart=/usr/bin/nix run github:dagger/nix/v0.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
|
|||||||
## Daily Workflow
|
## Daily Workflow
|
||||||
|
|
||||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||||
|
|
||||||
|
<!-- agentloop code test passed -->
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
## Root cause analysis
|
||||||
|
|
||||||
|
The "Load remote images" button is rendered in two places: `lib/ui/screens/email_detail_screen.dart:228-262` (single mail view) and `lib/ui/screens/thread_detail_screen.dart:203-237` (thread view). Both call the same pattern:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _loadRemoteImages = true); // 1. schedule rebuild
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(...addTrustedImageSender(senderEmail)); // 2. fire-and-forget DB write
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(SnackBar( // 3. queue snack bar
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
...
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Although `duration: 3s` is already set, the snack bar fails to auto-dismiss. This mirrors the bug fixed in PR #401 (issue #399): there, a snack bar fired during a navigation transition and the duration timer "didn't start correctly" because the snack bar was queued on an unstable scaffold.
|
||||||
|
|
||||||
|
Here, the analogous instability comes from three rebuilds that all land between `showSnackBar` and the moment the SnackBar's enter-animation would normally complete and start its dismiss timer:
|
||||||
|
|
||||||
|
1. The synchronous `setState` flips `_loadRemoteImages` → `true`, which immediately removes the "Load remote images" button (the very widget whose `onPressed` was running) and swaps the `SecureEmailWebView` into the rebuilt subtree with `loadRemoteImages: true`. The WebView's `didUpdateWidget` then triggers an async `loadHtmlString` reload (see `lib/ui/widgets/secure_email_webview.dart:100-106`), which subsequently calls `setState(() => _height = h)` inside `_measureHeight`.
|
||||||
|
2. The fire-and-forget `addTrustedImageSender` write resolves a moment later, the `trustedImageSendersProvider` stream emits, and `ref.watch(trustedImageSendersProvider)` in `email_detail_screen.dart:197` causes another rebuild of the whole screen body — including the `Scaffold`'s body subtree that hosts the snack bar overlay's host context.
|
||||||
|
3. These rebuilds happen during the SnackBar's enter animation, so the `_SnackBarState` ends up holding stale animation state and the per-snack-bar timer that schedules `hideCurrentSnackBar` after `duration` never fires.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Queue the snack bar **before** mutating state, so it reaches `ScaffoldMessenger` while the Scaffold subtree is still stable, and defer the state change to a post-frame callback so the snack bar's enter-animation can finish before the WebView reload and the provider-driven rebuild run.
|
||||||
|
|
||||||
|
In `lib/ui/screens/email_detail_screen.dart`, replace the body of `OutlinedButton.icon.onPressed` at lines 231-261 with:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
onPressed: () {
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(senderEmail),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
content: const Text(
|
||||||
|
'Images will be loaded automatically for this sender.',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View',
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(
|
||||||
|
context.push(
|
||||||
|
'/accounts/trusted-senders',
|
||||||
|
extra: senderEmail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() => _loadRemoteImages = true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same reordering to `lib/ui/screens/thread_detail_screen.dart:206-236`.
|
||||||
|
|
||||||
|
The key changes:
|
||||||
|
- `showSnackBar` runs first, on the still-stable scaffold subtree.
|
||||||
|
- `setState` (which triggers WebView swap-in and subsequent rebuilds) is deferred to a post-frame callback.
|
||||||
|
- When `senderEmail == null` (no trusted-sender to register, so no snack bar), the post-frame callback still flips `_loadRemoteImages` to true — preserving existing behavior of the button working even for unknown senders.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Add a widget test in `test/widget/email_detail_screen_test.dart` that:
|
||||||
|
|
||||||
|
1. Pumps an `EmailDetailScreen` with an HTML body and a non-empty `From` header.
|
||||||
|
2. Taps the "Load remote images" button.
|
||||||
|
3. Verifies the snack bar with text "Images will be loaded automatically for this sender." appears.
|
||||||
|
4. Calls `tester.pump(const Duration(seconds: 4))` (or uses `tester.pumpAndSettle` after a 3.5s pump).
|
||||||
|
5. Verifies the snack bar is gone (`expect(find.byType(SnackBar), findsNothing)`).
|
||||||
|
6. Verifies `_loadRemoteImages` did flip, by checking that the "Load remote images" button is no longer present.
|
||||||
|
|
||||||
|
Add an analogous test in `test/widget/thread_detail_screen_test.dart` (or wherever thread tests live; create the file if it does not exist yet — use the email_detail test as a template).
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- The "First update agent loop, fix search bug" line in the issue body is two unrelated todo notes the reporter jotted down (the search bug is tracked separately). This plan does not address them.
|
||||||
|
- Other `showSnackBar` call sites in `email_detail_screen.dart` (download success/failure, copy-to-clipboard, raw-email errors, etc.) are not affected by the same rebuild pattern and stay unchanged.
|
||||||
|
|
||||||
|
### Verification checklist
|
||||||
|
|
||||||
|
- [ ] `dart test` (or the project's `task test` equivalent) passes, including the two new widget tests.
|
||||||
|
- [ ] Manual: open a single mail in `EmailDetailScreen` with HTML body from a sender not yet trusted; tap "Load remote images"; verify snack bar appears, images load, and snack bar disappears after ~3 seconds.
|
||||||
|
- [ ] Manual: tap "View" on the snack bar before it dismisses; verify it navigates to `/accounts/trusted-senders` and that the snack bar is dismissed by the navigation as expected.
|
||||||
|
- [ ] Manual: repeat in `ThreadDetailScreen`.
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# Implementation Plan: Secure WebView for HTML Emails (#21)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Replace the current `flutter_html` based rendering with a hardened WebView-based approach to improve rendering fidelity while strictly enforcing security and privacy.
|
|
||||||
|
|
||||||
## 1. Dependency Management
|
|
||||||
- **Core**: `webview_flutter` (v4+)
|
|
||||||
- **Linux Platform**: `webview_flutter_linux` (Official community-supported or WebKitGTK based implementation). *Note: I will verify the exact package name during implementation.*
|
|
||||||
- **Utilities**: `url_launcher` (existing) for opening links in the system browser.
|
|
||||||
|
|
||||||
## 2. Secure WebView Component (`lib/ui/widgets/secure_email_webview.dart`)
|
|
||||||
Create a new widget `SecureEmailWebView` that encapsulates the `WebViewWidget` and its controller.
|
|
||||||
|
|
||||||
### Configuration & Hardening
|
|
||||||
- **Disable JavaScript**: `controller.setJavaScriptMode(JavaScriptMode.disabled)`.
|
|
||||||
- **Background**: Match the application theme (e.g., transparent or surface color).
|
|
||||||
- **Security Headers/CSP**: Inject a Content Security Policy via `<meta>` tag in the HTML wrapper:
|
|
||||||
- `default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:;` (Blocks all external assets by default).
|
|
||||||
|
|
||||||
### Image Blocking Logic
|
|
||||||
- **Initial State**: Block remote images by injecting a CSP that restricts `img-src` to `data:` and local schemes.
|
|
||||||
- **Toggle Mechanism**:
|
|
||||||
- Provide a "Load Remote Images" button in the Flutter UI.
|
|
||||||
- When triggered, re-render the HTML with an updated CSP: `img-src * data:;`.
|
|
||||||
|
|
||||||
### Link Interception & Phishing Protection
|
|
||||||
- Implement `NavigationDelegate.onNavigationRequest`.
|
|
||||||
- **Process**:
|
|
||||||
1. Intercept any URL that doesn't start with `about:blank` or `data:`.
|
|
||||||
2. Block the navigation in the WebView.
|
|
||||||
3. Trigger a Flutter `showDialog` for confirmation.
|
|
||||||
- **Phishing Protection Dialog**:
|
|
||||||
- Show the full URL.
|
|
||||||
- **Bold the FQDN**: Parse the URL using `Uri.parse`.
|
|
||||||
- Example: `https://`**`important-bank.com`**`/login`
|
|
||||||
- "Open in Browser" button uses `url_launcher`.
|
|
||||||
|
|
||||||
## 3. Integration Plan
|
|
||||||
### Step 1: Initialization
|
|
||||||
Modify `lib/main.dart` to initialize the Linux WebView platform (using `webview_flutter_linux` or similar) during app startup.
|
|
||||||
|
|
||||||
### Step 2: Replace Renderer in Screens
|
|
||||||
- **EmailDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
|
||||||
- **ThreadDetailScreen**: Replace `Html(...)` with `SecureEmailWebView(html: body.htmlBody!)`.
|
|
||||||
- Remove `flutter_html` imports and dependencies once migration is complete.
|
|
||||||
|
|
||||||
## 4. Verification & Security Audit
|
|
||||||
- **Manual Tests**:
|
|
||||||
- Open emails with complex HTML layouts.
|
|
||||||
- Verify images are blocked initially.
|
|
||||||
- Verify "Load images" works.
|
|
||||||
- Click various links (http, https, mailto) and verify the confirmation dialog and FQDN bolding.
|
|
||||||
- **Security Check**:
|
|
||||||
- Verify that `<script>` tags are not executed.
|
|
||||||
- Verify no network requests for external images occur before user consent (via DevTools or proxy).
|
|
||||||
|
|
||||||
## 5. Potential Challenges
|
|
||||||
- **Linux WebView Stability**: WebKitGTK on Linux can sometimes have rendering or sizing issues in Flutter.
|
|
||||||
- **Scrolling**: Ensuring the WebView integrates smoothly into the `ListView` of the email detail screen (might require fixed height or `SizedBox`).
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Snooze Feature Plan
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
|
|
||||||
|
|
||||||
## Technical Approach
|
|
||||||
|
|
||||||
### 1. Metadata Storage (Account Sync)
|
|
||||||
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
|
|
||||||
- **JMAP:** Use `keywords`.
|
|
||||||
- **IMAP:** Use User Flags (keywords).
|
|
||||||
|
|
||||||
### 2. Database Changes
|
|
||||||
- **Migration v22:**
|
|
||||||
- `Emails` table:
|
|
||||||
- `snoozedUntil` (DateTime, nullable)
|
|
||||||
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
|
|
||||||
- Index on `snoozedUntil`.
|
|
||||||
|
|
||||||
### 3. Repository Updates (`EmailRepository`)
|
|
||||||
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
|
|
||||||
- Optimistically update local DB.
|
|
||||||
- Enqueue `snooze` change.
|
|
||||||
- New method: `Future<int> wakeUpEmails(String accountId)`
|
|
||||||
- Find local rows where `snoozedUntil <= now`.
|
|
||||||
- Enqueue `move` back to original mailbox.
|
|
||||||
- Clear snooze metadata.
|
|
||||||
|
|
||||||
### 4. Sync Loop Integration
|
|
||||||
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
|
|
||||||
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
|
|
||||||
|
|
||||||
### 5. UI Implementation
|
|
||||||
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
|
|
||||||
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
|
|
||||||
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
1. [ ] Database migration and model updates.
|
|
||||||
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
|
|
||||||
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
|
|
||||||
4. [ ] Update sync logic to parse snooze keywords.
|
|
||||||
5. [ ] Integrate `wakeUpEmails` into the sync loop.
|
|
||||||
6. [ ] UI: Snooze picker dialog.
|
|
||||||
7. [ ] UI: Add Snooze action to list and detail screens.
|
|
||||||
8. [ ] Testing and validation.
|
|
||||||
@@ -37,6 +37,8 @@ tasks:
|
|||||||
run: once
|
run: once
|
||||||
deps: [_nix-check]
|
deps: [_nix-check]
|
||||||
preconditions:
|
preconditions:
|
||||||
|
- sh: '[ "$(id -u)" != "0" ]'
|
||||||
|
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
|
||||||
- sh: test -n "${IN_NIX_SHELL}"
|
- sh: test -n "${IN_NIX_SHELL}"
|
||||||
msg: "Not in nix dev shell. Run: nix develop"
|
msg: "Not in nix dev shell. Run: nix develop"
|
||||||
cmds:
|
cmds:
|
||||||
@@ -56,6 +58,14 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- echo "Setup complete."
|
- 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:
|
generate-changelog:
|
||||||
desc: Generate assets/changelog.txt from git history
|
desc: Generate assets/changelog.txt from git history
|
||||||
cmds:
|
cmds:
|
||||||
@@ -96,34 +106,19 @@ tasks:
|
|||||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
codegen:
|
codegen:
|
||||||
desc: Generate Drift DB code (run after any schema change)
|
desc: Generate Drift DB code via Dagger (exports generated files back to host)
|
||||||
deps: [_preflight, _pub-get]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- pubspec.yaml
|
|
||||||
generates:
|
|
||||||
- lib/**/*.g.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
desc: Static analysis (flutter analyze)
|
desc: Static analysis via Dagger (dart analyze --fatal-infos)
|
||||||
deps: [_preflight, _codegen]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/**/*.dart
|
|
||||||
- pubspec.yaml
|
|
||||||
- analysis_options.yaml
|
|
||||||
cmds:
|
cmds:
|
||||||
- scripts/run_analyze.sh
|
- dagger call --progress=plain -q -m ci --source=. analyze
|
||||||
|
|
||||||
format:
|
format:
|
||||||
desc: Format all Dart source files
|
desc: Format all Dart source files via Dagger (writes back to host)
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- "**/*.dart"
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart format lib test
|
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
||||||
|
|
||||||
check-mocks:
|
check-mocks:
|
||||||
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
desc: Fail if any *.mocks.dart file is out of date (re-runs build_runner)
|
||||||
@@ -136,13 +131,9 @@ tasks:
|
|||||||
- scripts/check_mocks_fresh.sh
|
- scripts/check_mocks_fresh.sh
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues with dart fix --apply
|
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
|
||||||
deps: [_preflight]
|
|
||||||
sources:
|
|
||||||
- lib/**/*.dart
|
|
||||||
- test/**/*.dart
|
|
||||||
cmds:
|
cmds:
|
||||||
- fvm dart fix --apply
|
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||||
@@ -177,17 +168,17 @@ tasks:
|
|||||||
test-backend:
|
test-backend:
|
||||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-backend
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
|
||||||
|
|
||||||
integration-ui:
|
integration-ui:
|
||||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-integration
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
|
||||||
|
|
||||||
sync-reliability:
|
sync-reliability:
|
||||||
desc: Run sync reliability runner (via Dagger)
|
desc: Run sync reliability runner (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||||
|
|
||||||
test-android-firebase:
|
test-android-firebase:
|
||||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||||
@@ -202,7 +193,7 @@ tasks:
|
|||||||
ci-graph:
|
ci-graph:
|
||||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. graph
|
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
|
||||||
|
|
||||||
stalwart:
|
stalwart:
|
||||||
desc: Start a Stalwart instance for local development (via Dagger)
|
desc: Start a Stalwart instance for local development (via Dagger)
|
||||||
@@ -218,13 +209,13 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
build-android-bundle:
|
build-android-bundle:
|
||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p build/app/outputs/bundle/release
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -234,7 +225,7 @@ tasks:
|
|||||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||||
msg: "AAB not found — run build-android-bundle first"
|
msg: "AAB not found — run build-android-bundle first"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||||
|
|
||||||
publish-android:
|
publish-android:
|
||||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||||
@@ -247,7 +238,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
@@ -261,7 +252,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
- HASH=$(git rev-parse --short HEAD) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
||||||
|
|
||||||
publish-website:
|
publish-website:
|
||||||
desc: Build and publish website via Dagger
|
desc: Build and publish website via Dagger
|
||||||
@@ -271,7 +262,7 @@ tasks:
|
|||||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
msg: "SSH_KNOWN_HOSTS is not set"
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
- HASH=$(git rev-parse --short HEAD) && timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. publish-website --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||||
|
|
||||||
check-dagger:
|
check-dagger:
|
||||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||||
@@ -294,11 +285,11 @@ tasks:
|
|||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
run_dagger "$@" && return 0
|
run_dagger "$@" && return 0
|
||||||
RC=$?
|
RC=$?
|
||||||
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
||||||
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
|
||||||
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
|
||||||
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
|
||||||
dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
||||||
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
echo "$(_ts) dagger: waiting 90s for freed space to settle..." >&2
|
||||||
sleep 90
|
sleep 90
|
||||||
else
|
else
|
||||||
@@ -319,7 +310,16 @@ tasks:
|
|||||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
until [ -s "$PORTFILE" ]; do
|
||||||
|
sleep 0.05
|
||||||
|
if ! kill -0 "$RECV_PID" 2>/dev/null; then
|
||||||
|
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
|
||||||
|
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||||
|
RC=$?
|
||||||
|
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||||
|
exit $RC
|
||||||
|
fi
|
||||||
|
done
|
||||||
PORT=$(cat "$PORTFILE")
|
PORT=$(cat "$PORTFILE")
|
||||||
retry_dagger env \
|
retry_dagger env \
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||||
@@ -342,7 +342,7 @@ tasks:
|
|||||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||||
|
|
||||||
integration-android:
|
integration-android:
|
||||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||||
@@ -417,6 +417,22 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
echo "Uploaded $TARBALL and updated latest.json"
|
echo "Uploaded $TARBALL and updated latest.json"
|
||||||
|
|
||||||
|
deploy-bugreport:
|
||||||
|
desc: Deploy the Go bugreport server by restarting the systemd service (it pulls latest code from Codeberg)
|
||||||
|
preconditions:
|
||||||
|
- sh: test -n "$SSH_USER"
|
||||||
|
msg: "SSH_USER is not set"
|
||||||
|
- sh: test -n "$SSH_HOST"
|
||||||
|
msg: "SSH_HOST is not set"
|
||||||
|
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||||
|
msg: "SSH_KNOWN_HOSTS is not set"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
|
||||||
|
ssh "root@$SSH_HOST" "systemctl restart bugreport"
|
||||||
|
echo "Restarted bugreport service on $SSH_HOST to pull latest code from Codeberg"
|
||||||
|
|
||||||
build-windows-release:
|
build-windows-release:
|
||||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||||
deps: [_pub-get, generate-changelog]
|
deps: [_pub-get, generate-changelog]
|
||||||
@@ -513,18 +529,10 @@ tasks:
|
|||||||
cmds:
|
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"
|
- 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]
|
|
||||||
preconditions:
|
|
||||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
|
||||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
|
||||||
cmds:
|
|
||||||
- python3 scripts/deploy_playstore.py
|
|
||||||
|
|
||||||
build-android-bundle-local:
|
build-android-bundle-local:
|
||||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||||
|
dotenv: [".env"]
|
||||||
method: timestamp
|
method: timestamp
|
||||||
sources:
|
sources:
|
||||||
- lib/**/*.dart
|
- lib/**/*.dart
|
||||||
@@ -533,7 +541,14 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/bundle/release/app-release.aab
|
- build/app/outputs/bundle/release/app-release.aab
|
||||||
cmds:
|
cmds:
|
||||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
- 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:
|
deploy-android:
|
||||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||||
@@ -560,7 +575,7 @@ tasks:
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
desc: Run the app on Linux desktop
|
desc: Run the app on Linux desktop
|
||||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter run -d linux --no-pub
|
- fvm flutter run -d linux --no-pub
|
||||||
|
|
||||||
@@ -663,8 +678,9 @@ tasks:
|
|||||||
${SSH_USER}@${SSH_HOST}:public_html/
|
${SSH_USER}@${SSH_HOST}:public_html/
|
||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
||||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
cmds:
|
||||||
|
- dagger call --progress=plain -q -m ci --source=. check-fast
|
||||||
|
|
||||||
check-layers:
|
check-layers:
|
||||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||||
@@ -691,6 +707,16 @@ tasks:
|
|||||||
fi
|
fi
|
||||||
echo "Hygiene check passed."
|
echo "Hygiene check passed."
|
||||||
|
|
||||||
|
check-ci-images:
|
||||||
|
desc: Verify that all container images referenced in ci/main.go are reachable
|
||||||
|
cmds:
|
||||||
|
- scripts/check_ci_images.sh
|
||||||
|
|
||||||
|
check-dagger-versions:
|
||||||
|
desc: Verify ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md pin the same Dagger version
|
||||||
|
cmds:
|
||||||
|
- scripts/check_dagger_versions.sh
|
||||||
|
|
||||||
_integrations:
|
_integrations:
|
||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
@@ -703,6 +729,17 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
- scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}"
|
||||||
|
|
||||||
|
screenshots:
|
||||||
|
desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes)
|
||||||
|
deps: [_preflight, _codegen]
|
||||||
|
cmds:
|
||||||
|
- fvm flutter test test/screenshot_automation_test.dart --update-goldens
|
||||||
|
|
||||||
|
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:
|
check:
|
||||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||||
deps: [analyze, build-linux, test]
|
deps: [analyze, build-linux, test]
|
||||||
|
|||||||
@@ -16,19 +16,23 @@ android {
|
|||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlin {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||||
create("release") {
|
|
||||||
// Hardcoded alias matching t.sh
|
if (ksPath != null) {
|
||||||
keyAlias = "upload"
|
signingConfigs {
|
||||||
// Use the same password for both key and keystore
|
create("release") {
|
||||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
keyAlias = "upload"
|
||||||
storePassword = pass
|
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
||||||
keyPassword = pass
|
storePassword = pass
|
||||||
storeFile = file("upload-keystore.jks")
|
keyPassword = pass
|
||||||
|
storeFile = file(ksPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,14 +48,9 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Use the signing config defined above for release builds.
|
if (ksPath != null) {
|
||||||
// If the keystore file exists (e.g. in CI or manually placed), sign it.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
|
|
||||||
signingConfigs.getByName("release")
|
|
||||||
} else {
|
|
||||||
signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
|
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 |
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.11.1" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -2,52 +2,4 @@ module dagger/ci
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require golang.org/x/sync v0.20.0
|
||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72
|
|
||||||
github.com/Khan/genqlient v0.8.1
|
|
||||||
github.com/dagger/otel-go v1.43.0
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33
|
|
||||||
go.opentelemetry.io/otel v1.43.0
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/99designs/gqlgen v0.17.90 // indirect
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
|
||||||
github.com/sosodev/duration v1.4.0 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/log v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.17.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
|
||||||
golang.org/x/text v0.35.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
|
|
||||||
|
|
||||||
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
|
|
||||||
|
|||||||
@@ -1,97 +1,2 @@
|
|||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72 h1:s39e07WvaUU6tLhpojK8ZEIoIbOSn5hHOJra0waenxQ=
|
|
||||||
dagger.io/dagger v0.20.6-0.20260415192040-7058e9313c72/go.mod h1:ZXg8+pQZaZUC8rAw4V/gPP8aKvKARIJZ+pfcV+RC1es=
|
|
||||||
github.com/99designs/gqlgen v0.17.90 h1:wSv6blm/PoplU6QoNw83EcQpNtC0HX3/+44vITJOzpk=
|
|
||||||
github.com/99designs/gqlgen v0.17.90/go.mod h1:GqYrEwYsqCG8VaOsq2kJUCUKwAE1T+u2i+Nj7NtXiVI=
|
|
||||||
github.com/Khan/genqlient v0.8.1 h1:wtOCc8N9rNynRLXN3k3CnfzheCUNKBcvXmVv5zt6WCs=
|
|
||||||
github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU=
|
|
||||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
|
||||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
|
||||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/dagger/otel-go v1.43.0 h1:AYCnAamWmxtSxigWPTgC+8EWqiWPcDZEegh8y05gdJ8=
|
|
||||||
github.com/dagger/otel-go v1.43.0/go.mod h1:83CTuXi70zcx1kaym5buqmb7RNzg1E9dEiQSFyLbLdU=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
|
||||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
|
||||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+TcvI/7E=
|
|
||||||
github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0 h1:VO3BL6OZXRQ1yQc8W6EVfJzINeJ35BkiHx4MYfoQf44=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.41.0/go.mod h1:qRDnJ2nv3CQXMK2HUd9K9VtvedsPAce3S+/4LZHjX/s=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0 h1:MMrOAN8H1FrvDyq9UJ4lu5/+ss49Qgfgb7Zpm0m8ABo=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.41.0/go.mod h1:Na+2NNASJtF+uT4NxDe0G+NQb+bUgdPDfwxY/6JmS/c=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
|
|
||||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
|
||||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
|
||||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
|
||||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"dagger/ci/internal/dagger"
|
"dagger/ci/internal/dagger"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -148,16 +149,33 @@ if __name__ == "__main__":
|
|||||||
`
|
`
|
||||||
|
|
||||||
type Ci struct {
|
type Ci struct {
|
||||||
Source *dagger.Directory
|
Source *dagger.Directory
|
||||||
|
FlutterVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
|
ctx context.Context,
|
||||||
// +defaultPath=".."
|
// +defaultPath=".."
|
||||||
source *dagger.Directory,
|
source *dagger.Directory,
|
||||||
) *Ci {
|
) (*Ci, error) {
|
||||||
|
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
|
||||||
|
}
|
||||||
|
var fvmrc struct {
|
||||||
|
Flutter string `json:"flutter"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
|
||||||
|
}
|
||||||
|
if fvmrc.Flutter == "" {
|
||||||
|
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
|
||||||
|
}
|
||||||
return &Ci{
|
return &Ci{
|
||||||
|
FlutterVersion: fvmrc.Flutter,
|
||||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||||
Include: []string{
|
Include: []string{
|
||||||
|
".fvmrc",
|
||||||
"lib/",
|
"lib/",
|
||||||
"test/",
|
"test/",
|
||||||
"assets/",
|
"assets/",
|
||||||
@@ -173,7 +191,7 @@ func New(
|
|||||||
"website/",
|
"website/",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
// toolchain returns the Flutter+Android toolchain without any mutable cache mounts.
|
||||||
@@ -181,7 +199,7 @@ func New(
|
|||||||
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
// Used as the base for pubGetLayer so flutter pub get is execution-cached between runs.
|
||||||
func (m *Ci) toolchain() *dagger.Container {
|
func (m *Ci) toolchain() *dagger.Container {
|
||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
From("ghcr.io/cirruslabs/flutter:"+m.FlutterVersion).
|
||||||
WithExec([]string{"apt-get", "-qq", "update"}).
|
WithExec([]string{"apt-get", "-qq", "update"}).
|
||||||
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
WithExec([]string{"apt-get", "install", "-y", "-qq", "clang", "cmake", "ninja-build", "pkg-config", "libgtk-3-dev", "liblzma-dev", "libsecret-1-dev", "libgcrypt20-dev", "libjsoncpp-dev", "sqlite3", "iproute2", "netcat-openbsd", "xvfb", "libosmesa6", "libegl1", "lld"}).
|
||||||
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
WithExec([]string{"useradd", "-m", "-s", "/bin/bash", "ci"}).
|
||||||
@@ -338,7 +356,17 @@ func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.
|
|||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("alpine:3.21").
|
From("alpine:3.21").
|
||||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
// Create .ssh with strict permissions before Dagger mounts anything there,
|
||||||
|
// so the directory is 700 (not Dagger's default 755).
|
||||||
|
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
|
||||||
|
// Mount the raw key outside .ssh so Dagger cannot override the directory
|
||||||
|
// permissions we just set above.
|
||||||
|
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
|
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
|
||||||
|
// Using Python3 (not tr) changes the Dagger cache key so stale cached
|
||||||
|
// results from the old tr-based step are not reused.
|
||||||
|
WithExec([]string{"python3", "-c",
|
||||||
|
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
|
||||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||||
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||||
}
|
}
|
||||||
@@ -360,7 +388,7 @@ func (m *Ci) Stalwart() *dagger.Service {
|
|||||||
return dag.Container().
|
return dag.Container().
|
||||||
From("stalwartlabs/stalwart:v0.14.1").
|
From("stalwartlabs/stalwart:v0.14.1").
|
||||||
WithFile("/etc/stalwart/config.toml.orig", config).
|
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).
|
WithDirectory("/tmp/stalwart", dataDir).
|
||||||
WithExposedPort(8080). // JMAP
|
WithExposedPort(8080). // JMAP
|
||||||
WithExposedPort(1430). // IMAP
|
WithExposedPort(1430). // IMAP
|
||||||
@@ -412,33 +440,91 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMocks verifies that generated mocks are up to date.
|
// FormatWrite formats Dart files and exports the modified /src directory.
|
||||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
func (m *Ci) FormatWrite() *dagger.Directory {
|
||||||
// running build_runner, so git diff detects real staleness instead of always
|
return m.setup(m.checkSrc()).
|
||||||
// comparing two freshly-generated outputs.
|
WithExec([]string{"dart", "format", "lib", "test"}).
|
||||||
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
Directory("/src")
|
||||||
return m.pubGetLayer().
|
}
|
||||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
|
||||||
WithWorkdir("/src").
|
// Analyze runs static analysis with dart analyze --fatal-infos.
|
||||||
WithExec([]string{"git", "init"}).
|
func (m *Ci) Analyze(ctx context.Context) (string, error) {
|
||||||
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
|
return m.setup(m.checkSrc()).
|
||||||
WithExec([]string{"git", "config", "user.name", "CI"}).
|
WithExec([]string{"dart", "analyze", "--fatal-infos"}).
|
||||||
WithExec([]string{"git", "add", "."}).
|
|
||||||
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
|
|
||||||
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 '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage runs unit tests with coverage gate.
|
// Codegen runs build_runner and exports the modified /src directory.
|
||||||
|
func (m *Ci) Codegen() *dagger.Directory {
|
||||||
|
return m.codegenBase().Directory("/src")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
|
||||||
|
func (m *Ci) AnalyzeFix() *dagger.Directory {
|
||||||
|
return m.setup(m.checkSrc()).
|
||||||
|
WithExec([]string{"dart", "fix", "--apply"}).
|
||||||
|
Directory("/src")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
|
||||||
|
func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var eg errgroup.Group
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.CheckHygiene(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.CheckLayers(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.Format(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.Analyze(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.CheckGenerated(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := m.Coverage(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := eg.Wait(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "All fast checks passed!", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
||||||
|
// It 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("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
|
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage runs unit and widget tests with coverage gate.
|
||||||
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
func (m *Ci) Coverage(ctx context.Context) (string, error) {
|
||||||
return m.setup(m.checkSrc()).
|
return m.setup(m.checkSrc()).
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`flutter test test/unit --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter test test/unit test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
@@ -449,7 +535,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
|
|||||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
`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"`}).
|
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
@@ -475,49 +561,77 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
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.
|
// Check runs the full check suite.
|
||||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
// Run cheap structural checks in parallel for faster fail detection.
|
||||||
return "Hygiene check failed", err
|
var fastEg errgroup.Group
|
||||||
}
|
fastEg.Go(func() error {
|
||||||
if _, err := m.CheckLayers(ctx); err != nil {
|
_, err := m.CheckHygiene(ctx)
|
||||||
return "Layer check failed", err
|
return err
|
||||||
|
})
|
||||||
|
fastEg.Go(func() error {
|
||||||
|
_, err := m.CheckLayers(ctx)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := fastEg.Wait(); err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
checkSetup := m.setup(m.checkSrc())
|
// Run format, analyze, generated-code check, and coverage in parallel —
|
||||||
|
// they all share the same setup base and have no dependencies on each other.
|
||||||
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
var analyze, mocks, coverage string
|
||||||
return "Format check failed", err
|
var checkEg errgroup.Group
|
||||||
}
|
checkEg.Go(func() error {
|
||||||
|
setup := m.setup(m.checkSrc())
|
||||||
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
_, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx)
|
||||||
if err != nil {
|
return err
|
||||||
return analyze, err
|
})
|
||||||
}
|
checkEg.Go(func() error {
|
||||||
|
setup := m.setup(m.checkSrc())
|
||||||
mocks, err := m.CheckMocks(ctx)
|
var err error
|
||||||
if err != nil {
|
analyze, err = setup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||||
return mocks, err
|
return err
|
||||||
}
|
})
|
||||||
|
checkEg.Go(func() error {
|
||||||
coverage, err := m.Coverage(ctx)
|
var err error
|
||||||
if err != nil {
|
mocks, err = m.CheckGenerated(ctx)
|
||||||
return coverage, err
|
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
|
||||||
|
// sibling via context — which would surface as "context canceled" in dagger
|
||||||
|
// output and trigger spurious retries in check-dagger.
|
||||||
var testBackend, testIntegration string
|
var testBackend, testIntegration string
|
||||||
eg, egCtx := errgroup.WithContext(ctx)
|
var eg errgroup.Group
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testBackend, e = m.TestBackend(egCtx)
|
testBackend, e = m.TestBackend(ctx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testIntegration, e = m.TestIntegration(egCtx)
|
testIntegration, e = m.TestIntegration(ctx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
if err := eg.Wait(); err != nil {
|
if err := eg.Wait(); err != nil {
|
||||||
@@ -559,6 +673,8 @@ func (m *Ci) BuildWebsite(
|
|||||||
knownHosts *dagger.Secret,
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) *dagger.Directory {
|
) *dagger.Directory {
|
||||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||||
|
|
||||||
@@ -566,9 +682,13 @@ func (m *Ci) BuildWebsite(
|
|||||||
Include: []string{"website/"},
|
Include: []string{"website/"},
|
||||||
}).WithDirectory("website/content/builds", buildHistory)
|
}).WithDirectory("website/content/builds", buildHistory)
|
||||||
|
|
||||||
return m.Hugo().
|
hugo := m.Hugo().
|
||||||
WithDirectory("/src", websiteSource).
|
WithDirectory("/src", websiteSource).
|
||||||
WithWorkdir("/src/website").
|
WithWorkdir("/src/website")
|
||||||
|
if commitHash != "" {
|
||||||
|
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
|
||||||
|
}
|
||||||
|
return hugo.
|
||||||
WithExec([]string{"hugo", "--minify"}).
|
WithExec([]string{"hugo", "--minify"}).
|
||||||
Directory("public")
|
Directory("public")
|
||||||
}
|
}
|
||||||
@@ -580,8 +700,10 @@ func (m *Ci) PublishWebsite(
|
|||||||
knownHosts *dagger.Secret,
|
knownHosts *dagger.Secret,
|
||||||
sshUser string,
|
sshUser string,
|
||||||
sshHost string,
|
sshHost string,
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
|
||||||
|
|
||||||
return m.Deployer(sshKey, knownHosts).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithDirectory("/public", public).
|
WithDirectory("/public", public).
|
||||||
@@ -641,7 +763,8 @@ func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagg
|
|||||||
return m.androidBase().
|
return m.androidBase().
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
|
||||||
|
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
@@ -874,12 +997,12 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
|
|||||||
//
|
//
|
||||||
// dagger call --progress=plain -q -m ci --source=. graph
|
// dagger call --progress=plain -q -m ci --source=. graph
|
||||||
func (m *Ci) Graph() string {
|
func (m *Ci) Graph() string {
|
||||||
return `# CI Pipeline Graph
|
return fmt.Sprintf(`# CI Pipeline Graph
|
||||||
|
|
||||||
` + "```" + `mermaid
|
`+"```"+`mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph dagger ["Dagger · Check pipeline"]
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
|
||||||
pubGet["pubGetLayer\nflutter pub get"]
|
pubGet["pubGetLayer\nflutter pub get"]
|
||||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||||
@@ -889,7 +1012,7 @@ flowchart TD
|
|||||||
|
|
||||||
pubGet --> hygiene["CheckHygiene"]
|
pubGet --> hygiene["CheckHygiene"]
|
||||||
pubGet --> layers["CheckLayers"]
|
pubGet --> layers["CheckLayers"]
|
||||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
|
||||||
|
|
||||||
codegen --> fmt["Format"]
|
codegen --> fmt["Format"]
|
||||||
codegen --> analyze["Analyze"]
|
codegen --> analyze["Analyze"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
|
||||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
# Load .env into environment
|
# Load .env into environment
|
||||||
|
|||||||
@@ -48,11 +48,30 @@
|
|||||||
chmod +x $out/bin/fgj
|
chmod +x $out/bin/fgj
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The dagger/nix flake's Nix wrapper is a broken self-exec loop, so we
|
||||||
|
# fetch the CLI binary directly. Keep this version in lockstep with
|
||||||
|
# ci/dagger.json (engineVersion) and .forgejo/Dockerfile (DAGGER_VERSION) —
|
||||||
|
# scripts/check_dagger_versions.sh enforces this.
|
||||||
|
daggerCli = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "dagger";
|
||||||
|
version = "0.20.8";
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://dl.dagger.io/dagger/releases/0.20.8/dagger_v0.20.8_linux_amd64.tar.gz";
|
||||||
|
sha256 = "1ns6wq2z1skd2fq9lbrcali0s8kn24p3haamnjjgchg6zlv6b960";
|
||||||
|
};
|
||||||
|
sourceRoot = ".";
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/bin
|
||||||
|
cp dagger $out/bin/dagger
|
||||||
|
chmod +x $out/bin/dagger
|
||||||
|
'';
|
||||||
|
};
|
||||||
in {
|
in {
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
# Dagger CLI
|
# Dagger CLI
|
||||||
dagger.packages.${system}.dagger
|
daggerCli
|
||||||
|
|
||||||
# Go compiler — for Dagger development
|
# Go compiler — for Dagger development
|
||||||
go
|
go
|
||||||
@@ -99,12 +118,17 @@
|
|||||||
httplib2
|
httplib2
|
||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
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 = ''
|
shellHook = ''
|
||||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||||
export IN_NIX_SHELL=1
|
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
|
# Disable Flutter telemetry inside dev shell
|
||||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" shape-rendering="geometricPrecision">
|
||||||
|
<!-- White Background -->
|
||||||
|
<rect width="512" height="512" fill="white"/>
|
||||||
|
|
||||||
|
<!-- 6 Concentric Rainbow Rings (Tunnel Vision Geometry) -->
|
||||||
|
<g fill-rule="evenodd" stroke="black" stroke-width="2.5">
|
||||||
|
<!-- Red -->
|
||||||
|
<path fill="#FF0000" d="M256,256 m-242,0 a242,242 0 1,0 484,0 a242,242 0 1,0 -484,0 Z M256,256 m-190,0 a190,190 0 1,0 380,0 a190,190 0 1,0 -380,0 Z" />
|
||||||
|
|
||||||
|
<!-- Orange -->
|
||||||
|
<path fill="#FF8C00" d="M256,256 m-170,0 a170,170 0 1,0 340,0 a170,170 0 1,0 -340,0 Z M256,256 m-131,0 a131,131 0 1,0 262,0 a131,131 0 1,0 -262,0 Z" />
|
||||||
|
|
||||||
|
<!-- Yellow -->
|
||||||
|
<path fill="#FFD700" d="M256,256 m-115,0 a115,115 0 1,0 230,0 a115,115 0 1,0 -230,0 Z M256,256 m-85,0 a85,85 0 1,0 170,0 a85,85 0 1,0 -170,0 Z" />
|
||||||
|
|
||||||
|
<!-- Green -->
|
||||||
|
<path fill="#22AA00" d="M256,256 m-73,0 a73,73 0 1,0 146,0 a73,73 0 1,0 -146,0 Z M256,256 m-51,0 a51,51 0 1,0 102,0 a51,51 0 1,0 -102,0 Z" />
|
||||||
|
|
||||||
|
<!-- Blue -->
|
||||||
|
<path fill="#0055FF" d="M256,256 m-41,0 a41,41 0 1,0 82,0 a41,41 0 1,0 -82,0 Z M256,256 m-24,0 a24,24 0 1,0 48,0 a24,24 0 1,0 -48,0 Z" />
|
||||||
|
|
||||||
|
<!-- Purple -->
|
||||||
|
<path fill="#8B00FF" d="M256,256 m-16,0 a16,16 0 1,0 32,0 a16,16 0 1,0 -32,0 Z M256,256 m-3,0 a3,3 0 1,0 6,0 a3,3 0 1,0 -6,0 Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 36;
|
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;
|
||||||
|
}
|
||||||
@@ -192,6 +192,22 @@ class EmailThread {
|
|||||||
required this.accountId,
|
required this.accountId,
|
||||||
required this.mailboxPath,
|
required this.mailboxPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Wraps a single [Email] as a one-message thread for uniform rendering.
|
||||||
|
factory EmailThread.fromEmail(Email e) => EmailThread(
|
||||||
|
threadId: e.threadId ?? e.id,
|
||||||
|
subject: e.subject,
|
||||||
|
participants: e.from,
|
||||||
|
latestDate: e.sentAt ?? e.receivedAt,
|
||||||
|
messageCount: 1,
|
||||||
|
hasUnread: !e.isSeen,
|
||||||
|
isFlagged: e.isFlagged,
|
||||||
|
latestEmailId: e.id,
|
||||||
|
preview: e.preview,
|
||||||
|
emailIds: [e.id],
|
||||||
|
accountId: e.accountId,
|
||||||
|
mailboxPath: e.mailboxPath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class EmailAddress {
|
class EmailAddress {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class EmailNote {
|
||||||
|
final String id; // UUID (X-SharedInbox-Note-Id)
|
||||||
|
final String accountId;
|
||||||
|
final String messageId; // RFC 2822 Message-ID (X-SharedInbox-Note-For)
|
||||||
|
final String noteText;
|
||||||
|
final String serverId; // IMAP UID (as string) or JMAP email ID
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const EmailNote({
|
||||||
|
required this.id,
|
||||||
|
required this.accountId,
|
||||||
|
required this.messageId,
|
||||||
|
required this.noteText,
|
||||||
|
required this.serverId,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,13 +2,30 @@ enum MenuPosition { bottom, top }
|
|||||||
|
|
||||||
enum AfterMailViewAction { nextMessage, showMailbox }
|
enum AfterMailViewAction { nextMessage, showMailbox }
|
||||||
|
|
||||||
|
enum PrefetchMode {
|
||||||
|
disabled,
|
||||||
|
wifiOnly,
|
||||||
|
always;
|
||||||
|
|
||||||
|
static PrefetchMode fromString(String? value) {
|
||||||
|
return PrefetchMode.values.firstWhere(
|
||||||
|
(e) => e.name == value,
|
||||||
|
orElse: () => PrefetchMode.wifiOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class UserPreferences {
|
class UserPreferences {
|
||||||
const UserPreferences({
|
const UserPreferences({
|
||||||
this.menuPosition = MenuPosition.bottom,
|
this.menuPosition = MenuPosition.bottom,
|
||||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
this.mailViewButtonPosition = MenuPosition.bottom,
|
||||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
||||||
|
this.prefetchMode = PrefetchMode.wifiOnly,
|
||||||
|
this.bodyCacheLimitMb = 100,
|
||||||
});
|
});
|
||||||
final MenuPosition menuPosition;
|
final MenuPosition menuPosition;
|
||||||
final MenuPosition mailViewButtonPosition;
|
final MenuPosition mailViewButtonPosition;
|
||||||
final AfterMailViewAction afterMailViewAction;
|
final AfterMailViewAction afterMailViewAction;
|
||||||
|
final PrefetchMode prefetchMode;
|
||||||
|
final int bodyCacheLimitMb;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:sharedinbox/core/filter/filter_expression.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
abstract class EmailRepository {
|
abstract class EmailRepository {
|
||||||
@@ -15,6 +16,10 @@ abstract class EmailRepository {
|
|||||||
int limit = 50,
|
int limit = 50,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Returns threads from the INBOX mailbox of every account, sorted by latest
|
||||||
|
/// message date descending. Inbox mailboxes are identified by role = 'inbox'.
|
||||||
|
Stream<List<EmailThread>> observeAllInboxThreads({int limit = 50});
|
||||||
|
|
||||||
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
/// Returns all emails belonging to [threadId] in [mailboxPath].
|
||||||
Stream<List<Email>> observeEmailsInThread(
|
Stream<List<Email>> observeEmailsInThread(
|
||||||
String accountId,
|
String accountId,
|
||||||
@@ -54,9 +59,15 @@ abstract class EmailRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Searches the local DB across all mailboxes of [accountId] (or all accounts
|
/// 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);
|
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
|
/// Returns all locally cached emails in any mailbox of [accountId] (or all
|
||||||
/// accounts if null) whose from, to, or cc fields contain [address].
|
/// accounts if null) whose from, to, or cc fields contain [address].
|
||||||
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ abstract class MailboxRepository {
|
|||||||
String name,
|
String name,
|
||||||
String role,
|
String role,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Creates a new mailbox named [name] for [accountId] without a special role.
|
||||||
|
/// Returns the newly created [Mailbox].
|
||||||
|
Future<Mailbox> createMailbox(String accountId, String name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
|
||||||
|
abstract class NoteRepository {
|
||||||
|
/// Stream of notes for an email, keyed by [messageId] (stable across moves).
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Fetches notes from the server into the local cache.
|
||||||
|
Future<void> syncNotes(String accountId, String messageId);
|
||||||
|
|
||||||
|
/// Creates a new note on the server and caches it locally.
|
||||||
|
Future<void> addNote(String accountId, String messageId, String text);
|
||||||
|
|
||||||
|
/// Deletes a note from the server and removes it from the local cache.
|
||||||
|
Future<void> deleteNote(String noteId);
|
||||||
|
}
|
||||||
@@ -5,4 +5,10 @@ abstract class UserPreferencesRepository {
|
|||||||
Future<void> updateMenuPosition(MenuPosition position);
|
Future<void> updateMenuPosition(MenuPosition position);
|
||||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
||||||
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
|
||||||
|
Future<void> updatePrefetchMode(PrefetchMode mode);
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb);
|
||||||
|
|
||||||
|
Stream<List<String>> observeTrustedImageSenders();
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail);
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
|
|
||||||
|
/// Prefetches email bodies in the background and enforces a local cache size
|
||||||
|
/// limit by evicting the oldest cached bodies when the limit is exceeded.
|
||||||
|
class BodyCacheService {
|
||||||
|
BodyCacheService(this._db, this._accountRepo);
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accountRepo;
|
||||||
|
|
||||||
|
static const _batchSize = 20;
|
||||||
|
|
||||||
|
Future<void> run() async {
|
||||||
|
final prefs = await (_db.select(
|
||||||
|
_db.userPreferences,
|
||||||
|
)).getSingleOrNull();
|
||||||
|
final limitMb = prefs?.bodyCacheLimitMb ?? 100;
|
||||||
|
final limitBytes = limitMb * 1024 * 1024;
|
||||||
|
|
||||||
|
await _evictIfNeeded(limitBytes);
|
||||||
|
|
||||||
|
final candidates = await _fetchCandidates();
|
||||||
|
if (candidates.isEmpty) return;
|
||||||
|
|
||||||
|
final emailRepo = EmailRepositoryImpl(_db, _accountRepo);
|
||||||
|
|
||||||
|
for (final emailId in candidates) {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize >= limitBytes) break;
|
||||||
|
try {
|
||||||
|
await emailRepo.getEmailBody(emailId);
|
||||||
|
} catch (_) {
|
||||||
|
// Skip emails that fail to fetch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _evictIfNeeded(int limitBytes) async {
|
||||||
|
final currentSize = await _getCacheSizeBytes();
|
||||||
|
if (currentSize <= limitBytes) return;
|
||||||
|
|
||||||
|
final bodies = await (_db.select(_db.emailBodies)
|
||||||
|
..where((t) => t.cachedAt.isNotNull())
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)]))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
var remaining = currentSize;
|
||||||
|
for (final body in bodies) {
|
||||||
|
if (remaining <= limitBytes) break;
|
||||||
|
final bodySize =
|
||||||
|
(body.textBody?.length ?? 0) + (body.htmlBody?.length ?? 0);
|
||||||
|
await (_db.delete(_db.emailBodies)
|
||||||
|
..where((t) => t.emailId.equals(body.emailId)))
|
||||||
|
.go();
|
||||||
|
remaining -= bodySize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _getCacheSizeBytes() async {
|
||||||
|
final result = await _db
|
||||||
|
.customSelect(
|
||||||
|
"SELECT COALESCE(SUM(LENGTH(COALESCE(text_body, '')) + LENGTH(COALESCE(html_body, ''))), 0) AS total FROM email_bodies",
|
||||||
|
)
|
||||||
|
.getSingle();
|
||||||
|
return result.read<int>('total');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> _fetchCandidates() async {
|
||||||
|
final rows = await _db.customSelect(
|
||||||
|
'SELECT e.id FROM emails e '
|
||||||
|
'LEFT JOIN email_bodies eb ON eb.email_id = e.id '
|
||||||
|
'WHERE eb.email_id IS NULL '
|
||||||
|
'ORDER BY e.received_at DESC '
|
||||||
|
'LIMIT ?',
|
||||||
|
variables: [Variable.withInt(_batchSize)],
|
||||||
|
).get();
|
||||||
|
return rows.map((r) => r.read<String>('id')).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,8 +92,9 @@ class ShareEncryptionService {
|
|||||||
) {
|
) {
|
||||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||||
try {
|
try {
|
||||||
final data =
|
final data = Uint8List.fromList(
|
||||||
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
base64.decode(s.substring(_pubKeyPrefix.length)),
|
||||||
|
);
|
||||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||||
return (
|
return (
|
||||||
keyId: data.sublist(0, _keyIdLen),
|
keyId: data.sublist(0, _keyIdLen),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
|
||||||
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
import 'package:sharedinbox/core/sieve/sieve_conditions.dart';
|
||||||
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
import 'package:sharedinbox/core/sieve/sieve_rule.dart';
|
||||||
|
import 'package:sharedinbox/core/utils/glob_match.dart';
|
||||||
|
|
||||||
/// A lightweight email representation used by [SieveInterpreter].
|
/// A lightweight email representation used by [SieveInterpreter].
|
||||||
/// Header names are lower-cased.
|
/// Header names are lower-cased.
|
||||||
@@ -102,17 +103,11 @@ class SieveInterpreter {
|
|||||||
return switch (matchType) {
|
return switch (matchType) {
|
||||||
':contains' => k.isEmpty || v.contains(k),
|
':contains' => k.isEmpty || v.contains(k),
|
||||||
':is' => v == k,
|
':is' => v == k,
|
||||||
':matches' => _globMatch(v, k),
|
':matches' => globMatch(v, k),
|
||||||
_ => false,
|
_ => 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) {
|
void _applyActions(List<SieveAction> actions, SieveExecutionContext ctx) {
|
||||||
for (final action in actions) {
|
for (final action in actions) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
|||||||
@@ -466,9 +466,7 @@ class _Scanner {
|
|||||||
|
|
||||||
String readTaggedArg() {
|
String readTaggedArg() {
|
||||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
throw SieveParseException(
|
throw SieveParseException('Expected tagged argument at position $_pos');
|
||||||
'Expected tagged argument at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? peekSizeUnit() {
|
String? peekSizeUnit() {
|
||||||
@@ -480,9 +478,7 @@ class _Scanner {
|
|||||||
|
|
||||||
String readDigits() {
|
String readDigits() {
|
||||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||||
throw SieveParseException(
|
throw SieveParseException('Expected number at position $_pos');
|
||||||
'Expected number at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final start = _pos;
|
final start = _pos;
|
||||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||||
@@ -493,9 +489,7 @@ class _Scanner {
|
|||||||
|
|
||||||
String readQuotedString() {
|
String readQuotedString() {
|
||||||
if (_src[_pos] != '"') {
|
if (_src[_pos] != '"') {
|
||||||
throw SieveParseException(
|
throw SieveParseException('Expected " at position $_pos');
|
||||||
'Expected " at position $_pos',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
_pos++; // skip opening quote
|
_pos++; // skip opening quote
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
|
|||||||
@@ -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'\"');
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/services/body_cache_service.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
@@ -21,6 +23,7 @@ import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
|||||||
import 'package:workmanager/workmanager.dart';
|
import 'package:workmanager/workmanager.dart';
|
||||||
|
|
||||||
const _kTaskName = 'si_bg_sync';
|
const _kTaskName = 'si_bg_sync';
|
||||||
|
const _kPrefetchTaskName = 'si_bg_prefetch';
|
||||||
const _kResourceType = 'background_check';
|
const _kResourceType = 'background_check';
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
@@ -28,9 +31,13 @@ void callbackDispatcher() {
|
|||||||
// Required so that path_provider and other plugins are available in this
|
// Required so that path_provider and other plugins are available in this
|
||||||
// background isolate (issue #192).
|
// background isolate (issue #192).
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
Workmanager().executeTask((_, __) async {
|
Workmanager().executeTask((taskName, __) async {
|
||||||
try {
|
try {
|
||||||
await _doBackgroundSync();
|
if (taskName == _kPrefetchTaskName) {
|
||||||
|
await _doBodyPrefetch();
|
||||||
|
} else {
|
||||||
|
await _doBackgroundSync();
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -55,6 +62,31 @@ Future<void> registerBackgroundSync() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Registers (or cancels) the body-prefetch WorkManager task based on [mode].
|
||||||
|
/// Call on app startup and whenever the user changes the prefetch preference.
|
||||||
|
Future<void> registerBodyPrefetchTask(PrefetchMode mode) async {
|
||||||
|
try {
|
||||||
|
if (mode == PrefetchMode.disabled) {
|
||||||
|
await Workmanager().cancelByUniqueName(_kPrefetchTaskName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final networkType = mode == PrefetchMode.wifiOnly
|
||||||
|
? NetworkType.unmetered
|
||||||
|
: NetworkType.connected;
|
||||||
|
await Workmanager().registerPeriodicTask(
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
_kPrefetchTaskName,
|
||||||
|
frequency: const Duration(hours: 1),
|
||||||
|
constraints: Constraints(networkType: networkType),
|
||||||
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.replace,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// Ignore — WorkManager unavailable.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Ignore — plugin not registered.
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _doBackgroundSync() async {
|
Future<void> _doBackgroundSync() async {
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final db = AppDatabase(
|
final db = AppDatabase(
|
||||||
@@ -76,6 +108,22 @@ Future<void> _doBackgroundSync() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _doBodyPrefetch() async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
final db = AppDatabase(
|
||||||
|
NativeDatabase(File(p.join(dir.path, 'sharedinbox.db'))),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final accountRepo = AccountRepositoryImpl(
|
||||||
|
db,
|
||||||
|
const FlutterSecureStorageImpl(),
|
||||||
|
);
|
||||||
|
await BodyCacheService(db, accountRepo).run();
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _checkAccount(
|
Future<void> _checkAccount(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountRepository accountRepo,
|
AccountRepository accountRepo,
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ String injectInlineImages(String html, imap.MimeMessage msg) {
|
|||||||
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
.replaceAll('src="cid:$bareCid"', 'src="$dataUri"')
|
||||||
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
.replaceAll("src='cid:$bareCid'", "src='$dataUri'")
|
||||||
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
.replaceAll('src="cid:${bareCid.toLowerCase()}"', 'src="$dataUri"')
|
||||||
.replaceAll(
|
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
||||||
"src='cid:${bareCid.toLowerCase()}'",
|
|
||||||
"src='$dataUri'",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
|
import 'package:sqlite3/sqlite3.dart' show Database;
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -307,6 +308,48 @@ class LocalSieveApplied extends Table {
|
|||||||
Set<Column> get primaryKey => {accountId, messageId};
|
Set<Column> get primaryKey => {accountId, messageId};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Senders for whom remote images are loaded automatically.
|
||||||
|
/// Per-device/per-user — not tied to any email account.
|
||||||
|
@DataClassName('ImageTrustedSenderRow')
|
||||||
|
class ImageTrustedSenders extends Table {
|
||||||
|
TextColumn get senderEmail => text()();
|
||||||
|
DateTimeColumn get addedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {senderEmail};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-email notes stored server-side (IMAP Notes folder / JMAP Notes mailbox).
|
||||||
|
/// Keyed by the RFC 2822 Message-ID header so notes survive folder moves.
|
||||||
|
// Added in schema v39.
|
||||||
|
@DataClassName('EmailNoteRow')
|
||||||
|
class EmailNotes extends Table {
|
||||||
|
// UUID matching the X-SharedInbox-Note-Id custom header on the server.
|
||||||
|
TextColumn get id => text()();
|
||||||
|
TextColumn get accountId =>
|
||||||
|
text().references(Accounts, #id, onDelete: KeyAction.cascade)();
|
||||||
|
// X-SharedInbox-Note-For value — stable across IMAP folder moves.
|
||||||
|
TextColumn get messageId => text()();
|
||||||
|
TextColumn get noteText => text()();
|
||||||
|
// IMAP UID (as string) or JMAP email ID of the note message on the server.
|
||||||
|
TextColumn get serverId => text()();
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// App-wide user preferences, stored as a singleton row (id always 1).
|
||||||
@DataClassName('UserPreferencesRow')
|
@DataClassName('UserPreferencesRow')
|
||||||
class UserPreferences extends Table {
|
class UserPreferences extends Table {
|
||||||
@@ -319,6 +362,12 @@ class UserPreferences extends Table {
|
|||||||
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
||||||
TextColumn get afterMailViewAction =>
|
TextColumn get afterMailViewAction =>
|
||||||
text().withDefault(const Constant('nextMessage'))();
|
text().withDefault(const Constant('nextMessage'))();
|
||||||
|
// Added in schema v38: 'disabled' | 'wifiOnly' (default) | 'always'
|
||||||
|
TextColumn get prefetchMode =>
|
||||||
|
text().withDefault(const Constant('wifiOnly'))();
|
||||||
|
// Added in schema v38: max cache size for offline email bodies, in megabytes.
|
||||||
|
IntColumn get bodyCacheLimitMb =>
|
||||||
|
integer().withDefault(const Constant(100))();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
@@ -345,6 +394,9 @@ class UserPreferences extends Table {
|
|||||||
LocalSieveApplied,
|
LocalSieveApplied,
|
||||||
ShareKeys,
|
ShareKeys,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
|
ImageTrustedSenders,
|
||||||
|
EmailNotes,
|
||||||
|
InstalledVersions,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -611,8 +663,153 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
userPreferences.afterMailViewAction,
|
userPreferences.afterMailViewAction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (from < 37) {
|
||||||
|
await m.createTable(imageTrustedSenders);
|
||||||
|
}
|
||||||
|
if (from >= 34 && from < 38) {
|
||||||
|
await m.addColumn(userPreferences, userPreferences.prefetchMode);
|
||||||
|
await m.addColumn(
|
||||||
|
userPreferences,
|
||||||
|
userPreferences.bodyCacheLimitMb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (from < 39) {
|
||||||
|
await m.createTable(emailNotes);
|
||||||
|
}
|
||||||
|
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().
|
// Resolved once in main() via initDatabasePath() before runApp().
|
||||||
@@ -707,18 +904,34 @@ Future<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
|||||||
void resetDatabasePathForTesting() => _dbPath = null;
|
void resetDatabasePathForTesting() => _dbPath = null;
|
||||||
Future<String?> androidFallbackPathForTesting() => _androidFallbackPath();
|
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() {
|
LazyDatabase _openConnection() {
|
||||||
return LazyDatabase(() async {
|
return LazyDatabase(() async {
|
||||||
final file = File(await _resolveDatabasePath());
|
final file = File(await _resolveDatabasePath());
|
||||||
return NativeDatabase.createInBackground(
|
return NativeDatabase.createInBackground(file, setup: _setupPragmas);
|
||||||
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;');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,8 +9,9 @@ class LocalSieveRepository {
|
|||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
|
|
||||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||||
final rows = await (_db.select(_db.localSieveScripts)
|
final rows = await (_db.select(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.localSieveScripts,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.get();
|
.get();
|
||||||
return rows
|
return rows
|
||||||
.map(
|
.map(
|
||||||
@@ -26,10 +27,9 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||||
final rowId = int.parse(blobId);
|
final rowId = int.parse(blobId);
|
||||||
final row = await (_db.select(_db.localSieveScripts)
|
final row = await (_db.select(
|
||||||
..where(
|
_db.localSieveScripts,
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
))
|
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (row == null) throw Exception('Local script not found: $blobId');
|
if (row == null) throw Exception('Local script not found: $blobId');
|
||||||
return row.content;
|
return row.content;
|
||||||
@@ -44,9 +44,7 @@ class LocalSieveRepository {
|
|||||||
if (id != null) {
|
if (id != null) {
|
||||||
final rowId = int.parse(id);
|
final rowId = int.parse(id);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where(
|
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.write(
|
.write(
|
||||||
LocalSieveScriptsCompanion(
|
LocalSieveScriptsCompanion(
|
||||||
name: Value(name),
|
name: Value(name),
|
||||||
@@ -78,10 +76,9 @@ class LocalSieveRepository {
|
|||||||
|
|
||||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.delete(_db.localSieveScripts)
|
await (_db.delete(
|
||||||
..where(
|
_db.localSieveScripts,
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +89,7 @@ class LocalSieveRepository {
|
|||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||||
final rowId = int.parse(scriptId);
|
final rowId = int.parse(scriptId);
|
||||||
await (_db.update(_db.localSieveScripts)
|
await (_db.update(_db.localSieveScripts)
|
||||||
..where(
|
..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
|
||||||
))
|
|
||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,8 @@ import 'package:sharedinbox/data/db/database.dart';
|
|||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
|
||||||
class DraftRepositoryImpl implements DraftRepository {
|
class DraftRepositoryImpl implements DraftRepository {
|
||||||
DraftRepositoryImpl(
|
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
||||||
this._db,
|
: _imapConnect = imapConnect;
|
||||||
this._accounts, {
|
|
||||||
ImapConnectFn? imapConnect,
|
|
||||||
}) : _imapConnect = imapConnect;
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -124,10 +121,7 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncWithServer(
|
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
|
||||||
imap.ImapClient client,
|
|
||||||
String accountId,
|
|
||||||
) async {
|
|
||||||
// Create/select the Drafts folder.
|
// Create/select the Drafts folder.
|
||||||
try {
|
try {
|
||||||
await client.createMailbox('Drafts');
|
await client.createMailbox('Drafts');
|
||||||
@@ -162,8 +156,9 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
? uidList.first.toString()
|
? uidList.first.toString()
|
||||||
: null;
|
: null;
|
||||||
if (uid != null) {
|
if (uid != null) {
|
||||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
||||||
.write(DraftsCompanion(imapServerId: Value(uid)));
|
DraftsCompanion(imapServerId: Value(uid)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
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/account.dart' as account_model;
|
||||||
import 'package:sharedinbox/core/models/email.dart' as model;
|
import 'package:sharedinbox/core/models/email.dart' as model;
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
@@ -95,6 +96,26 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.map((rows) => rows.map(_threadRowToModel).toList());
|
.map((rows) => rows.map(_threadRowToModel).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<model.EmailThread>> observeAllInboxThreads({int limit = 50}) {
|
||||||
|
final query = _db.select(_db.threads).join([
|
||||||
|
innerJoin(
|
||||||
|
_db.mailboxes,
|
||||||
|
_db.mailboxes.accountId.equalsExp(_db.threads.accountId) &
|
||||||
|
_db.mailboxes.path.equalsExp(_db.threads.mailboxPath),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
query
|
||||||
|
..where(_db.mailboxes.role.equals('inbox'))
|
||||||
|
..orderBy([OrderingTerm.desc(_db.threads.latestDate)])
|
||||||
|
..limit(limit);
|
||||||
|
return query.watch().map(
|
||||||
|
(rows) => rows
|
||||||
|
.map((row) => _threadRowToModel(row.readTable(_db.threads)))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
model.EmailThread _threadRowToModel(ThreadRow row) {
|
model.EmailThread _threadRowToModel(ThreadRow row) {
|
||||||
List<model.EmailAddress> parseAddresses(String json) {
|
List<model.EmailAddress> parseAddresses(String json) {
|
||||||
final list = jsonDecode(json) as List<dynamic>;
|
final list = jsonDecode(json) as List<dynamic>;
|
||||||
@@ -156,6 +177,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (threadEmails.isEmpty) return;
|
||||||
final latest = threadEmails.last;
|
final latest = threadEmails.last;
|
||||||
|
|
||||||
// Collect unique participants across the whole thread.
|
// Collect unique participants across the whole thread.
|
||||||
@@ -237,7 +259,12 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||||
final msg = fetch.messages.first;
|
final msg = fetch.messages.firstOrNull;
|
||||||
|
if (msg == null) {
|
||||||
|
throw StateError(
|
||||||
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
final textBody = msg.decodeTextPlainPart();
|
final textBody = msg.decodeTextPlainPart();
|
||||||
final rawHtml = msg.decodeTextHtmlPart();
|
final rawHtml = msg.decodeTextHtmlPart();
|
||||||
final htmlBody =
|
final htmlBody =
|
||||||
@@ -325,13 +352,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
],
|
],
|
||||||
'fetchHTMLBodyValues': true,
|
'fetchHTMLBodyValues': true,
|
||||||
'fetchTextBodyValues': true,
|
'fetchTextBodyValues': true,
|
||||||
'bodyProperties': [
|
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
||||||
'partId',
|
|
||||||
'type',
|
|
||||||
'name',
|
|
||||||
'size',
|
|
||||||
'subParts',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
@@ -540,7 +561,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
for (final msg in result.messages) {
|
for (final msg in result.messages) {
|
||||||
final uid = msg.uid;
|
final uid = msg.uid;
|
||||||
if (uid == null) continue;
|
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(
|
await (_db.update(_db.emails)..where((t) => t.id.equals(emailId))).write(
|
||||||
EmailsCompanion(
|
EmailsCompanion(
|
||||||
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
isSeen: Value(msg.flags?.contains(r'\Seen') ?? false),
|
||||||
@@ -595,7 +616,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bytes += msg.size ?? 0;
|
bytes += msg.size ?? 0;
|
||||||
final emailId = '${account.id}:$uid';
|
final emailId = '${account.id}:$mailboxPath:$uid';
|
||||||
final msgId = envelope.messageId?.trim();
|
final msgId = envelope.messageId?.trim();
|
||||||
final inReplyTo = envelope.inReplyTo?.trim();
|
final inReplyTo = envelope.inReplyTo?.trim();
|
||||||
final refs = msg.getHeaderValue('References')?.trim();
|
final refs = msg.getHeaderValue('References')?.trim();
|
||||||
@@ -1949,8 +1970,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
final alreadyApplied = await (_db.select(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.localSieveApplied,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.get();
|
.get();
|
||||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||||
|
|
||||||
@@ -2050,7 +2072,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(1))
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (destMailbox == null) {
|
if (destMailbox == null) {
|
||||||
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
log(
|
||||||
|
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
destPath = destMailbox.path;
|
destPath = destMailbox.path;
|
||||||
@@ -2808,11 +2832,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||||
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||||
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||||
final fetch = await client.uidFetchMessage(
|
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||||
emailRow.uid,
|
final msg = fetch.messages.firstOrNull;
|
||||||
'BODY.PEEK[]',
|
if (msg == null) {
|
||||||
);
|
throw StateError(
|
||||||
final msg = fetch.messages.first;
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||||
final bytes = part.decodeContentBinary();
|
final bytes = part.decodeContentBinary();
|
||||||
if (bytes == null) {
|
if (bytes == null) {
|
||||||
@@ -2874,11 +2900,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||||
final fetch = await client.uidFetchMessage(
|
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||||
emailRow.uid,
|
final msg = fetch.messages.firstOrNull;
|
||||||
'BODY.PEEK[]',
|
if (msg == null) {
|
||||||
);
|
throw StateError(
|
||||||
return fetch.messages.first.renderMessage();
|
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return msg.renderMessage();
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
}
|
}
|
||||||
@@ -2894,9 +2923,9 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
|
|
||||||
final sql = accountId != null
|
final sql = accountId != null
|
||||||
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
? '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'
|
: '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
|
final variables = accountId != null
|
||||||
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
|
||||||
: [Variable<String>(ftsQuery)];
|
: [Variable<String>(ftsQuery)];
|
||||||
@@ -2906,18 +2935,151 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
final emailRows = await Future.wait(
|
final emailRows = await Future.wait(
|
||||||
queryRows.map((r) => _db.emails.mapFromRow(r)),
|
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();
|
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.
|
/// Converts a user query string into an FTS5 match expression.
|
||||||
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
/// Each whitespace-separated word becomes a prefix term (word*) so that
|
||||||
/// partial words still match. Special FTS5 characters are stripped.
|
/// partial words still match. Special FTS5 characters are stripped.
|
||||||
static String _toFtsQuery(String query) {
|
static String _toFtsQuery(String query) {
|
||||||
final words = query
|
final words = query
|
||||||
.trim()
|
.trim()
|
||||||
.split(RegExp(r'\s+'))
|
.split(RegExp(r'[^\w]+'))
|
||||||
.where((w) => w.isNotEmpty)
|
|
||||||
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
|
|
||||||
.where((w) => w.isNotEmpty)
|
.where((w) => w.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
if (words.isEmpty) return '';
|
if (words.isEmpty) return '';
|
||||||
@@ -2955,6 +3117,20 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
final pattern = '%${query.toLowerCase()}%';
|
final pattern = '%${query.toLowerCase()}%';
|
||||||
|
|
||||||
|
// Addresses we deliberately wrote to (sent folder) should appear before
|
||||||
|
// addresses that happened to email us (inbox/other folders).
|
||||||
|
final sentMailboxes = await (_db.select(_db.mailboxes)
|
||||||
|
..where((t) {
|
||||||
|
Expression<bool> cond = t.role.equals('sent');
|
||||||
|
if (accountId != null) {
|
||||||
|
cond = t.accountId.equals(accountId) & cond;
|
||||||
|
}
|
||||||
|
return cond;
|
||||||
|
}))
|
||||||
|
.get();
|
||||||
|
final sentPaths = {for (final m in sentMailboxes) m.path};
|
||||||
|
|
||||||
final rows = await (_db.select(_db.emails)
|
final rows = await (_db.select(_db.emails)
|
||||||
..where((t) {
|
..where((t) {
|
||||||
Expression<bool> cond = const Constant(true);
|
Expression<bool> cond = const Constant(true);
|
||||||
@@ -2969,11 +3145,22 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(100))
|
..limit(100))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
|
// Two passes: sent-folder rows first (prioritise recipients we chose),
|
||||||
|
// then other rows (senders who contacted us).
|
||||||
|
final sortedRows = [
|
||||||
|
...rows.where((r) => sentPaths.contains(r.mailboxPath)),
|
||||||
|
...rows.where((r) => !sentPaths.contains(r.mailboxPath)),
|
||||||
|
];
|
||||||
|
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
final results = <model.EmailAddress>[];
|
final results = <model.EmailAddress>[];
|
||||||
final lowerQuery = query.toLowerCase();
|
final lowerQuery = query.toLowerCase();
|
||||||
for (final row in rows) {
|
for (final row in sortedRows) {
|
||||||
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
final isSent = sentPaths.contains(row.mailboxPath);
|
||||||
|
final fields = isSent
|
||||||
|
? [row.toAddresses, row.ccJson, row.fromJson]
|
||||||
|
: [row.fromJson, row.toAddresses, row.ccJson];
|
||||||
|
for (final jsonStr in fields) {
|
||||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||||
for (final e in list) {
|
for (final e in list) {
|
||||||
final map = e as Map<String, dynamic>;
|
final map = e as Map<String, dynamic>;
|
||||||
@@ -2994,68 +3181,42 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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(
|
Future<List<model.Email>> searchEmails(
|
||||||
String accountId,
|
String accountId,
|
||||||
String mailboxPath,
|
String mailboxPath,
|
||||||
String query,
|
String query,
|
||||||
) async {
|
) async {
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
final ftsQuery = _toFtsQuery(query);
|
||||||
final password = await _accounts.getPassword(accountId);
|
if (ftsQuery.isEmpty) return [];
|
||||||
final client = await _imapConnect(
|
|
||||||
account,
|
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
||||||
_effectiveUsername(account),
|
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
||||||
password,
|
' 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(
|
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
||||||
imap.MessageSequence.fromIds(uids, isUid: true),
|
|
||||||
'(UID FLAGS ENVELOPE)',
|
final seen = <String>{};
|
||||||
);
|
final merged = <model.Email>[];
|
||||||
return fetch.messages
|
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
||||||
.where((msg) => msg.uid != null && msg.envelope != null)
|
if (seen.add(e.id)) merged.add(e);
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
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 ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Computes a stable threadId from RFC 2822 headers.
|
/// Computes a stable threadId from RFC 2822 headers.
|
||||||
@@ -3252,14 +3413,17 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||||
try {
|
try {
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
await (_db.delete(_db.emails)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.emails,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(_db.pendingChanges)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.pendingChanges,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(_db.syncStates)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.syncStates,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -82,8 +82,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||||
// folders the server doesn't tag with a special-use attribute.
|
// folders the server doesn't tag with a special-use attribute.
|
||||||
final existingRows = await (_db.select(_db.mailboxes)
|
final existingRows = await (_db.select(
|
||||||
..where((t) => t.accountId.equals(account.id)))
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.accountId.equals(account.id)))
|
||||||
.get();
|
.get();
|
||||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||||
|
|
||||||
@@ -320,8 +321,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {
|
Future<void> clearForResync(String accountId) async {
|
||||||
await (_db.delete(_db.mailboxes)
|
await (_db.delete(
|
||||||
..where((t) => t.accountId.equals(accountId)))
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.accountId.equals(accountId)))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,11 +343,23 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<model.Mailbox> createMailbox(String accountId, String name) async {
|
||||||
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
return _createMailboxWithRoleImap(account, password, name, null);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
return _createMailboxWithRoleJmap(account, password, name, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<model.Mailbox> _createMailboxWithRoleImap(
|
Future<model.Mailbox> _createMailboxWithRoleImap(
|
||||||
account_model.Account account,
|
account_model.Account account,
|
||||||
String password,
|
String password,
|
||||||
String name,
|
String name,
|
||||||
String role,
|
String? role,
|
||||||
) async {
|
) async {
|
||||||
final client = await _imapConnect(
|
final client = await _imapConnect(
|
||||||
account,
|
account,
|
||||||
@@ -367,7 +381,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
role: Value(role),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
|
final row = await (_db.select(
|
||||||
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.id.equals(id)))
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return _toModel(row);
|
return _toModel(row);
|
||||||
}
|
}
|
||||||
@@ -376,7 +392,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
account_model.Account account,
|
account_model.Account account,
|
||||||
String password,
|
String password,
|
||||||
String name,
|
String name,
|
||||||
String role,
|
String? role,
|
||||||
) async {
|
) async {
|
||||||
final jmapUrl = account.jmapUrl;
|
final jmapUrl = account.jmapUrl;
|
||||||
if (jmapUrl == null || jmapUrl.isEmpty) {
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
@@ -394,7 +410,10 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
{
|
{
|
||||||
'accountId': jmap.accountId,
|
'accountId': jmap.accountId,
|
||||||
'create': {
|
'create': {
|
||||||
'new-mailbox': {'name': name, 'role': role},
|
'new-mailbox': {
|
||||||
|
'name': name,
|
||||||
|
if (role != null) 'role': role,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
@@ -419,8 +438,9 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
role: Value(role),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final row = await (_db.select(_db.mailboxes)
|
final row = await (_db.select(
|
||||||
..where((t) => t.id.equals(dbId)))
|
_db.mailboxes,
|
||||||
|
)..where((t) => t.id.equals(dbId)))
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return _toModel(row);
|
return _toModel(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:enough_mail/enough_mail.dart' as imap;
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart' as account_model;
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
|
import 'package:sharedinbox/data/jmap/jmap_client.dart';
|
||||||
|
|
||||||
|
const _notesFolder = 'Notes';
|
||||||
|
const _headerNoteFor = 'X-SharedInbox-Note-For';
|
||||||
|
const _headerNoteId = 'X-SharedInbox-Note-Id';
|
||||||
|
|
||||||
|
class NoteRepositoryImpl implements NoteRepository {
|
||||||
|
NoteRepositoryImpl(
|
||||||
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn imapConnect = connectImap,
|
||||||
|
http.Client? httpClient,
|
||||||
|
}) : _imapConnect = imapConnect,
|
||||||
|
_httpClient = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final AccountRepository _accounts;
|
||||||
|
final ImapConnectFn _imapConnect;
|
||||||
|
final http.Client _httpClient;
|
||||||
|
|
||||||
|
String _effectiveUsername(account_model.Account account) =>
|
||||||
|
account.username.isNotEmpty ? account.username : account.email;
|
||||||
|
|
||||||
|
// ── Observe (local cache) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<EmailNote>> observeNotes(String accountId, String messageId) {
|
||||||
|
return (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(accountId) & t.messageId.equals(messageId),
|
||||||
|
)
|
||||||
|
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map(_toModel).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sync (server → local cache) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> syncNotes(String accountId, String messageId) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _syncNotesImap(account, password, messageId);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _syncNotesJmap(account, password, messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder doesn't exist — nothing to sync.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final escaped = messageId.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
|
||||||
|
final searchResult = await client.uidSearchMessages(
|
||||||
|
searchCriteria: 'HEADER $_headerNoteFor "$escaped"',
|
||||||
|
);
|
||||||
|
final uids = searchResult.matchingSequence?.toList() ?? [];
|
||||||
|
|
||||||
|
if (uids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final seq = imap.MessageSequence.fromIds(uids, isUid: true);
|
||||||
|
final fetch = await client.uidFetchMessages(seq, '(UID BODY.PEEK[])');
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final msg in fetch.messages) {
|
||||||
|
final uid = msg.uid;
|
||||||
|
if (uid == null) continue;
|
||||||
|
final noteId = msg.getHeaderValue(_headerNoteId)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: msg.decodeTextPlainPart() ?? '',
|
||||||
|
serverId: uid.toString(),
|
||||||
|
createdAt: msg.decodeDate() ?? DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes (deleted on the server).
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNotesJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (mailboxId == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queryResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/query',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'filter': {'inMailbox': mailboxId},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final ids = List<String>.from(
|
||||||
|
(_responseArgs(queryResp, 0, 'Email/query')['ids'] as List? ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
await (_db.delete(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) &
|
||||||
|
t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final getResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/get',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'ids': ids,
|
||||||
|
'properties': [
|
||||||
|
'id',
|
||||||
|
'receivedAt',
|
||||||
|
'textBody',
|
||||||
|
'bodyValues',
|
||||||
|
'header:$_headerNoteFor:asText',
|
||||||
|
'header:$_headerNoteId:asText',
|
||||||
|
],
|
||||||
|
'fetchTextBodyValues': true,
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list =
|
||||||
|
_responseArgs(getResp, 0, 'Email/get')['list'] as List<dynamic>;
|
||||||
|
|
||||||
|
final fetchedIds = <String>{};
|
||||||
|
for (final e in list) {
|
||||||
|
final m = e as Map<String, dynamic>;
|
||||||
|
final noteFor = (m['header:$_headerNoteFor:asText'] as String?)?.trim();
|
||||||
|
if (noteFor != messageId) continue;
|
||||||
|
final noteId = (m['header:$_headerNoteId:asText'] as String?)?.trim();
|
||||||
|
if (noteId == null || noteId.isEmpty) continue;
|
||||||
|
final jmapEmailId = m['id'] as String;
|
||||||
|
|
||||||
|
final bodyValues = m['bodyValues'] as Map<String, dynamic>? ?? {};
|
||||||
|
final textBodyParts = m['textBody'] as List<dynamic>? ?? [];
|
||||||
|
var noteText = '';
|
||||||
|
if (textBodyParts.isNotEmpty) {
|
||||||
|
final partId =
|
||||||
|
(textBodyParts.first as Map<String, dynamic>)['partId'] as String?;
|
||||||
|
if (partId != null) {
|
||||||
|
noteText = (bodyValues[partId] as Map<String, dynamic>?)?['value']
|
||||||
|
as String? ??
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final createdAt =
|
||||||
|
DateTime.tryParse(m['receivedAt'] as String? ?? '') ?? DateTime.now();
|
||||||
|
fetchedIds.add(noteId);
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: noteText,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stale local notes.
|
||||||
|
final local = await (_db.select(_db.emailNotes)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(account.id) & t.messageId.equals(messageId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
for (final note in local) {
|
||||||
|
if (!fetchedIds.contains(note.id)) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(note.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addNote(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final account = await _accounts.getAccount(accountId);
|
||||||
|
if (account == null) return;
|
||||||
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
final noteId = _generateId();
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _addNoteImap(account, password, messageId, noteId, text);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _addNoteJmap(account, password, messageId, noteId, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.createMailbox(_notesFolder);
|
||||||
|
} catch (_) {
|
||||||
|
// Already exists.
|
||||||
|
}
|
||||||
|
|
||||||
|
final builder = imap.MessageBuilder()
|
||||||
|
..subject = 'Note'
|
||||||
|
..text = text;
|
||||||
|
builder.addHeader(_headerNoteFor, messageId);
|
||||||
|
builder.addHeader(_headerNoteId, noteId);
|
||||||
|
final mime = builder.buildMimeMessage();
|
||||||
|
|
||||||
|
final appendResult = await client.appendMessage(
|
||||||
|
mime,
|
||||||
|
targetMailboxPath: _notesFolder,
|
||||||
|
);
|
||||||
|
final uidList =
|
||||||
|
appendResult.responseCodeAppendUid?.targetSequence.toList();
|
||||||
|
final serverId = (uidList != null && uidList.isNotEmpty)
|
||||||
|
? uidList.first.toString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: serverId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
String messageId,
|
||||||
|
String noteId,
|
||||||
|
String text,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) {
|
||||||
|
throw Exception('JMAP account ${account.id} has no jmapUrl');
|
||||||
|
}
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mailboxId = await _findOrCreateNotesMailboxJmap(jmap);
|
||||||
|
|
||||||
|
const bodyPartId = '1';
|
||||||
|
final setResp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-note': {
|
||||||
|
'mailboxIds': {mailboxId: true},
|
||||||
|
'subject': 'Note',
|
||||||
|
'keywords': {r'$seen': true},
|
||||||
|
'headers': [
|
||||||
|
{'name': _headerNoteFor, 'value': ' $messageId'},
|
||||||
|
{'name': _headerNoteId, 'value': ' $noteId'},
|
||||||
|
],
|
||||||
|
'bodyValues': {
|
||||||
|
bodyPartId: {
|
||||||
|
'value': text,
|
||||||
|
'isEncodingProblem': false,
|
||||||
|
'isTruncated': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'textBody': [
|
||||||
|
{'partId': bodyPartId, 'type': 'text/plain'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
final result = _responseArgs(setResp, 0, 'Email/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newEmail = created?['new-note'] as Map<String, dynamic>?;
|
||||||
|
final jmapEmailId = newEmail?['id'] as String? ?? '';
|
||||||
|
|
||||||
|
await _db.into(_db.emailNotes).insertOnConflictUpdate(
|
||||||
|
EmailNotesCompanion.insert(
|
||||||
|
id: noteId,
|
||||||
|
accountId: account.id,
|
||||||
|
messageId: messageId,
|
||||||
|
noteText: text,
|
||||||
|
serverId: jmapEmailId,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteNote(String noteId) async {
|
||||||
|
final noteRow = await (_db.select(_db.emailNotes)
|
||||||
|
..where((t) => t.id.equals(noteId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (noteRow == null) return;
|
||||||
|
|
||||||
|
final account = await _accounts.getAccount(noteRow.accountId);
|
||||||
|
if (account == null) {
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteId)))
|
||||||
|
.go();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final password = await _accounts.getPassword(account.id);
|
||||||
|
|
||||||
|
switch (account.type) {
|
||||||
|
case account_model.AccountType.imap:
|
||||||
|
await _deleteNoteImap(account, password, noteRow);
|
||||||
|
case account_model.AccountType.jmap:
|
||||||
|
await _deleteNoteJmap(account, password, noteRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteImap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final client = await _imapConnect(
|
||||||
|
account,
|
||||||
|
_effectiveUsername(account),
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.selectMailboxByPath(_notesFolder);
|
||||||
|
final uid = int.tryParse(noteRow.serverId);
|
||||||
|
if (uid != null) {
|
||||||
|
final seq = imap.MessageSequence.fromId(uid, isUid: true);
|
||||||
|
await client.uidMarkDeleted(seq);
|
||||||
|
await client.uidExpunge(seq);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Notes folder gone or message already deleted — clean up locally.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
|
}
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteNoteJmap(
|
||||||
|
account_model.Account account,
|
||||||
|
String password,
|
||||||
|
EmailNoteRow noteRow,
|
||||||
|
) async {
|
||||||
|
final jmapUrl = account.jmapUrl;
|
||||||
|
if (jmapUrl == null || jmapUrl.isEmpty) return;
|
||||||
|
|
||||||
|
final jmap = await JmapClient.connect(
|
||||||
|
httpClient: _httpClient,
|
||||||
|
jmapUrl: Uri.parse(jmapUrl),
|
||||||
|
username: _effectiveUsername(account),
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noteRow.serverId.isNotEmpty) {
|
||||||
|
await jmap.call([
|
||||||
|
[
|
||||||
|
'Email/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'destroy': [noteRow.serverId],
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await (_db.delete(_db.emailNotes)..where((t) => t.id.equals(noteRow.id)))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JMAP helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<String?> _findNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/get',
|
||||||
|
{'accountId': jmap.accountId, 'ids': null},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final list = _responseArgs(resp, 0, 'Mailbox/get')['list'] as List<dynamic>;
|
||||||
|
for (final m in list) {
|
||||||
|
final map = m as Map<String, dynamic>;
|
||||||
|
if (map['name'] == _notesFolder) return map['id'] as String?;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _findOrCreateNotesMailboxJmap(JmapClient jmap) async {
|
||||||
|
final existing = await _findNotesMailboxJmap(jmap);
|
||||||
|
if (existing != null) return existing;
|
||||||
|
|
||||||
|
final resp = await jmap.call([
|
||||||
|
[
|
||||||
|
'Mailbox/set',
|
||||||
|
{
|
||||||
|
'accountId': jmap.accountId,
|
||||||
|
'create': {
|
||||||
|
'new-notes': {'name': _notesFolder},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
final result = _responseArgs(resp, 0, 'Mailbox/set');
|
||||||
|
final created = result['created'] as Map<String, dynamic>?;
|
||||||
|
final newMailbox = created?['new-notes'] as Map<String, dynamic>?;
|
||||||
|
return newMailbox?['id'] as String? ?? _notesFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _responseArgs(
|
||||||
|
List<dynamic> responses,
|
||||||
|
int index,
|
||||||
|
String expectedMethod,
|
||||||
|
) {
|
||||||
|
final triple = responses[index] as List<dynamic>;
|
||||||
|
final method = triple[0] as String;
|
||||||
|
if (method == 'error') {
|
||||||
|
final err = triple[1] as Map<String, dynamic>;
|
||||||
|
throw JmapException('$expectedMethod error: ${err['type']}');
|
||||||
|
}
|
||||||
|
return triple[1] as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailNote _toModel(EmailNoteRow row) => EmailNote(
|
||||||
|
id: row.id,
|
||||||
|
accountId: row.accountId,
|
||||||
|
messageId: row.messageId,
|
||||||
|
noteText: row.noteText,
|
||||||
|
serverId: row.serverId,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generates a random UUID v4.
|
||||||
|
static String _generateId() {
|
||||||
|
final rng = math.Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}'
|
||||||
|
'-${hex.substring(12, 16)}-${hex.substring(16, 20)}'
|
||||||
|
'-${hex.substring(20)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,8 +24,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
|
|
||||||
await _db.transaction(() async {
|
await _db.transaction(() async {
|
||||||
// Remove existing entry for same query (deduplication).
|
// Remove existing entry for same query (deduplication).
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
await (_db.delete(
|
||||||
..where((t) => t.query.equals(trimmed)))
|
_db.searchHistoryEntries,
|
||||||
|
)..where((t) => t.query.equals(trimmed)))
|
||||||
.go();
|
.go();
|
||||||
|
|
||||||
await _db.into(_db.searchHistoryEntries).insert(
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
@@ -43,8 +44,9 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (keepIds.isNotEmpty) {
|
if (keepIds.isNotEmpty) {
|
||||||
await (_db.delete(_db.searchHistoryEntries)
|
await (_db.delete(
|
||||||
..where((t) => t.id.isNotIn(keepIds)))
|
_db.searchHistoryEntries,
|
||||||
|
)..where((t) => t.id.isNotIn(keepIds)))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
await _pruneExpired();
|
await _pruneExpired();
|
||||||
|
|
||||||
final keyIdHex = _hex(keyId);
|
final keyIdHex = _hex(keyId);
|
||||||
final row = await (_db.select(_db.shareKeys)
|
final row = await (_db.select(
|
||||||
..where((t) => t.id.equals(keyIdHex)))
|
_db.shareKeys,
|
||||||
|
)..where((t) => t.id.equals(keyIdHex)))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (row == null) return null;
|
if (row == null) return null;
|
||||||
@@ -55,10 +56,9 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pruneExpired() async {
|
Future<void> _pruneExpired() async {
|
||||||
await (_db.delete(_db.shareKeys)
|
await (_db.delete(
|
||||||
..where(
|
_db.shareKeys,
|
||||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
||||||
))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<pref.UserPreferences> observePreferences() {
|
Stream<pref.UserPreferences> observePreferences() {
|
||||||
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
|
return (_db.select(
|
||||||
|
_db.userPreferences,
|
||||||
|
)..where((t) => t.id.equals(_rowId)))
|
||||||
.watchSingleOrNull()
|
.watchSingleOrNull()
|
||||||
.map(_rowToModel);
|
.map(_rowToModel);
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,51 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updatePrefetchMode(pref.PrefetchMode mode) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
prefetchMode: Value(mode.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> updateBodyCacheLimitMb(int mb) async {
|
||||||
|
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
||||||
|
UserPreferencesCompanion(
|
||||||
|
id: const Value(_rowId),
|
||||||
|
bodyCacheLimitMb: Value(mb),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<String>> observeTrustedImageSenders() {
|
||||||
|
return (_db.select(_db.imageTrustedSenders)
|
||||||
|
..orderBy([(t) => OrderingTerm.desc(t.addedAt)]))
|
||||||
|
.watch()
|
||||||
|
.map((rows) => rows.map((r) => r.senderEmail).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> addTrustedImageSender(String senderEmail) async {
|
||||||
|
await _db.into(_db.imageTrustedSenders).insertOnConflictUpdate(
|
||||||
|
ImageTrustedSendersCompanion(
|
||||||
|
senderEmail: Value(senderEmail.toLowerCase()),
|
||||||
|
addedAt: Value(DateTime.now()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeTrustedImageSender(String senderEmail) async {
|
||||||
|
await (_db.delete(_db.imageTrustedSenders)
|
||||||
|
..where((t) => t.senderEmail.equals(senderEmail.toLowerCase())))
|
||||||
|
.go();
|
||||||
|
}
|
||||||
|
|
||||||
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
|
||||||
if (row == null) return const pref.UserPreferences();
|
if (row == null) return const pref.UserPreferences();
|
||||||
return pref.UserPreferences(
|
return pref.UserPreferences(
|
||||||
@@ -63,6 +110,8 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|||||||
(e) => e.name == row.afterMailViewAction,
|
(e) => e.name == row.afterMailViewAction,
|
||||||
orElse: () => pref.AfterMailViewAction.nextMessage,
|
orElse: () => pref.AfterMailViewAction.nextMessage,
|
||||||
),
|
),
|
||||||
|
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
|
||||||
|
bodyCacheLimitMb: row.bodyCacheLimitMb,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:sharedinbox/core/models/account.dart' as model;
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/note_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
import 'package:sharedinbox/core/repositories/search_history_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
import 'package:sharedinbox/core/repositories/share_key_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
@@ -32,6 +34,7 @@ import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
|||||||
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/draft_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||||
|
import 'package:sharedinbox/data/repositories/note_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/search_history_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
|
||||||
@@ -101,8 +104,9 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final searchHistoryRepositoryProvider =
|
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
||||||
Provider<SearchHistoryRepository>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,8 +139,10 @@ final syncHealthProvider =
|
|||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
final isSyncingProvider =
|
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
||||||
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
ref,
|
||||||
|
accountId,
|
||||||
|
) {
|
||||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,8 +191,9 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
|||||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final undoServiceProvider =
|
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
||||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
UndoService.new,
|
||||||
|
);
|
||||||
|
|
||||||
/// Loads email header + body and marks the email as seen.
|
/// Loads email header + body and marks the email as seen.
|
||||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||||
@@ -207,10 +214,38 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
|||||||
repo.getEmailBody(_emailId),
|
repo.getEmailBody(_emailId),
|
||||||
]);
|
]);
|
||||||
unawaited(repo.setFlag(_emailId, seen: true));
|
unawaited(repo.setFlag(_emailId, seen: true));
|
||||||
|
final header = results[0] as Email?;
|
||||||
|
if (header != null) {
|
||||||
|
unawaited(_prefetchNextEmailBody(repo, header));
|
||||||
|
}
|
||||||
return (results[0] as Email?, results[1] as EmailBody);
|
return (results[0] as Email?, results[1] as EmailBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _prefetchNextEmailBody(
|
||||||
|
EmailRepository repo,
|
||||||
|
Email header,
|
||||||
|
) async {
|
||||||
|
final prefs = ref.read(userPreferencesProvider).value;
|
||||||
|
final action =
|
||||||
|
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
||||||
|
if (action != AfterMailViewAction.nextMessage) return;
|
||||||
|
|
||||||
|
final threads =
|
||||||
|
await repo.observeThreads(header.accountId, header.mailboxPath).first;
|
||||||
|
final currentIndex = threads.indexWhere(
|
||||||
|
(t) => t.emailIds.contains(_emailId),
|
||||||
|
);
|
||||||
|
if (currentIndex < 0 || currentIndex + 1 >= threads.length) return;
|
||||||
|
|
||||||
|
final nextId = threads[currentIndex + 1].latestEmailId;
|
||||||
|
await repo.getEmailBody(nextId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final allAccountsProvider = StreamProvider<List<model.Account>>((ref) {
|
||||||
|
return ref.watch(accountRepositoryProvider).observeAccounts();
|
||||||
|
});
|
||||||
|
|
||||||
final accountByIdProvider =
|
final accountByIdProvider =
|
||||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||||
@@ -232,12 +267,40 @@ final accountConnectionStatusProvider =
|
|||||||
.testConnection(account, password);
|
.testConnection(account, password);
|
||||||
});
|
});
|
||||||
|
|
||||||
final userPreferencesRepositoryProvider =
|
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
||||||
Provider<UserPreferencesRepository>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final userPreferencesProvider =
|
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
||||||
StreamProvider.autoDispose<UserPreferences>((ref) {
|
ref,
|
||||||
|
) {
|
||||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final trustedImageSendersProvider =
|
||||||
|
StreamProvider.autoDispose<List<String>>((ref) {
|
||||||
|
return ref
|
||||||
|
.watch(userPreferencesRepositoryProvider)
|
||||||
|
.observeTrustedImageSenders();
|
||||||
|
});
|
||||||
|
|
||||||
|
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
|
||||||
|
return NoteRepositoryImpl(
|
||||||
|
ref.watch(dbProvider),
|
||||||
|
ref.watch(accountRepositoryProvider),
|
||||||
|
imapConnect: ref.watch(imapConnectProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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)>(
|
||||||
|
(ref, params) =>
|
||||||
|
ref.watch(noteRepositoryProvider).observeNotes(params.$1, params.$2),
|
||||||
|
);
|
||||||
|
|||||||
@@ -5,19 +5,30 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_riverpod/misc.dart' show Override;
|
import 'package:flutter_riverpod/misc.dart' show Override;
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
import 'package:sharedinbox/data/db/database.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/router.dart';
|
import 'package:sharedinbox/ui/router.dart';
|
||||||
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
import 'package:sharedinbox/ui/screens/crash_screen.dart';
|
||||||
|
import 'package:stack_trace/stack_trace.dart' as stack_trace;
|
||||||
|
|
||||||
void main({List<Override> overrides = const []}) async {
|
void main({List<Override> overrides = const []}) {
|
||||||
unawaited(
|
unawaited(
|
||||||
runZonedGuarded(
|
runZonedGuarded(
|
||||||
() async {
|
() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Dart's async machinery propagates stack traces in chain format
|
||||||
|
// (with '===== asynchronous gap =====' separators). Flutter's
|
||||||
|
// StackFrame parser asserts on those lines, so strip them first.
|
||||||
|
FlutterError.demangleStackTrace = (StackTrace s) {
|
||||||
|
if (s is stack_trace.Chain) return s.toTrace().vmTrace;
|
||||||
|
if (s is stack_trace.Trace) return s.vmTrace;
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
// Catch errors during build (e.g. layout exceptions) and show CrashScreen.
|
||||||
ErrorWidget.builder = (details) => CrashScreen(
|
ErrorWidget.builder = (details) => CrashScreen(
|
||||||
exception: details.exception,
|
exception: details.exception,
|
||||||
@@ -39,19 +50,35 @@ void main({List<Override> overrides = const []}) async {
|
|||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
await initNotifications();
|
await initNotifications();
|
||||||
await registerBackgroundSync();
|
await registerBackgroundSync();
|
||||||
|
await _registerPrefetchTaskFromStoredPrefs();
|
||||||
}
|
}
|
||||||
runApp(
|
runApp(
|
||||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(error, stack) {
|
// This handler runs in the parent zone — runApp cannot be called here.
|
||||||
// Catch unhandled async errors.
|
// Framework errors are already handled by FlutterError.onError above.
|
||||||
runApp(CrashScreen(exception: error, stackTrace: stack));
|
(error, stack) => FlutterError.reportError(
|
||||||
},
|
FlutterErrorDetails(exception: error, stack: stack),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the stored prefetch preference and registers the WorkManager task
|
||||||
|
/// with the correct network constraint for it. Opens and immediately closes
|
||||||
|
/// a temporary DB connection; safe because initDatabasePath() has already run.
|
||||||
|
Future<void> _registerPrefetchTaskFromStoredPrefs() async {
|
||||||
|
final db = AppDatabase();
|
||||||
|
try {
|
||||||
|
final row = await db.select(db.userPreferences).getSingleOrNull();
|
||||||
|
final mode = PrefetchMode.fromString(row?.prefetchMode);
|
||||||
|
await registerBodyPrefetchTask(mode);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SharedInboxApp extends ConsumerStatefulWidget {
|
class SharedInboxApp extends ConsumerStatefulWidget {
|
||||||
const SharedInboxApp({super.key});
|
const SharedInboxApp({super.key});
|
||||||
|
|
||||||
@@ -59,6 +86,8 @@ class SharedInboxApp extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
ConsumerState<SharedInboxApp> createState() => _SharedInboxAppState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _kGitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -66,6 +95,11 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
// Start background IMAP sync once — runs for the lifetime of the app.
|
// Start background IMAP sync once — runs for the lifetime of the app.
|
||||||
ref.read(syncManagerProvider).start();
|
ref.read(syncManagerProvider).start();
|
||||||
ref.read(reliabilityRunnerProvider).start();
|
ref.read(reliabilityRunnerProvider).start();
|
||||||
|
if (_kGitHash.isNotEmpty) {
|
||||||
|
unawaited(
|
||||||
|
ref.read(dbProvider).recordInstalledVersionIfNew(_kGitHash),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
darkTheme: ThemeData(
|
darkTheme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
@@ -82,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
splashFactory: NoSplash.splashFactory,
|
||||||
),
|
),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/sieve_script.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/about_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
@@ -8,7 +9,9 @@ import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_send_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/add_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
import 'package:sharedinbox/ui/screens/address_emails_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/bug_report_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
import 'package:sharedinbox/ui/screens/changelog_screen.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
import 'package:sharedinbox/ui/screens/edit_account_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
||||||
@@ -19,16 +22,22 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart';
|
|||||||
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/thread_detail_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/undo_log_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/accounts',
|
initialLocation: '/inbox',
|
||||||
routes: [
|
routes: [
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (ctx, state, child) => UndoShell(child: child),
|
builder: (ctx, state, child) => UndoShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/inbox',
|
||||||
|
builder: (ctx, state) => const CombinedInboxScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/accounts',
|
path: '/accounts',
|
||||||
builder: (ctx, state) => const AccountListScreen(),
|
builder: (ctx, state) => const AccountListScreen(),
|
||||||
@@ -48,6 +57,14 @@ final router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'undo-log',
|
path: 'undo-log',
|
||||||
builder: (ctx, state) => const UndoLogScreen(),
|
builder: (ctx, state) => const UndoLogScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':actionId',
|
||||||
|
builder: (ctx, state) => UndoLogDetailScreen(
|
||||||
|
action: state.extra as UndoAction,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'changelog',
|
path: 'changelog',
|
||||||
@@ -61,6 +78,12 @@ final router = GoRouter(
|
|||||||
path: 'preferences',
|
path: 'preferences',
|
||||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'trusted-senders',
|
||||||
|
builder: (ctx, state) => TrustedImageSendersScreen(
|
||||||
|
highlightedSender: state.extra as String?,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: ':accountId/edit',
|
path: ':accountId/edit',
|
||||||
builder: (ctx, state) => EditAccountScreen(
|
builder: (ctx, state) => EditAccountScreen(
|
||||||
@@ -164,6 +187,12 @@ final router = GoRouter(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/bug-report',
|
||||||
|
builder: (ctx, state) => BugReportScreen(
|
||||||
|
emailId: state.uri.queryParameters['emailId'],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
@@ -72,8 +73,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
|
|
||||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||||
try {
|
try {
|
||||||
final launched =
|
final launched = await launchUrl(
|
||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -121,8 +124,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final launched =
|
final launched = await launchUrl(
|
||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
url,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -176,9 +181,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
selectable: true,
|
selectable: true,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(_launchUrl(context, Uri.parse(href)));
|
||||||
_launchUrl(context, Uri.parse(href)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -195,22 +198,30 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.copy),
|
icon: const Icon(Icons.copy),
|
||||||
label: const Text('Copy to clipboard'),
|
label: const Text('Copy info'),
|
||||||
onPressed: () => unawaited(
|
onPressed: () => unawaited(
|
||||||
_copyToClipboard(context, imapCount, jmapCount),
|
_copyToClipboard(context, imapCount, jmapCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.bug_report),
|
icon: const Icon(Icons.bug_report_outlined),
|
||||||
label: const Text('Create issue'),
|
label: const Text('Public issue'),
|
||||||
onPressed: () => unawaited(
|
onPressed: () => unawaited(
|
||||||
_createIssue(context, imapCount, jmapCount),
|
_createIssue(context, imapCount, jmapCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.feedback_outlined),
|
||||||
|
label: const Text('Report bug'),
|
||||||
|
onPressed: () => context.push('/bug-report'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -219,11 +219,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_Step.done => const Center(
|
_Step.done => const Center(
|
||||||
child: Icon(
|
child: Icon(Icons.check_circle, size: 64, color: Colors.green),
|
||||||
Icons.check_circle,
|
|
||||||
size: 64,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_Step.error => Center(
|
_Step.error => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -158,10 +158,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
for (final account in selected) {
|
for (final account in selected) {
|
||||||
final password = await repo.getPassword(account.id);
|
final password = await repo.getPassword(account.id);
|
||||||
payloads.add(
|
payloads.add(
|
||||||
AccountPayload(
|
AccountPayload(accountJson: account.toJson(), password: password),
|
||||||
accountJson: account.toJson(),
|
|
||||||
password: password,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,9 +358,7 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
|||||||
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text('Encrypted code copied to clipboard'),
|
||||||
'Encrypted code copied to clipboard',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,635 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||||
|
|
||||||
|
const _bugReportApiUrl = String.fromEnvironment(
|
||||||
|
'BUG_REPORT_API_URL',
|
||||||
|
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
|
||||||
|
);
|
||||||
|
|
||||||
|
class BugReportScreen extends ConsumerStatefulWidget {
|
||||||
|
const BugReportScreen({super.key, this.emailId});
|
||||||
|
|
||||||
|
final String? emailId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
|
||||||
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
late final Future<String?> _deviceModelFuture = getDeviceModel();
|
||||||
|
|
||||||
|
final List<PlatformFile> _attachments = [];
|
||||||
|
bool _includeEmail = false;
|
||||||
|
bool _includeSyncLog = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
Email? _attachedEmail;
|
||||||
|
List<Account> _accounts = [];
|
||||||
|
String? _selectedAccountId;
|
||||||
|
String? _deviceModel;
|
||||||
|
bool _loadingEmail = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_loadInitialData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialData() async {
|
||||||
|
setState(() => _loadingEmail = true);
|
||||||
|
try {
|
||||||
|
_deviceModel = await _deviceModelFuture;
|
||||||
|
_accounts =
|
||||||
|
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||||
|
|
||||||
|
if (widget.emailId != null) {
|
||||||
|
final email =
|
||||||
|
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
|
||||||
|
if (mounted && email != null) {
|
||||||
|
_attachedEmail = email;
|
||||||
|
_selectedAccountId = email.accountId;
|
||||||
|
final fromStr =
|
||||||
|
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
|
||||||
|
final subjectStr = email.subject ?? '(no subject)';
|
||||||
|
_descriptionController.text =
|
||||||
|
'Problem with email from $fromStr: "$subjectStr"\n\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedAccountId == null && _accounts.isNotEmpty) {
|
||||||
|
_selectedAccountId = _accounts.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedAccountId != null) {
|
||||||
|
final matching =
|
||||||
|
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
|
||||||
|
if (matching != null) {
|
||||||
|
_emailController.text = matching.email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _loadingEmail = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int get _totalAttachmentSize {
|
||||||
|
return _attachments.fold(0, (sum, f) => sum + f.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSize(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickAttachments() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.pickFiles();
|
||||||
|
if (result == null) return;
|
||||||
|
final newFiles =
|
||||||
|
result.files.where((PlatformFile f) => f.path != null).toList();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_attachments.addAll(newFiles);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to pick files: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeAttachment(int index) {
|
||||||
|
setState(() {
|
||||||
|
_attachments.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _serializeSyncLogs(List<SyncLogEntry> entries) {
|
||||||
|
final sb = StringBuffer();
|
||||||
|
for (final entry in entries.take(50)) {
|
||||||
|
sb.writeln('ID: ${entry.id}');
|
||||||
|
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
|
||||||
|
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
|
||||||
|
sb.writeln('Result: ${entry.result}');
|
||||||
|
if (entry.errorMessage != null) {
|
||||||
|
sb.writeln('Error: ${entry.errorMessage}');
|
||||||
|
}
|
||||||
|
if (entry.stackTrace != null) {
|
||||||
|
sb.writeln('StackTrace:\n${entry.stackTrace}');
|
||||||
|
}
|
||||||
|
sb.writeln('Protocol: ${entry.protocol}');
|
||||||
|
sb.writeln(
|
||||||
|
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
|
||||||
|
);
|
||||||
|
if (entry.protocolLog != null) {
|
||||||
|
sb.writeln('Protocol Log:\n${entry.protocolLog}');
|
||||||
|
}
|
||||||
|
sb.writeln('---');
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitReport() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final totalSize = _totalAttachmentSize;
|
||||||
|
if (totalSize > 20 * 1024 * 1024) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final client = ref.read(httpClientProvider);
|
||||||
|
final uri = Uri.parse(_bugReportApiUrl);
|
||||||
|
final request = http.MultipartRequest('POST', uri);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
request.fields['description'] = _descriptionController.text;
|
||||||
|
|
||||||
|
// Email Data if from email view
|
||||||
|
if (_attachedEmail != null) {
|
||||||
|
final emailMap = {
|
||||||
|
'id': _attachedEmail!.id,
|
||||||
|
'subject': _attachedEmail!.subject,
|
||||||
|
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
|
||||||
|
'date': _attachedEmail!.sentAt?.toIso8601String() ??
|
||||||
|
_attachedEmail!.receivedAt.toIso8601String(),
|
||||||
|
'preview': _attachedEmail!.preview,
|
||||||
|
};
|
||||||
|
request.fields['email_data'] = jsonEncode(emailMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact Email
|
||||||
|
if (_includeEmail) {
|
||||||
|
request.fields['email'] = _emailController.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// About Info
|
||||||
|
PackageInfo? pkg;
|
||||||
|
try {
|
||||||
|
pkg = await _packageInfoFuture;
|
||||||
|
} catch (_) {}
|
||||||
|
final imapCount =
|
||||||
|
_accounts.where((a) => a.type == AccountType.imap).length;
|
||||||
|
final jmapCount =
|
||||||
|
_accounts.where((a) => a.type == AccountType.jmap).length;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final aboutInfo = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: pkg,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
|
);
|
||||||
|
request.fields['about_info'] = aboutInfo;
|
||||||
|
|
||||||
|
// Sync Log
|
||||||
|
if (_includeSyncLog && _selectedAccountId != null) {
|
||||||
|
final syncLogs = await ref
|
||||||
|
.read(syncLogRepositoryProvider)
|
||||||
|
.observeSyncLogs(_selectedAccountId!)
|
||||||
|
.first;
|
||||||
|
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
for (final file in _attachments) {
|
||||||
|
final multipartFile = await http.MultipartFile.fromPath(
|
||||||
|
'attachments[]',
|
||||||
|
file.path!,
|
||||||
|
filename: file.name,
|
||||||
|
);
|
||||||
|
request.files.add(multipartFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamedResponse = await client.send(request);
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (response.statusCode == 201) {
|
||||||
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final reportId = resData['id'] as String;
|
||||||
|
_showSuccessDialog(reportId);
|
||||||
|
} else if (response.statusCode == 429) {
|
||||||
|
final retryAfter = response.headers['retry-after'] ?? '6';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
String errorMsg =
|
||||||
|
'Failed to submit report. Server returned status: ${response.statusCode}';
|
||||||
|
try {
|
||||||
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
if (resData['error'] != null) {
|
||||||
|
errorMsg = resData['error'] as String;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(errorMsg),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('An error occurred: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSuccessDialog(String reportId) {
|
||||||
|
unawaited(
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Bug Report Submitted'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: ListBody(
|
||||||
|
children: [
|
||||||
|
const Text('Thank you for helping us improve SharedInbox!'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Your Report ID is:\n$reportId',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Your report is handled confidentially and has not been posted to the public issue tracker.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(); // Dismiss dialog
|
||||||
|
context.pop(); // Go back to previous screen
|
||||||
|
},
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final totalSize = _totalAttachmentSize;
|
||||||
|
const sizeLimit = 20 * 1024 * 1024;
|
||||||
|
final approachingLimit = totalSize > 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Report a Bug'),
|
||||||
|
),
|
||||||
|
body: _loadingEmail
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
children: [
|
||||||
|
// Confidentiality info card
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.secondaryContainer
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color:
|
||||||
|
theme.colorScheme.secondary.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
color: theme.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Your report is handled confidentially and will not be posted to the public issue tracker.',
|
||||||
|
style: TextStyle(height: 1.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Description Text Field
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 8,
|
||||||
|
minLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'What went wrong?',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText:
|
||||||
|
'Please describe the problem and how to reproduce it.',
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Please enter a description.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Email info chip if email is attached
|
||||||
|
if (_attachedEmail != null) ...[
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.email_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'The current email metadata will be attached automatically.',
|
||||||
|
style: TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Attachments Section
|
||||||
|
Text(
|
||||||
|
'Attachments',
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _submitting ? null : _pickAttachments,
|
||||||
|
icon: const Icon(Icons.add_a_photo_outlined),
|
||||||
|
label: const Text('Add screenshots'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Screenshots help us understand the problem faster.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_attachments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _attachments.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = _attachments[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8.0),
|
||||||
|
child: InputChip(
|
||||||
|
label: Text(
|
||||||
|
'${file.name} (${_formatSize(file.size)})',
|
||||||
|
),
|
||||||
|
onDeleted: _submitting
|
||||||
|
? null
|
||||||
|
: () => _removeAttachment(index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: totalSize > sizeLimit
|
||||||
|
? Colors.red
|
||||||
|
: approachingLimit
|
||||||
|
? Colors.orange
|
||||||
|
: Colors.grey,
|
||||||
|
fontWeight: approachingLimit
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (totalSize > sizeLimit) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Email opt-in
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Include my email for follow-up'),
|
||||||
|
value: _includeEmail,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
setState(() => _includeEmail = val ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
if (_includeEmail) ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Contact Email Address',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (value) {
|
||||||
|
if (_includeEmail &&
|
||||||
|
(value == null || value.trim().isEmpty)) {
|
||||||
|
return 'Please enter an email address.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Sync log opt-in
|
||||||
|
if (_selectedAccountId != null) ...[
|
||||||
|
CheckboxListTile(
|
||||||
|
title: const Text('Include recent sync log'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Helps diagnose connection and protocol issues.',
|
||||||
|
),
|
||||||
|
value: _includeSyncLog,
|
||||||
|
onChanged: _submitting
|
||||||
|
? null
|
||||||
|
: (val) {
|
||||||
|
setState(() => _includeSyncLog = val ?? false);
|
||||||
|
},
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
|
||||||
|
// System info section
|
||||||
|
FutureBuilder<PackageInfo>(
|
||||||
|
future: _packageInfoFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final imapCount = _accounts
|
||||||
|
.where((a) => a.type == AccountType.imap)
|
||||||
|
.length;
|
||||||
|
final jmapCount = _accounts
|
||||||
|
.where((a) => a.type == AccountType.jmap)
|
||||||
|
.length;
|
||||||
|
final aboutMd = buildAboutMarkdown(
|
||||||
|
context: context,
|
||||||
|
pkg: snapshot.data,
|
||||||
|
imapCount: imapCount,
|
||||||
|
jmapCount: jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: ExpansionTile(
|
||||||
|
title: const Text(
|
||||||
|
'System Info (attached automatically)',
|
||||||
|
style: TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: MarkdownBody(data: aboutMd),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Submit Button
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _submitting ? null : _submitReport,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||||
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Send Bug Report',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,90 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class ChangeLogScreen extends StatelessWidget {
|
class ChangeLogScreen extends ConsumerWidget {
|
||||||
const ChangeLogScreen({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final installedVersions = ref.watch(installedVersionsProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('ChangeLog')),
|
appBar: AppBar(title: const Text('ChangeLog')),
|
||||||
body: FutureBuilder<String>(
|
body: FutureBuilder<String>(
|
||||||
future:
|
future:
|
||||||
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
DefaultAssetBundle.of(context).loadString('assets/changelog.txt'),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting ||
|
||||||
|
installedVersions.isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
@@ -23,9 +93,12 @@ class ChangeLogScreen extends StatelessWidget {
|
|||||||
child: Text('Error loading changelog: ${snapshot.error}'),
|
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(
|
return Markdown(
|
||||||
data: content,
|
data: annotated,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
||||||
|
|
||||||
|
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||||
|
const CombinedInboxScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<CombinedInboxScreen> createState() =>
|
||||||
|
_CombinedInboxScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||||
|
static const _pageSize = 50;
|
||||||
|
int _limit = _pageSize;
|
||||||
|
|
||||||
|
// Thread-level selection (key = threadId).
|
||||||
|
final Set<String> _selectedThreadIds = {};
|
||||||
|
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
||||||
|
List<EmailThread> _currentThreads = [];
|
||||||
|
|
||||||
|
bool get _selecting => _selectedThreadIds.isNotEmpty;
|
||||||
|
|
||||||
|
void _toggleThreadSelection(EmailThread thread) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedThreadIds.contains(thread.threadId)) {
|
||||||
|
_selectedThreadIds.remove(thread.threadId);
|
||||||
|
} else {
|
||||||
|
_selectedThreadIds.add(thread.threadId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() => setState(() => _selectedThreadIds.clear());
|
||||||
|
|
||||||
|
void _selectAll() {
|
||||||
|
setState(
|
||||||
|
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accountsAsync = ref.watch(allAccountsProvider);
|
||||||
|
|
||||||
|
return accountsAsync.when(
|
||||||
|
loading: () => const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (e, _) => Scaffold(
|
||||||
|
body: Center(child: Text('Error: $e')),
|
||||||
|
),
|
||||||
|
data: (accounts) {
|
||||||
|
if (accounts.isEmpty) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted) context.go('/accounts');
|
||||||
|
});
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final accountNames = {
|
||||||
|
for (final a in accounts) a.id: a.displayName,
|
||||||
|
};
|
||||||
|
final showAccount = accounts.length > 1;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: _buildAppBar(accounts),
|
||||||
|
drawer: _selecting ? null : _buildDrawer(context, accounts),
|
||||||
|
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||||
|
body: _buildBody(accountNames, showAccount),
|
||||||
|
floatingActionButton: _selecting
|
||||||
|
? null
|
||||||
|
: FloatingActionButton(
|
||||||
|
onPressed: () => context.push('/compose'),
|
||||||
|
child: const Icon(Icons.edit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
||||||
|
if (_selecting) {
|
||||||
|
return AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
),
|
||||||
|
title: Text('${_selectedThreadIds.length} selected'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.select_all),
|
||||||
|
tooltip: 'Select all',
|
||||||
|
onPressed: _selectAll,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppBar(
|
||||||
|
title: const Text('Combined Inbox'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
tooltip: 'Search',
|
||||||
|
onPressed: () => context.push('/search'),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.sync),
|
||||||
|
tooltip: 'Sync all',
|
||||||
|
onPressed: () {
|
||||||
|
for (final a in accounts) {
|
||||||
|
ref.read(syncManagerProvider).syncNow(a.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _selectionBottomBar() {
|
||||||
|
return BottomAppBar(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.archive),
|
||||||
|
tooltip: 'Archive',
|
||||||
|
onPressed: _batchArchive,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: _batchDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||||
|
return Drawer(
|
||||||
|
child: ListView(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
children: [
|
||||||
|
const DrawerHeader(
|
||||||
|
decoration: BoxDecoration(color: Colors.blueGrey),
|
||||||
|
child: Text(
|
||||||
|
'sharedinbox.de',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.manage_accounts),
|
||||||
|
title: const Text('Accounts'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.go('/accounts');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_add),
|
||||||
|
title: const Text('Add account'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/add'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
for (final account in accounts)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.inbox),
|
||||||
|
title: Text(account.displayName),
|
||||||
|
subtitle: Text(account.email),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/${account.id}/mailboxes'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Preferences'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/preferences'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.history),
|
||||||
|
title: const Text('Undo Log'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/undo-log'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('About'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
unawaited(context.push('/accounts/about'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(Map<String, String> accountNames, bool showAccount) {
|
||||||
|
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
final accounts = ref.read(allAccountsProvider).value ?? [];
|
||||||
|
for (final a in accounts) {
|
||||||
|
ref.read(syncManagerProvider).syncNow(a.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: StreamBuilder<List<EmailThread>>(
|
||||||
|
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
if (!snap.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final threads = snap.data!;
|
||||||
|
_currentThreads = threads;
|
||||||
|
if (threads.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 300,
|
||||||
|
child: Center(child: Text('No emails')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildThreadList(threads, accountNames, showAccount);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThreadList(
|
||||||
|
List<EmailThread> threads,
|
||||||
|
Map<String, String> accountNames,
|
||||||
|
bool showAccount,
|
||||||
|
) {
|
||||||
|
final hasMore = threads.length == _limit;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i == threads.length) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () => setState(() => _limit += _pageSize),
|
||||||
|
child: const Text('Load more'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final t = threads[i];
|
||||||
|
return EmailThreadTile(
|
||||||
|
thread: t,
|
||||||
|
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||||
|
isSelecting: _selecting,
|
||||||
|
showAccount: showAccount,
|
||||||
|
accountName: accountNames[t.accountId],
|
||||||
|
onTap: _selecting
|
||||||
|
? () => _toggleThreadSelection(t)
|
||||||
|
: t.messageCount > 1
|
||||||
|
? () => context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||||
|
)
|
||||||
|
: () => context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||||
|
),
|
||||||
|
onLongPress: () => _toggleThreadSelection(t),
|
||||||
|
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSwipeDismissed(
|
||||||
|
EmailThread t,
|
||||||
|
DismissDirection direction,
|
||||||
|
) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (direction == DismissDirection.startToEnd) {
|
||||||
|
final archive = await ref
|
||||||
|
.read(mailboxRepositoryProvider)
|
||||||
|
.findMailboxByRole(t.accountId, 'archive');
|
||||||
|
if (!mounted || archive == null) return;
|
||||||
|
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _batchArchive() async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
|
|
||||||
|
// Group selected threads by accountId so we look up each account's archive once.
|
||||||
|
final byAccount = <String, List<EmailThread>>{};
|
||||||
|
for (final t in _currentThreads) {
|
||||||
|
if (!_selectedThreadIds.contains(t.threadId)) continue;
|
||||||
|
(byAccount[t.accountId] ??= []).add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
|
||||||
|
for (final entry in byAccount.entries) {
|
||||||
|
final accountId = entry.key;
|
||||||
|
final threads = entry.value;
|
||||||
|
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
|
||||||
|
if (!mounted || archive == null) continue;
|
||||||
|
|
||||||
|
for (final t in threads) {
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _batchDelete() async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
final selectedThreads = _currentThreads
|
||||||
|
.where((t) => _selectedThreadIds.contains(t.threadId))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_clearSelection();
|
||||||
|
|
||||||
|
for (final t in selectedThreads) {
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,9 +194,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
await OpenFilex.open(path);
|
await OpenFilex.open(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Failed to open file: $e'),
|
content: Text('Failed to open file: $e'),
|
||||||
@@ -213,9 +211,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Select an account first'),
|
content: Text('Select an account first'),
|
||||||
@@ -255,9 +251,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
content: Text('Send failed: $e'),
|
content: Text('Send failed: $e'),
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
theme: ThemeData(splashFactory: NoSplash.splashFactory),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Something went wrong'),
|
title: const Text('Something went wrong'),
|
||||||
@@ -81,9 +82,9 @@ class CrashScreen extends StatelessWidget {
|
|||||||
builder: (context, snapshot) => Text(
|
builder: (context, snapshot) => Text(
|
||||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(
|
||||||
color: Colors.grey[600],
|
context,
|
||||||
),
|
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ Future<Mailbox?> resolveMailboxByRole(
|
|||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final m
|
for (final m in mailboxes.where(
|
||||||
in mailboxes.where((m) => m.path != currentMailboxPath))
|
(m) => m.path != currentMailboxPath,
|
||||||
|
))
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.folder_outlined),
|
leading: const Icon(Icons.folder_outlined),
|
||||||
title: Text(m.name),
|
title: Text(m.name),
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/note.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/utils/format_utils.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/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -36,6 +39,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
bool _isFlagged = false;
|
bool _isFlagged = false;
|
||||||
bool _loadRemoteImages = false;
|
bool _loadRemoteImages = false;
|
||||||
final Set<String> _downloading = {};
|
final Set<String> _downloading = {};
|
||||||
|
bool _notesSynced = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -49,6 +53,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (email != null && mounted) {
|
if (email != null && mounted) {
|
||||||
setState(() => _isFlagged = email.isFlagged);
|
setState(() => _isFlagged = email.isFlagged);
|
||||||
}
|
}
|
||||||
|
if (!_notesSynced && email?.messageId != null) {
|
||||||
|
_notesSynced = true;
|
||||||
|
unawaited(
|
||||||
|
ref.read(noteRepositoryProvider).syncNotes(
|
||||||
|
email!.accountId,
|
||||||
|
email.messageId!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -61,10 +74,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: !isMobile,
|
automaticallyImplyLeading: !isMobile,
|
||||||
title: Text(
|
|
||||||
header?.subject ?? '(loading…)',
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.reply),
|
icon: const Icon(Icons.reply),
|
||||||
@@ -72,9 +81,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(
|
unawaited(_replyWithRecipientDialog(context, header, body));
|
||||||
_replyWithRecipientDialog(context, header, body),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -94,19 +101,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
final destPath = await repo.deleteEmail(widget.emailId);
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
if (header != null) {
|
if (header != null) {
|
||||||
unawaited(
|
await ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
UndoAction(
|
||||||
UndoAction(
|
id: DateTime.now().toIso8601String(),
|
||||||
id: DateTime.now().toIso8601String(),
|
accountId: header.accountId,
|
||||||
accountId: header.accountId,
|
type: UndoType.delete,
|
||||||
type: UndoType.delete,
|
emailIds: [widget.emailId],
|
||||||
emailIds: [widget.emailId],
|
sourceMailboxPath: header.mailboxPath,
|
||||||
sourceMailboxPath: header.mailboxPath,
|
destinationMailboxPath: destPath,
|
||||||
destinationMailboxPath: destPath,
|
originalEmails: [header],
|
||||||
originalEmails: [header],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||||
@@ -124,24 +129,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (mounted) setState(() => _isFlagged = next);
|
if (mounted) setState(() => _isFlagged = next);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.report_outlined),
|
||||||
|
tooltip: 'Mark as spam',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_markAsSpam(context, header));
|
||||||
|
},
|
||||||
|
),
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
||||||
value: 'forward',
|
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
||||||
child: Text('Forward'),
|
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
||||||
),
|
|
||||||
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(
|
const PopupMenuItem(
|
||||||
value: 'mark_unread',
|
value: 'mark_unread',
|
||||||
child: Text('Mark as unread'),
|
child: Text('Mark as unread'),
|
||||||
@@ -155,9 +156,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
value: 'structure',
|
value: 'structure',
|
||||||
child: Text('Show Mail Structure'),
|
child: Text('Show Mail Structure'),
|
||||||
),
|
),
|
||||||
|
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
||||||
|
const PopupMenuDivider(),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'rfc',
|
value: 'bug_report',
|
||||||
child: Text('Show Raw Email'),
|
child: Text('Report a Bug'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
@@ -167,8 +170,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(_moveTo(context, header));
|
unawaited(_moveTo(context, header));
|
||||||
} else if (value == 'snooze' && header != null) {
|
} else if (value == 'snooze' && header != null) {
|
||||||
unawaited(_snooze(context, header));
|
unawaited(_snooze(context, header));
|
||||||
} else if (value == 'spam' && header != null) {
|
|
||||||
unawaited(_markAsSpam(context, header));
|
|
||||||
} else if (value == 'mark_unread') {
|
} else if (value == 'mark_unread') {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
@@ -179,6 +180,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
_showStructure(context, body);
|
_showStructure(context, body);
|
||||||
} else if (value == 'rfc') {
|
} else if (value == 'rfc') {
|
||||||
unawaited(_showRaw(context, header));
|
unawaited(_showRaw(context, header));
|
||||||
|
} else if (value == 'bug_report') {
|
||||||
|
unawaited(
|
||||||
|
context.push('/bug-report?emailId=${widget.emailId}'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -187,19 +192,35 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
body: detail.when(
|
body: detail.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Error: $e')),
|
error: (e, _) => Center(child: Text('Error: $e')),
|
||||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
data: (d) {
|
||||||
|
final trusted =
|
||||||
|
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||||
|
return _buildBody(context, d.$1, d.$2, trusted);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
|
Widget _buildBody(
|
||||||
|
BuildContext ctx,
|
||||||
|
Email? header,
|
||||||
|
EmailBody body,
|
||||||
|
List<String> trustedSenders,
|
||||||
|
) {
|
||||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||||
|
final senderEmail = header?.from.isNotEmpty == true
|
||||||
|
? header!.from.first.email.toLowerCase()
|
||||||
|
: null;
|
||||||
|
final isTrusted = senderEmail != null &&
|
||||||
|
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||||
|
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
||||||
if (hasHtml) ...[
|
if (hasHtml) ...[
|
||||||
if (!_loadRemoteImages)
|
if (!effectiveLoadImages)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -207,19 +228,50 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.image_outlined, size: 18),
|
icon: const Icon(Icons.image_outlined, size: 18),
|
||||||
label: const Text('Load remote images'),
|
label: const Text('Load remote images'),
|
||||||
onPressed: () => setState(() => _loadRemoteImages = true),
|
onPressed: () {
|
||||||
|
setState(() => _loadRemoteImages = true);
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(senderEmail),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
content: const Text(
|
||||||
|
'Images will be loaded automatically for this sender.',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View',
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(
|
||||||
|
context.push(
|
||||||
|
'/accounts/trusted-senders',
|
||||||
|
extra: senderEmail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
SecureEmailWebView(
|
||||||
htmlBody: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
loadRemoteImages: effectiveLoadImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
body.textBody ?? '',
|
body.textBody ?? '',
|
||||||
style: Theme.of(ctx).textTheme.bodyMedium,
|
style: Theme.of(ctx).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
if (header?.messageId != null) _buildNotesSection(ctx, header!),
|
||||||
if (body.attachments.isNotEmpty) ...[
|
if (body.attachments.isNotEmpty) ...[
|
||||||
const Divider(),
|
const Divider(),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -264,8 +316,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
.observeThreads(header.accountId, header.mailboxPath)
|
.observeThreads(header.accountId, header.mailboxPath)
|
||||||
.first;
|
.first;
|
||||||
|
|
||||||
final currentIndex =
|
final currentIndex = threads.indexWhere(
|
||||||
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
|
(t) => t.emailIds.contains(widget.emailId),
|
||||||
|
);
|
||||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
||||||
return threads[currentIndex + 1].latestEmailId;
|
return threads[currentIndex + 1].latestEmailId;
|
||||||
}
|
}
|
||||||
@@ -302,6 +355,114 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNotesSection(BuildContext ctx, Email header) {
|
||||||
|
final messageId = header.messageId!;
|
||||||
|
final notes = ref.watch(notesProvider((header.accountId, messageId)));
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Divider(),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text(
|
||||||
|
'Notes',
|
||||||
|
style: Theme.of(ctx).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.add, size: 16),
|
||||||
|
label: const Text('Add'),
|
||||||
|
onPressed: () => unawaited(_addNoteDialog(ctx, header)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
notes.when(
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (e, _) => Text('Error loading notes: $e'),
|
||||||
|
data: (list) {
|
||||||
|
if (list.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 4),
|
||||||
|
child: Text(
|
||||||
|
'No notes yet.',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final note in list) _buildNoteRow(ctx, note),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNoteRow(BuildContext ctx, EmailNote note) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(note.noteText),
|
||||||
|
subtitle: Text(
|
||||||
|
DateFormat('MMM d, HH:mm').format(note.createdAt),
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 20),
|
||||||
|
tooltip: 'Delete note',
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(ref.read(noteRepositoryProvider).deleteNote(note.id));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addNoteDialog(BuildContext context, Email header) async {
|
||||||
|
final messageId = header.messageId;
|
||||||
|
if (messageId == null) return;
|
||||||
|
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Add note'),
|
||||||
|
content: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(hintText: 'Type a note…'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final text = ctrl.text.trim();
|
||||||
|
ctrl.dispose();
|
||||||
|
if (confirmed != true || text.isEmpty) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
await ref.read(noteRepositoryProvider).addNote(
|
||||||
|
header.accountId,
|
||||||
|
messageId,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext ctx, Email email) {
|
Widget _buildHeader(BuildContext ctx, Email email) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -520,14 +681,47 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
extra: {
|
extra: {'prefillSubject': subject, 'prefillBody': quoted},
|
||||||
'prefillSubject': subject,
|
|
||||||
'prefillBody': quoted,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String?> _promptNewFolderName(BuildContext context) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
try {
|
||||||
|
return await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Create new folder'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(hintText: 'Folder name'),
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
onSubmitted: (value) {
|
||||||
|
if (value.trim().isNotEmpty) Navigator.pop(ctx, value.trim());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final name = controller.text.trim();
|
||||||
|
if (name.isNotEmpty) Navigator.pop(ctx, name);
|
||||||
|
},
|
||||||
|
child: const Text('Create'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _moveTo(BuildContext context, Email header) async {
|
Future<void> _moveTo(BuildContext context, Email header) async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||||
|
|
||||||
@@ -541,6 +735,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
const createNewSentinel = '__create_new__';
|
||||||
|
|
||||||
final chosen = await showModalBottomSheet<String>(
|
final chosen = await showModalBottomSheet<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => ListView(
|
builder: (ctx) => ListView(
|
||||||
@@ -558,13 +754,28 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
title: Text(m.name),
|
title: Text(m.name),
|
||||||
onTap: () => Navigator.pop(ctx, m.path),
|
onTap: () => Navigator.pop(ctx, m.path),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.create_new_folder_outlined),
|
||||||
|
title: const Text('Create new folder…'),
|
||||||
|
onTap: () => Navigator.pop(ctx, createNewSentinel),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chosen == null || !context.mounted) return;
|
if (chosen == null || !context.mounted) return;
|
||||||
|
|
||||||
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
String destination = chosen;
|
||||||
|
if (chosen == createNewSentinel) {
|
||||||
|
final name = await _promptNewFolderName(context);
|
||||||
|
if (name == null || !context.mounted) return;
|
||||||
|
final mailbox = await mailboxRepo.createMailbox(header.accountId, name);
|
||||||
|
destination = mailbox.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.moveEmail(widget.emailId, destination);
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
@@ -574,7 +785,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
type: UndoType.move,
|
type: UndoType.move,
|
||||||
emailIds: [widget.emailId],
|
emailIds: [widget.emailId],
|
||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
destinationMailboxPath: chosen,
|
destinationMailboxPath: destination,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -625,9 +836,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
.fetchRawRfc822(widget.emailId);
|
.fetchRawRfc822(widget.emailId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,47 +952,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => EmailHeadersDialog(headers: body.headers),
|
||||||
title: const Text('Mail Headers'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: body.headers.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final header = body.headers[i];
|
|
||||||
return Container(
|
|
||||||
color: i.isEven
|
|
||||||
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
|
||||||
: Theme.of(ctx).colorScheme.surface,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 4,
|
|
||||||
horizontal: 8,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: SelectableText(
|
|
||||||
header.name,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(flex: 2, child: SelectableText(header.value)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -792,9 +963,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text(
|
content: Text('Structure not available. Try re-syncing the email.'),
|
||||||
'Structure not available. Try re-syncing the email.',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -806,12 +975,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => Dialog.fullscreen(
|
||||||
title: const Text('Mail Structure'),
|
child: Scaffold(
|
||||||
content: SizedBox(
|
appBar: AppBar(
|
||||||
width: double.maxFinite,
|
title: const Text('Mail Structure'),
|
||||||
child: ListView.builder(
|
leading: const CloseButton(),
|
||||||
shrinkWrap: true,
|
),
|
||||||
|
body: ListView.builder(
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
@@ -840,12 +1010,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -903,14 +1067,8 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
|||||||
SegmentedButton<_Placement>(
|
SegmentedButton<_Placement>(
|
||||||
showSelectedIcon: false,
|
showSelectedIcon: false,
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(
|
ButtonSegment(value: _Placement.to, label: Text('To')),
|
||||||
value: _Placement.to,
|
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
||||||
label: Text('To'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
|
||||||
value: _Placement.cc,
|
|
||||||
label: Text('Cc'),
|
|
||||||
),
|
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: _Placement.skip,
|
value: _Placement.skip,
|
||||||
label: Text('Skip'),
|
label: Text('Skip'),
|
||||||
|
|||||||
@@ -12,19 +12,10 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
|
|||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||||
final _dateFmt = DateFormat('MMM d');
|
|
||||||
// Cache formatted dates by local calendar day so DateFormat.format is called
|
|
||||||
// at most once per unique date rather than once per list item per rebuild.
|
|
||||||
final _formattedDates = <int, String>{};
|
|
||||||
|
|
||||||
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
|
||||||
|
|
||||||
String _fmtDate(DateTime dt) =>
|
|
||||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
|
||||||
|
|
||||||
class EmailListScreen extends ConsumerStatefulWidget {
|
class EmailListScreen extends ConsumerStatefulWidget {
|
||||||
const EmailListScreen({
|
const EmailListScreen({
|
||||||
@@ -59,6 +50,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// Pagination: number of threads currently requested from the DB.
|
// Pagination: number of threads currently requested from the DB.
|
||||||
static const _pageSize = 50;
|
static const _pageSize = 50;
|
||||||
int _limit = _pageSize;
|
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 =>
|
bool get _selecting =>
|
||||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = null;
|
_searchResults = null;
|
||||||
_searchLoading = false;
|
_searchLoading = false;
|
||||||
|
_lastSettledQuery = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -126,18 +127,35 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runSearch(String query) async {
|
Future<void> _runSearch(String query) async {
|
||||||
if (query.trim().isEmpty) {
|
final q = query.trim();
|
||||||
setState(() => _searchResults = null);
|
if (q.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = null;
|
||||||
|
_lastSettledQuery = null;
|
||||||
|
});
|
||||||
return;
|
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);
|
setState(() => _searchLoading = true);
|
||||||
try {
|
try {
|
||||||
final results = await ref
|
final results = await ref
|
||||||
.read(emailRepositoryProvider)
|
.read(emailRepositoryProvider)
|
||||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
.searchEmails(widget.accountId, widget.mailboxPath, q);
|
||||||
if (mounted) setState(() => _searchResults = results);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() {
|
||||||
|
_searchResults = results;
|
||||||
|
_lastSettledQuery = q;
|
||||||
|
});
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _searchLoading = false);
|
if (mounted && generation == _searchGeneration) {
|
||||||
|
setState(() => _searchLoading = false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +278,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: _onSearchChanged,
|
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,
|
textInputAction: TextInputAction.search,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -381,11 +406,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
return MaterialBanner(
|
return MaterialBanner(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
content: Text(
|
content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
error,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.sync_problem,
|
Icons.sync_problem,
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
@@ -399,9 +420,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.push(
|
onPressed: () =>
|
||||||
'/accounts/${widget.accountId}/sync-log',
|
context.push('/accounts/${widget.accountId}/sync-log'),
|
||||||
),
|
|
||||||
child: const Text('View log'),
|
child: const Text('View log'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -555,8 +575,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
if (wasSearching && mounted) {
|
if (wasSearching && mounted) {
|
||||||
// Filter deleted emails out of the local results immediately.
|
// Filter deleted emails out of the local results immediately.
|
||||||
// Calling searchEmails here would hit the IMAP server, which still has
|
// Calling searchEmails here would still return deleted rows because the
|
||||||
// the emails because the delete is only enqueued — not yet applied.
|
// delete is only enqueued — not yet applied to the local DB.
|
||||||
final deletedIds = ids.toSet();
|
final deletedIds = ids.toSet();
|
||||||
final remaining = (_searchResults ?? [])
|
final remaining = (_searchResults ?? [])
|
||||||
.where((e) => !deletedIds.contains(e.id))
|
.where((e) => !deletedIds.contains(e.id))
|
||||||
@@ -693,177 +713,93 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final t = threads[i];
|
final t = threads[i];
|
||||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
return EmailThreadTile(
|
||||||
final senderNames =
|
thread: t,
|
||||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||||
|
isSelecting: _selecting,
|
||||||
final tile = ListTile(
|
|
||||||
leading: SizedBox(
|
|
||||||
width: 40,
|
|
||||||
child: _selecting
|
|
||||||
? Checkbox(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (_) => _toggleThreadSelection(t),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
|
||||||
color:
|
|
||||||
t.hasUnread ? Theme.of(ctx).colorScheme.primary : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
senderNames.isEmpty ? '(unknown)' : senderNames,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (t.messageCount > 1)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4),
|
|
||||||
child: Text(
|
|
||||||
'[${t.messageCount}]',
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (t.preview != null && t.preview!.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
t.preview!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
selected: isSelected,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (t.isFlagged)
|
|
||||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_fmtDate(t.latestDate),
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: _selecting
|
onTap: _selecting
|
||||||
? () => _toggleThreadSelection(t)
|
? () => _toggleThreadSelection(t)
|
||||||
: t.messageCount > 1
|
: t.messageCount > 1
|
||||||
? () => context.push(
|
? () => context.push(
|
||||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
|
'/accounts/${widget.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||||
|
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||||
)
|
)
|
||||||
: () => context.push(
|
: () => context.push(
|
||||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
'/accounts/${widget.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||||
),
|
),
|
||||||
onLongPress: () => _toggleThreadSelection(t),
|
onLongPress: () => _toggleThreadSelection(t),
|
||||||
);
|
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
||||||
|
|
||||||
// For swipe actions on threads, operate on the latest email only
|
|
||||||
// (single-email threads) or the whole thread.
|
|
||||||
return Dismissible(
|
|
||||||
key: ValueKey(t.threadId),
|
|
||||||
direction:
|
|
||||||
_selecting ? DismissDirection.none : DismissDirection.horizontal,
|
|
||||||
background: _swipeBackground(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
color: Colors.green,
|
|
||||||
icon: Icons.archive,
|
|
||||||
label: 'Archive',
|
|
||||||
),
|
|
||||||
secondaryBackground: _swipeBackground(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
color: Colors.red,
|
|
||||||
icon: Icons.delete,
|
|
||||||
label: 'Delete',
|
|
||||||
),
|
|
||||||
onDismissed: (direction) async {
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
final type = direction == DismissDirection.startToEnd
|
|
||||||
? UndoType.move
|
|
||||||
: UndoType.delete;
|
|
||||||
|
|
||||||
// Fetch full email data before moving/deleting.
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
t.emailIds.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (direction == DismissDirection.startToEnd) {
|
|
||||||
final archive = await ref
|
|
||||||
.read(mailboxRepositoryProvider)
|
|
||||||
.findMailboxByRole(widget.accountId, 'archive');
|
|
||||||
if (!mounted || archive == null) return;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
await repo.moveEmail(id, archive.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: type,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: archive.path,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
String? lastDestPath;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
lastDestPath = await repo.deleteEmail(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: type,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: lastDestPath,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(
|
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(action),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: tile,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onSwipeDismissed(
|
||||||
|
EmailThread t,
|
||||||
|
DismissDirection direction,
|
||||||
|
) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
final type = direction == DismissDirection.startToEnd
|
||||||
|
? UndoType.move
|
||||||
|
: UndoType.delete;
|
||||||
|
|
||||||
|
// Fetch full email data before moving/deleting.
|
||||||
|
final originalEmails = (await Future.wait(
|
||||||
|
t.emailIds.map((id) => repo.getEmail(id)),
|
||||||
|
))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (direction == DismissDirection.startToEnd) {
|
||||||
|
final archive = await ref
|
||||||
|
.read(mailboxRepositoryProvider)
|
||||||
|
.findMailboxByRole(widget.accountId, 'archive');
|
||||||
|
if (!mounted || archive == null) return;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: widget.accountId,
|
||||||
|
type: type,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: widget.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: widget.accountId,
|
||||||
|
type: type,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: widget.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
|
||||||
// Used for search results, which are individual emails.
|
// Used for search results, which are individual emails.
|
||||||
Widget _buildEmailList(List<Email> emails) {
|
Widget _buildEmailList(List<Email> emails) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: emails.length,
|
itemCount: emails.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final e = emails[i];
|
final e = emails[i];
|
||||||
|
final t = EmailThread.fromEmail(e);
|
||||||
final isSelected = _selectedSearchIds.contains(e.id);
|
final isSelected = _selectedSearchIds.contains(e.id);
|
||||||
return EmailTile(
|
return ThreadTile(
|
||||||
email: e,
|
thread: t,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -882,25 +818,4 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _swipeBackground({
|
|
||||||
required AlignmentGeometry alignment,
|
|
||||||
required Color color,
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
}) {
|
|
||||||
return Container(
|
|
||||||
color: color,
|
|
||||||
alignment: alignment,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: Colors.white),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(label, style: const TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,12 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
? BottomAppBar(
|
? BottomAppBar(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
Builder(
|
||||||
icon: const Icon(Icons.menu),
|
builder: (ctx) => IconButton(
|
||||||
tooltip: 'Open folders',
|
icon: const Icon(Icons.menu),
|
||||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
tooltip: 'Open folders',
|
||||||
|
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
import 'package:sharedinbox/core/utils/logger.dart';
|
import 'package:sharedinbox/core/utils/logger.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||||
|
|
||||||
final _searchHistoryProvider =
|
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||||
FutureProvider.autoDispose<List<String>>((ref) async {
|
ref,
|
||||||
|
) async {
|
||||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,6 +39,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _fieldFocused = false;
|
bool _fieldFocused = false;
|
||||||
|
|
||||||
|
// Advanced (structured) search state.
|
||||||
|
bool _advancedMode = false;
|
||||||
|
FilterGroup _filterGroup = FilterGroup.empty();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -52,6 +59,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _toggleAdvanced() {
|
||||||
|
setState(() {
|
||||||
|
_advancedMode = !_advancedMode;
|
||||||
|
_results = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _onChanged(String value) {
|
void _onChanged(String value) {
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
if (value.trim().length < 3) {
|
if (value.trim().length < 3) {
|
||||||
@@ -134,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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: TextField(
|
title: _advancedMode
|
||||||
controller: _ctrl,
|
? const Text('Advanced Search')
|
||||||
focusNode: _focusNode,
|
: TextField(
|
||||||
autofocus: true,
|
controller: _ctrl,
|
||||||
decoration: const InputDecoration(
|
focusNode: _focusNode,
|
||||||
hintText: 'Search folders, addresses, emails…',
|
autofocus: true,
|
||||||
border: InputBorder.none,
|
decoration: const InputDecoration(
|
||||||
),
|
hintText: 'Search folders, addresses, emails…',
|
||||||
onChanged: _onChanged,
|
border: InputBorder.none,
|
||||||
),
|
),
|
||||||
|
onChanged: _onChanged,
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_ctrl.text.isNotEmpty)
|
if (!_advancedMode && _ctrl.text.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -157,6 +196,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
setState(() => _results = null);
|
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(),
|
body: _buildBody(),
|
||||||
@@ -164,6 +212,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
|
if (_advancedMode) return _buildAdvancedBody();
|
||||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_results == null) {
|
if (_results == null) {
|
||||||
if (_fieldFocused && _ctrl.text.isEmpty) {
|
if (_fieldFocused && _ctrl.text.isEmpty) {
|
||||||
@@ -173,7 +222,54 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
}
|
}
|
||||||
final r = _results!;
|
final r = _results!;
|
||||||
if (r.isEmpty) return const Center(child: Text('No 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(
|
return ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
if (r.mailboxes.isNotEmpty) ...[
|
if (r.mailboxes.isNotEmpty) ...[
|
||||||
const _SectionHeader('Folders'),
|
const _SectionHeader('Folders'),
|
||||||
@@ -188,9 +284,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
|||||||
if (r.emails.isNotEmpty) ...[
|
if (r.emails.isNotEmpty) ...[
|
||||||
const _SectionHeader('Messages'),
|
const _SectionHeader('Messages'),
|
||||||
for (final e in r.emails)
|
for (final e in r.emails)
|
||||||
EmailTile(
|
ThreadTile(
|
||||||
email: e,
|
thread: EmailThread.fromEmail(e),
|
||||||
showLocation: true,
|
locationLabel: '${e.accountId} • ${e.mailboxPath}',
|
||||||
onTap: () => context.push(
|
onTap: () => context.push(
|
||||||
'/accounts/${e.accountId}/mailboxes'
|
'/accounts/${e.accountId}/mailboxes'
|
||||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/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/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
|
||||||
|
|
||||||
class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
||||||
const SieveScriptEditScreen({
|
const SieveScriptEditScreen({
|
||||||
@@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
|
|||||||
_SieveScriptEditScreenState();
|
_SieveScriptEditScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
late final TextEditingController _nameController;
|
late final TextEditingController _nameController;
|
||||||
late final TextEditingController _contentController;
|
late final TextEditingController _contentController;
|
||||||
|
late final TabController _tabController;
|
||||||
|
|
||||||
bool _loadingContent = false;
|
bool _loadingContent = false;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
// Visual-editor state.
|
||||||
|
FilterGroup _filterGroup = FilterGroup.empty();
|
||||||
|
List<SieveAction> _actions = [];
|
||||||
|
bool _visualSupported = true;
|
||||||
|
int _visualLoadCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_nameController = TextEditingController(text: widget.script?.name ?? '');
|
_nameController = TextEditingController(text: widget.script?.name ?? '');
|
||||||
_contentController = TextEditingController();
|
_contentController = TextEditingController();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
_tabController.addListener(_onTabChanged);
|
||||||
if (widget.script != null) {
|
if (widget.script != null) {
|
||||||
unawaited(_loadContent());
|
unawaited(_loadContent());
|
||||||
}
|
}
|
||||||
@@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
_contentController.dispose();
|
_contentController.dispose();
|
||||||
|
_tabController
|
||||||
|
..removeListener(_onTabChanged)
|
||||||
|
..dispose();
|
||||||
super.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 {
|
Future<void> _loadContent() async {
|
||||||
setState(() => _loadingContent = true);
|
setState(() => _loadingContent = true);
|
||||||
try {
|
try {
|
||||||
@@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
.getScriptContent(widget.accountId, widget.script!.blobId);
|
.getScriptContent(widget.accountId, widget.script!.blobId);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_contentController.text = content;
|
_contentController.text = content;
|
||||||
|
_parseScriptIntoVisual();
|
||||||
setState(() => _loadingContent = false);
|
setState(() => _loadingContent = false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
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();
|
final name = _nameController.text.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
setState(() => _error = 'Name is required');
|
setState(() => _error = 'Name is required');
|
||||||
@@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(isNew ? 'New script' : 'Edit script'),
|
title: Text(isNew ? 'New script' : 'Edit script'),
|
||||||
|
bottom: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (_saving)
|
if (_saving)
|
||||||
const Padding(
|
const Padding(
|
||||||
@@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TabBarView(
|
||||||
controller: _contentController,
|
controller: _tabController,
|
||||||
decoration: const InputDecoration(
|
children: [_buildVisualTab(), _buildScriptTab()],
|
||||||
labelText: 'Script',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
alignLabelWithHint: true,
|
|
||||||
),
|
|
||||||
maxLines: null,
|
|
||||||
expands: true,
|
|
||||||
textAlignVertical: TextAlignVertical.top,
|
|
||||||
style: const TextStyle(fontFamily: 'monospace'),
|
|
||||||
enabled: !_saving,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,9 +137,7 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
||||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/models/user_preferences.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/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
@@ -113,6 +114,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final trustedSenders =
|
||||||
|
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||||
|
final senderEmail = widget.email.from.isNotEmpty
|
||||||
|
? widget.email.from.first.email.toLowerCase()
|
||||||
|
: null;
|
||||||
|
final isTrusted = senderEmail != null &&
|
||||||
|
trustedSenders.any((p) => globMatch(senderEmail, p));
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -147,13 +156,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_expanded) _buildExpandedBody(),
|
if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExpandedBody() {
|
Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -163,6 +172,17 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
FutureBuilder<EmailBody>(
|
FutureBuilder<EmailBody>(
|
||||||
future: _bodyFuture,
|
future: _bodyFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Failed to load email: ${snapshot.error}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -173,21 +193,51 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
final body = snapshot.data!;
|
final body = snapshot.data!;
|
||||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||||
|
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (hasHtml) ...[
|
if (hasHtml) ...[
|
||||||
if (!_loadRemoteImages)
|
if (!effectiveLoadImages)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.image_outlined, size: 16),
|
icon: const Icon(Icons.image_outlined, size: 16),
|
||||||
label: const Text('Load remote images'),
|
label: const Text('Load remote images'),
|
||||||
onPressed: () =>
|
onPressed: () {
|
||||||
setState(() => _loadRemoteImages = true),
|
setState(() => _loadRemoteImages = true);
|
||||||
|
if (senderEmail != null) {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.addTrustedImageSender(senderEmail),
|
||||||
|
);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
content: const Text(
|
||||||
|
'Images will be loaded automatically for this sender.',
|
||||||
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View',
|
||||||
|
onPressed: () {
|
||||||
|
if (mounted) {
|
||||||
|
unawaited(
|
||||||
|
context.push(
|
||||||
|
'/accounts/trusted-senders',
|
||||||
|
extra: senderEmail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
SecureEmailWebView(
|
SecureEmailWebView(
|
||||||
htmlBody: body.htmlBody!,
|
htmlBody: body.htmlBody!,
|
||||||
loadRemoteImages: _loadRemoteImages,
|
loadRemoteImages: effectiveLoadImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -251,47 +301,27 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
context: context,
|
// Fetch data first for IMAP undo support
|
||||||
builder: (ctx) => AlertDialog(
|
final original = await repo.getEmail(widget.email.id);
|
||||||
title: const Text('Delete email'),
|
|
||||||
content: const Text('Move this email to Trash?'),
|
final destPath = await repo.deleteEmail(widget.email.id);
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
|
||||||
child: const Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (confirmed == true) {
|
if (original != null) {
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
unawaited(
|
||||||
// Fetch data first for IMAP undo support
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
final original = await repo.getEmail(widget.email.id);
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
final destPath = await repo.deleteEmail(widget.email.id);
|
accountId: widget.email.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
if (!mounted) return;
|
emailIds: [widget.email.id],
|
||||||
if (original != null) {
|
sourceMailboxPath: widget.email.mailboxPath,
|
||||||
unawaited(
|
destinationMailboxPath: destPath,
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
originalEmails: [original],
|
||||||
UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.email.accountId,
|
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: [widget.email.id],
|
|
||||||
sourceMailboxPath: widget.email.mailboxPath,
|
|
||||||
destinationMailboxPath: destPath,
|
|
||||||
originalEmails: [original],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
|
class TrustedImageSendersScreen extends ConsumerWidget {
|
||||||
|
const TrustedImageSendersScreen({super.key, this.highlightedSender});
|
||||||
|
|
||||||
|
final String? highlightedSender;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
tooltip: 'Add address',
|
||||||
|
onPressed: () => _showAddDialog(context, ref),
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
),
|
||||||
|
body: trustedSendersAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (_, __) =>
|
||||||
|
const Center(child: Text('Error loading trusted senders')),
|
||||||
|
data: (senders) {
|
||||||
|
if (senders.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'No addresses added yet. '
|
||||||
|
'Tap + to add an address or pattern (e.g. *@example.com), '
|
||||||
|
'or tap "Load remote images" in an email to add the sender automatically.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: senders.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final sender = senders[index];
|
||||||
|
final isHighlighted = sender == highlightedSender;
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
sender,
|
||||||
|
style: isHighlighted
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Remove',
|
||||||
|
onPressed: () {
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.removeTrustedImageSender(sender),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
@@ -55,6 +56,10 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
onTap: () => context.go(
|
||||||
|
'/accounts/undo-log/${action.id}',
|
||||||
|
extra: action,
|
||||||
|
),
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
action.type == UndoType.delete
|
action.type == UndoType.delete
|
||||||
? Icons.delete_outline
|
? Icons.delete_outline
|
||||||
@@ -84,9 +89,7 @@ class _UndoActionTile extends ConsumerWidget {
|
|||||||
.read(undoServiceProvider.notifier)
|
.read(undoServiceProvider.notifier)
|
||||||
.undo(actionId: action.id);
|
.undo(actionId: action.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Action undone.'),
|
content: Text('Action undone.'),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
|
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
|
||||||
class UserPreferencesScreen extends ConsumerWidget {
|
class UserPreferencesScreen extends ConsumerWidget {
|
||||||
@@ -12,6 +14,8 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
final prefsAsync = ref.watch(userPreferencesProvider);
|
||||||
|
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
||||||
|
final trustedCount = trustedSendersAsync.value?.length ?? 0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Preferences')),
|
appBar: AppBar(title: const Text('Preferences')),
|
||||||
@@ -90,9 +94,7 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
RadioListTile<MenuPosition>(
|
RadioListTile<MenuPosition>(
|
||||||
title: Text('Top'),
|
title: Text('Top'),
|
||||||
subtitle: Text(
|
subtitle: Text('Show the back button in the top bar.'),
|
||||||
'Show the back button in the top bar.',
|
|
||||||
),
|
|
||||||
value: MenuPosition.top,
|
value: MenuPosition.top,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -122,24 +124,118 @@ class UserPreferencesScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
RadioListTile<AfterMailViewAction>(
|
RadioListTile<AfterMailViewAction>(
|
||||||
title: Text('Next message (default)'),
|
title: Text('Next message (default)'),
|
||||||
subtitle: Text(
|
subtitle: Text('Show the next message in the mailbox.'),
|
||||||
'Show the next message in the mailbox.',
|
|
||||||
),
|
|
||||||
value: AfterMailViewAction.nextMessage,
|
value: AfterMailViewAction.nextMessage,
|
||||||
),
|
),
|
||||||
RadioListTile<AfterMailViewAction>(
|
RadioListTile<AfterMailViewAction>(
|
||||||
title: Text('Return to mailbox'),
|
title: Text('Return to mailbox'),
|
||||||
subtitle: Text(
|
subtitle: Text('Return to the message list.'),
|
||||||
'Return to the message list.',
|
|
||||||
),
|
|
||||||
value: AfterMailViewAction.showMailbox,
|
value: AfterMailViewAction.showMailbox,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Offline email cache',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Pre-fetch email bodies in the background so they are available offline.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioGroup<PrefetchMode>(
|
||||||
|
groupValue: prefs.prefetchMode,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updatePrefetchMode(value),
|
||||||
|
);
|
||||||
|
unawaited(registerBodyPrefetchTask(value));
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Wi-Fi only (default)'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Pre-fetch bodies in the background when connected to Wi-Fi.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.wifiOnly,
|
||||||
|
),
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Any network'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Pre-fetch bodies on Wi-Fi and mobile data.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.always,
|
||||||
|
),
|
||||||
|
RadioListTile<PrefetchMode>(
|
||||||
|
title: Text('Disabled'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Do not pre-fetch email bodies in the background.',
|
||||||
|
),
|
||||||
|
value: PrefetchMode.disabled,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (prefs.prefetchMode != PrefetchMode.disabled) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Cache size limit:'),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
DropdownButton<int>(
|
||||||
|
value: _nearestCacheOption(prefs.bodyCacheLimitMb),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 50, child: Text('50 MB')),
|
||||||
|
DropdownMenuItem(value: 100, child: Text('100 MB')),
|
||||||
|
DropdownMenuItem(value: 200, child: Text('200 MB')),
|
||||||
|
DropdownMenuItem(value: 500, child: Text('500 MB')),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
unawaited(
|
||||||
|
ref
|
||||||
|
.read(userPreferencesRepositoryProvider)
|
||||||
|
.updateBodyCacheLimitMb(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Allowed addresses for images',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
trustedCount == 0
|
||||||
|
? 'No addresses added yet.'
|
||||||
|
: '$trustedCount address${trustedCount == 1 ? '' : 'es'}',
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => context.push('/accounts/trusted-senders'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _nearestCacheOption(int mb) {
|
||||||
|
const options = [50, 100, 200, 500];
|
||||||
|
return options.reduce(
|
||||||
|
(a, b) => (a - mb).abs() <= (b - mb).abs() ? a : b,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ String buildAboutMarkdown({
|
|||||||
final osName = _capitalize(Platform.operatingSystem);
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
final locale = Localizations.localeOf(context).toString();
|
final locale = Localizations.localeOf(context).toString();
|
||||||
final textScale =
|
final textScale = MediaQuery.of(
|
||||||
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
context,
|
||||||
|
).textScaler.scale(1.0).toStringAsFixed(1);
|
||||||
|
|
||||||
final gitCommitLine = _gitHash.isNotEmpty
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
/// Full-screen dialog for browsing email headers, organised into groups.
|
||||||
|
class EmailHeadersDialog extends StatelessWidget {
|
||||||
|
const EmailHeadersDialog({super.key, required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog.fullscreen(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Mail Headers'),
|
||||||
|
leading: const CloseButton(),
|
||||||
|
),
|
||||||
|
body: _HeadersBody(headers: headers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeadersBody extends StatelessWidget {
|
||||||
|
const _HeadersBody({required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final receivedHeaders = <EmailHeader>[];
|
||||||
|
final listHeaders = <EmailHeader>[];
|
||||||
|
final arcHeaders = <EmailHeader>[];
|
||||||
|
final otherHeaders = <EmailHeader>[];
|
||||||
|
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
|
||||||
|
final xByPrefix = <String, List<EmailHeader>>{};
|
||||||
|
|
||||||
|
for (final h in headers) {
|
||||||
|
final lower = h.name.toLowerCase();
|
||||||
|
if (lower == 'received') {
|
||||||
|
receivedHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('list-')) {
|
||||||
|
listHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('arc-')) {
|
||||||
|
arcHeaders.add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lower.startsWith('x-')) {
|
||||||
|
final parts = h.name.split('-');
|
||||||
|
// "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single".
|
||||||
|
final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name;
|
||||||
|
xByPrefix.putIfAbsent(prefix, () => []).add(h);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
otherHeaders.add(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
final sections = <Widget>[];
|
||||||
|
|
||||||
|
if (otherHeaders.isNotEmpty) {
|
||||||
|
sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders));
|
||||||
|
}
|
||||||
|
if (listHeaders.isNotEmpty) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(title: 'List- Headers', headers: listHeaders),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (receivedHeaders.isNotEmpty) {
|
||||||
|
sections.add(_ReceivedSection(headers: receivedHeaders));
|
||||||
|
}
|
||||||
|
if (arcHeaders.isNotEmpty) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(title: 'ARC- Headers', headers: arcHeaders),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X- headers at bottom, each prefix in its own collapsible group.
|
||||||
|
final sortedPrefixes = xByPrefix.keys.toList()
|
||||||
|
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||||
|
for (final prefix in sortedPrefixes) {
|
||||||
|
sections.add(
|
||||||
|
_HeadersSection(
|
||||||
|
title: '$prefix Headers',
|
||||||
|
headers: xByPrefix[prefix]!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(children: sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeadersSection extends StatelessWidget {
|
||||||
|
const _HeadersSection({required this.title, required this.headers});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ExpansionTile(
|
||||||
|
title: Text('$title (${headers.length})'),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < headers.length; i++)
|
||||||
|
_HeaderRow(header: headers[i], index: i),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Received headers section — collapsed by default; shows inter-hop delays.
|
||||||
|
class _ReceivedSection extends StatelessWidget {
|
||||||
|
const _ReceivedSection({required this.headers});
|
||||||
|
final List<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entries = _buildEntries(headers);
|
||||||
|
return ExpansionTile(
|
||||||
|
title: Text('Received (${headers.length})'),
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < entries.length; i++) ...[
|
||||||
|
_HeaderRow(header: entries[i].header, index: i),
|
||||||
|
if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<_ReceivedEntry> _buildEntries(List<EmailHeader> headers) {
|
||||||
|
final timestamps =
|
||||||
|
headers.map((h) => _parseReceivedTimestamp(h.value)).toList();
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < headers.length; i++)
|
||||||
|
_ReceivedEntry(
|
||||||
|
header: headers[i],
|
||||||
|
delay: _computeDelay(timestamps, i),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Duration? _computeDelay(List<DateTime?> timestamps, int i) {
|
||||||
|
if (i >= timestamps.length - 1) return null;
|
||||||
|
final current = timestamps[i];
|
||||||
|
final next = timestamps[i + 1];
|
||||||
|
if (current == null || next == null) return null;
|
||||||
|
final d = current.difference(next);
|
||||||
|
return d.isNegative ? Duration.zero : d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReceivedEntry {
|
||||||
|
const _ReceivedEntry({required this.header, this.delay});
|
||||||
|
final EmailHeader header;
|
||||||
|
final Duration? delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeaderRow extends StatelessWidget {
|
||||||
|
const _HeaderRow({required this.header, required this.index});
|
||||||
|
final EmailHeader header;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bg = index.isEven
|
||||||
|
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
|
: Theme.of(context).colorScheme.surface;
|
||||||
|
return Container(
|
||||||
|
color: bg,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SelectableText(
|
||||||
|
header.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(flex: 2, child: SelectableText(header.value)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DelayRow extends StatelessWidget {
|
||||||
|
const _DelayRow({required this.delay});
|
||||||
|
final Duration delay;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = _delayColor(delay);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.arrow_downward, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatDuration(delay),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: color,
|
||||||
|
fontWeight:
|
||||||
|
delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the RFC 2822 timestamp from a Received header value.
|
||||||
|
///
|
||||||
|
/// Received headers end with `; date`, e.g.:
|
||||||
|
/// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
|
||||||
|
DateTime? _parseReceivedTimestamp(String value) {
|
||||||
|
final semiIndex = value.lastIndexOf(';');
|
||||||
|
if (semiIndex < 0) return null;
|
||||||
|
var s = value.substring(semiIndex + 1).trim();
|
||||||
|
// Strip parenthesised comments like (UTC).
|
||||||
|
s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim();
|
||||||
|
// Strip leading day-of-week abbreviation like "Mon, ".
|
||||||
|
s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), '');
|
||||||
|
// Collapse runs of whitespace.
|
||||||
|
s = s.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
|
||||||
|
for (final fmt in [
|
||||||
|
DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'),
|
||||||
|
DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'),
|
||||||
|
DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'),
|
||||||
|
DateFormat('d MMM yyyy HH:mm:ss', 'en_US'),
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
return fmt.parse(s);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration d) {
|
||||||
|
if (d.inSeconds < 60) return '${d.inSeconds}s';
|
||||||
|
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s';
|
||||||
|
return '${d.inHours}h ${d.inMinutes.remainder(60)}m';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _delayColor(Duration d) {
|
||||||
|
if (d.inSeconds < 30) return Colors.green;
|
||||||
|
if (d.inSeconds < 300) return Colors.orange;
|
||||||
|
return Colors.red;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
final _formattedDates = <int, String>{};
|
||||||
|
|
||||||
|
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
||||||
|
|
||||||
|
String _fmtDate(DateTime dt) =>
|
||||||
|
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||||
|
|
||||||
|
/// A swipeable list tile for an [EmailThread].
|
||||||
|
///
|
||||||
|
/// Handles the [Dismissible] wrapper (archive left, delete right) and
|
||||||
|
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
|
||||||
|
/// line with the account name — used in the combined-inbox view.
|
||||||
|
class EmailThreadTile extends StatelessWidget {
|
||||||
|
const EmailThreadTile({
|
||||||
|
super.key,
|
||||||
|
required this.thread,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.isSelecting,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
required this.onDismissed,
|
||||||
|
this.showAccount = false,
|
||||||
|
this.accountName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EmailThread thread;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool isSelecting;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
final Future<void> Function(DismissDirection) onDismissed;
|
||||||
|
|
||||||
|
/// When true, renders an extra subtitle line with [accountName].
|
||||||
|
final bool showAccount;
|
||||||
|
final String? accountName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = thread;
|
||||||
|
final senderNames =
|
||||||
|
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||||
|
|
||||||
|
final tile = ListTile(
|
||||||
|
leading: SizedBox(
|
||||||
|
width: 40,
|
||||||
|
child: isSelecting
|
||||||
|
? Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (_) => onTap(),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||||
|
color:
|
||||||
|
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
senderNames.isEmpty ? '(unknown)' : senderNames,
|
||||||
|
style: t.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (t.messageCount > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: Text(
|
||||||
|
'[${t.messageCount}]',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.subject ?? '(no subject)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: t.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (t.preview != null && t.preview!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
t.preview!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (showAccount && accountName != null)
|
||||||
|
Text(
|
||||||
|
accountName!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
selected: isSelected,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (t.isFlagged)
|
||||||
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_fmtDate(t.latestDate),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey('${t.accountId}:${t.threadId}'),
|
||||||
|
direction:
|
||||||
|
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
|
||||||
|
background: _swipeBackground(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
color: Colors.green,
|
||||||
|
icon: Icons.archive,
|
||||||
|
label: 'Archive',
|
||||||
|
),
|
||||||
|
secondaryBackground: _swipeBackground(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
color: Colors.red,
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: 'Delete',
|
||||||
|
),
|
||||||
|
onDismissed: onDismissed,
|
||||||
|
child: tile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _swipeBackground({
|
||||||
|
required AlignmentGeometry alignment,
|
||||||
|
required Color color,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
color: color,
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label, style: const TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,12 +111,16 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _measureHeight(String _) async {
|
Future<void> _measureHeight(String _) async {
|
||||||
final result = await _controller!.runJavaScriptReturningResult(
|
try {
|
||||||
'document.documentElement.scrollHeight',
|
final result = await _controller!.runJavaScriptReturningResult(
|
||||||
);
|
'document.documentElement.scrollHeight',
|
||||||
final h = double.tryParse(result.toString());
|
);
|
||||||
if (h != null && h > 0 && mounted) {
|
final h = double.tryParse(result.toString());
|
||||||
setState(() => _height = h);
|
if (h != null && h > 0 && mounted) {
|
||||||
|
setState(() => _height = h);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// WebView not ready yet; height stays at default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,12 +191,14 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final launched =
|
final launched = await launchUrl(
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
uri,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
if (!launched && mounted) {
|
if (!launched && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Could not open: $url')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
|
||||||
|
final _dateFmt = DateFormat('MMM d');
|
||||||
|
// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls.
|
||||||
|
final _formattedDates = <int, String>{};
|
||||||
|
|
||||||
|
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
||||||
|
|
||||||
|
String _fmtDate(DateTime dt) =>
|
||||||
|
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||||
|
|
||||||
|
/// A list tile for an [EmailThread].
|
||||||
|
///
|
||||||
|
/// Used in inbox lists, combined inbox, and search result lists.
|
||||||
|
/// Pass a custom [leading] widget to support selection-mode checkboxes.
|
||||||
|
/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or
|
||||||
|
/// "accountId • mailboxPath") — useful in cross-mailbox views.
|
||||||
|
class ThreadTile extends StatelessWidget {
|
||||||
|
const ThreadTile({
|
||||||
|
super.key,
|
||||||
|
required this.thread,
|
||||||
|
required this.onTap,
|
||||||
|
this.leading,
|
||||||
|
this.selected = false,
|
||||||
|
this.onLongPress,
|
||||||
|
this.locationLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EmailThread thread;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Widget? leading;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
|
/// When non-null, appended as an extra subtitle line in primary colour.
|
||||||
|
final String? locationLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final senderNames = thread.participants.isEmpty
|
||||||
|
? '(unknown)'
|
||||||
|
: thread.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: leading ??
|
||||||
|
Icon(
|
||||||
|
thread.hasUnread ? Icons.mail : Icons.mail_outline,
|
||||||
|
color:
|
||||||
|
thread.hasUnread ? Theme.of(context).colorScheme.primary : null,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
senderNames,
|
||||||
|
style: thread.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (thread.messageCount > 1)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: Text(
|
||||||
|
'[${thread.messageCount}]',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
thread.subject ?? '(no subject)',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: thread.hasUnread
|
||||||
|
? const TextStyle(fontWeight: FontWeight.bold)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (thread.preview != null && thread.preview!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
thread.preview!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (locationLabel != null)
|
||||||
|
Text(
|
||||||
|
locationLabel!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (thread.isFlagged)
|
||||||
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_fmtDate(thread.latestDate),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
selected: selected,
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,3 +102,7 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
|||||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||||
COMPONENT Runtime)
|
COMPONENT Runtime)
|
||||||
endif()
|
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));
|
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
|
// 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.
|
// window content area (1280×800) to FlView, not the default 1×1.
|
||||||
gtk_widget_show_all(GTK_WIDGET(window));
|
gtk_widget_show_all(GTK_WIDGET(window));
|
||||||
|
|||||||
|
After Width: | Height: | Size: 78 KiB |
@@ -1,66 +0,0 @@
|
|||||||
# Next
|
|
||||||
|
|
||||||
## Introduction
|
|
||||||
|
|
||||||
Continue the momentum from the safety hardening and infrastructure work.
|
|
||||||
The focus is on making the app ready for real-world use with robust error
|
|
||||||
handling and performance optimizations.
|
|
||||||
|
|
||||||
Create several small commits. Every commit should be self contained.
|
|
||||||
|
|
||||||
while working create/append to plan.log, so that the user sees what you are working on.
|
|
||||||
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
### 0. deploy-android
|
|
||||||
|
|
||||||
Make `task deploy-android` work.
|
|
||||||
|
|
||||||
### 0.5 Debug duration of deploy-android
|
|
||||||
|
|
||||||
Is there a way to make deploy-android faster?
|
|
||||||
|
|
||||||
Use `task --verbose` to see what gets done.
|
|
||||||
|
|
||||||
Maybe avoid doing things again, when nothing changed.
|
|
||||||
Taskfile has features to avoid calling things again, when the input has not changed.
|
|
||||||
|
|
||||||
### 1. Fix Android E2E Race Condition (aliceTile)
|
|
||||||
|
|
||||||
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
|
|
||||||
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
|
|
||||||
The current "double pumpUntil" fix isn't reliable enough.
|
|
||||||
Investigate if the animation state or the Drift stream propagation is the
|
|
||||||
culprit.
|
|
||||||
|
|
||||||
### 2. Implement Global Crash Screen
|
|
||||||
|
|
||||||
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
|
|
||||||
Implement a `CrashScreen` widget that shows the stack trace and a
|
|
||||||
"Copy to Clipboard" button for user reporting.
|
|
||||||
|
|
||||||
### 3. Database-Backed Threading
|
|
||||||
|
|
||||||
Currently, emails are grouped into threads in-memory in the repository.
|
|
||||||
Refactor to store thread relationships in the local SQLite database.
|
|
||||||
This is necessary for performance on mailboxes with thousands of messages.
|
|
||||||
|
|
||||||
### 4. Implement Undo for Bulk Actions
|
|
||||||
|
|
||||||
Add a global "Undo" snackbar after deleting or moving emails.
|
|
||||||
The system needs to handle the three sync states:
|
|
||||||
- Queued (easy to undo)
|
|
||||||
- In-progress (cancel network call)
|
|
||||||
- Finished (requires a reverse move/un-delete)
|
|
||||||
|
|
||||||
### 5. Transition to Real Account Testing
|
|
||||||
|
|
||||||
Prepare the integration tests to run against a real test account
|
|
||||||
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
|
|
||||||
This verifies the app against real-world network latency and RFC edge cases.
|
|
||||||
|
|
||||||
### 6. Coverage Gate Maintenance
|
|
||||||
|
|
||||||
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
|
|
||||||
Add a test to ensure the exclusion list doesn't contain files that no longer
|
|
||||||
exist ("ghost paths").
|
|
||||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -5,18 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
|
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "93.0.0"
|
version: "99.0.0"
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
|
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.1"
|
version: "12.1.0"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -165,10 +165,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: code_assets
|
name: code_assets
|
||||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.2.1"
|
||||||
code_builder:
|
code_builder:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -237,18 +237,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2"
|
sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.7"
|
version: "3.1.8"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.14"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -349,10 +349,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "0204695694b687b167fd497da5252e9f4aaa162e8d274d6fa1e757380f2a5f46"
|
sha256: fc83774ce5bd7ce08168333b5e53dbe9090ec04eb21e7aa7cd7bac921032c934
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.0.0-beta.4"
|
version: "12.0.0-beta.5"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -371,6 +371,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -383,34 +391,42 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
|
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "21.0.0"
|
version: "22.0.0"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_linux
|
name: flutter_local_notifications_linux
|
||||||
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
|
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.0"
|
version: "8.0.1"
|
||||||
flutter_local_notifications_platform_interface:
|
flutter_local_notifications_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_platform_interface
|
name: flutter_local_notifications_platform_interface
|
||||||
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
|
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.0.0"
|
version: "12.0.0"
|
||||||
|
flutter_local_notifications_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_web
|
||||||
|
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
flutter_local_notifications_windows:
|
flutter_local_notifications_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications_windows
|
name: flutter_local_notifications_windows
|
||||||
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
|
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.1.0"
|
||||||
flutter_markdown_plus:
|
flutter_markdown_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -423,10 +439,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_plugin_android_lifecycle
|
name: flutter_plugin_android_lifecycle
|
||||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.34"
|
version: "2.0.35"
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -439,10 +455,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage
|
name: flutter_secure_storage
|
||||||
sha256: d2a6ac2df7353f5ca47eb159a5407c1dba7ec48ca0e02dc38c9ff4d29447b261
|
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.3.0"
|
version: "10.3.1"
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -518,10 +534,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: go_router
|
name: go_router
|
||||||
sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f"
|
sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.2.3"
|
version: "17.3.0"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -534,10 +550,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: hooks
|
name: hooks
|
||||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.3"
|
version: "2.0.2"
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -562,6 +578,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image
|
||||||
|
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.8.0"
|
||||||
integration_test:
|
integration_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -691,10 +715,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: native_toolchain_c
|
name: native_toolchain_c
|
||||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.17.6"
|
version: "0.19.1"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -707,10 +731,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: objective_c
|
name: objective_c
|
||||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.3.0"
|
version: "9.4.1"
|
||||||
open_filex:
|
open_filex:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -997,13 +1021,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.2"
|
version: "1.10.2"
|
||||||
sqlite3:
|
sqlite3:
|
||||||
dependency: "direct dev"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.1"
|
version: "3.3.2"
|
||||||
sqlite3_flutter_libs:
|
sqlite3_flutter_libs:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1021,7 +1045,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.44.4"
|
version: "0.44.4"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: stack_trace
|
name: stack_trace
|
||||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
@@ -1072,10 +1096,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: synchronized
|
name: synchronized
|
||||||
sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5"
|
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.0+1"
|
version: "3.4.1"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1272,10 +1296,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_android
|
name: webview_flutter_android
|
||||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.12.0"
|
version: "4.13.0"
|
||||||
webview_flutter_platform_interface:
|
webview_flutter_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1288,10 +1312,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
|
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.25.1"
|
version: "3.26.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1365,5 +1389,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.0 <4.0.0"
|
dart: ">=3.12.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.44.0"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dependencies:
|
|||||||
|
|
||||||
# Local persistence (offline-first)
|
# Local persistence (offline-first)
|
||||||
drift: ^2.20.3
|
drift: ^2.20.3
|
||||||
|
sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas)
|
||||||
sqlite3_flutter_libs: ^0.6.0+eol
|
sqlite3_flutter_libs: ^0.6.0+eol
|
||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
path: ^1.9.1
|
path: ^1.9.1
|
||||||
@@ -27,16 +28,16 @@ dependencies:
|
|||||||
flutter_riverpod: ^3.0.0
|
flutter_riverpod: ^3.0.0
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
go_router: ^17.2.3
|
go_router: ^17.3.0
|
||||||
|
|
||||||
# Secure credential storage (passwords)
|
# Secure credential storage (passwords)
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
|
||||||
# Date formatting
|
# Date formatting
|
||||||
intl: any
|
intl: ^0.20.2
|
||||||
|
|
||||||
# File picking (compose attachments) and opening downloaded attachments
|
# File picking (compose attachments) and opening downloaded attachments
|
||||||
file_picker: ^12.0.0-beta.4
|
file_picker: ^12.0.0-beta.5
|
||||||
open_filex: ^4.6.0
|
open_filex: ^4.6.0
|
||||||
mime: ^2.0.0
|
mime: ^2.0.0
|
||||||
|
|
||||||
@@ -55,9 +56,12 @@ dependencies:
|
|||||||
flutter_markdown_plus: ^1.0.7
|
flutter_markdown_plus: ^1.0.7
|
||||||
|
|
||||||
# Background sync and local notifications
|
# Background sync and local notifications
|
||||||
flutter_local_notifications: ^21.0.0
|
flutter_local_notifications: ^22.0.0
|
||||||
workmanager: ^0.9.0
|
workmanager: ^0.9.0
|
||||||
|
|
||||||
|
# Stack trace chain-to-VM conversion for FlutterError.demangleStackTrace
|
||||||
|
stack_trace: ^1.12.1
|
||||||
|
|
||||||
# App version metadata for crash reports
|
# App version metadata for crash reports
|
||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
@@ -75,9 +79,17 @@ dev_dependencies:
|
|||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
fake_async: ^1.3.1
|
fake_async: ^1.3.1
|
||||||
path_provider_platform_interface: ^2.1.2
|
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
|
url_launcher_platform_interface: ^2.3.2
|
||||||
plugin_platform_interface: ^2.1.8
|
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:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -11,6 +11,37 @@
|
|||||||
{
|
{
|
||||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
||||||
"addLabels": ["automerge"]
|
"addLabels": ["automerge"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"matchFileNames": ["ci/**"],
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^\\.fvmrc$"],
|
||||||
|
"matchStrings": ["\"flutter\":\\s*\"(?<currentValue>[^\"]+)\""],
|
||||||
|
"depNameTemplate": "ghcr.io/cirruslabs/flutter",
|
||||||
|
"datasourceTemplate": "docker",
|
||||||
|
"versioningTemplate": "semver"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^\\.forgejo/Dockerfile$"],
|
||||||
|
"matchStrings": ["DAGGER_VERSION=(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)"],
|
||||||
|
"depNameTemplate": "dagger/dagger",
|
||||||
|
"datasourceTemplate": "github-releases",
|
||||||
|
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"fileMatch": ["^DAGGER\\.md$"],
|
||||||
|
"matchStrings": ["github:dagger/nix/v(?<currentValue>[0-9]+\\.[0-9]+\\.[0-9]+)#dagger"],
|
||||||
|
"depNameTemplate": "dagger/dagger",
|
||||||
|
"datasourceTemplate": "github-releases",
|
||||||
|
"extractVersionTemplate": "^v(?<version>.*)$"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
tmp=$(mktemp /dev/shm/keystore.XXXXXX.jks)
|
||||||
|
trap "rm -f $tmp" EXIT
|
||||||
|
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$tmp"
|
||||||
|
|
||||||
|
ANDROID_KEYSTORE_PATH="$tmp" \
|
||||||
|
ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}" \
|
||||||
|
fvm flutter build appbundle --release --no-pub \
|
||||||
|
--build-number "$(date +%s)" \
|
||||||
|
--build-name "$(date +%y%m%d-%H%M)" \
|
||||||
|
--dart-define="GIT_HASH=$(git rev-parse --short HEAD)" \
|
||||||
|
| grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||