Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d47af177a | ||
|
|
f6a37eaa16 | ||
|
|
156b040b92 | ||
|
|
e6c1288afe |
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "SharedInbox Dev",
|
|
||||||
"build": {
|
|
||||||
"dockerfile": "../Dockerfile.dev",
|
|
||||||
"context": ".."
|
|
||||||
},
|
|
||||||
"workspaceFolder": "/src",
|
|
||||||
"workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind,consistency=cached",
|
|
||||||
"remoteUser": "ci"
|
|
||||||
}
|
|
||||||
@@ -4,18 +4,14 @@
|
|||||||
# 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 \
|
||||||
jq \
|
stunnel4 \
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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,39 +1,159 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
paths:
|
||||||
|
- '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:
|
||||||
concurrency:
|
paths:
|
||||||
group: ci-${{ github.ref }}
|
- 'lib/**'
|
||||||
cancel-in-progress: true
|
- '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'
|
||||||
|
|
||||||
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:
|
||||||
- name: Print runner wait time
|
- uses: actions/checkout@v4
|
||||||
|
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 }}
|
||||||
RUN_NUMBER: ${{ github.run_number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: |
|
run: |
|
||||||
runner_start=$(date +%s)
|
python3 - << 'PYEOF'
|
||||||
created=$(curl -sf --max-time 30 \
|
import os, json, urllib.request, urllib.error, sys
|
||||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
|
||||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runs?run_number=$RUN_NUMBER" \
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
| python3 -c "import sys,json;rs=json.load(sys.stdin).get('workflow_runs',[]);print(rs[0]['created'] if rs else '')" 2>/dev/null) || true
|
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||||
if [ -n "$created" ]; then
|
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||||
queued_epoch=$(date -d "$created" +%s)
|
pr_number = os.environ["PR_NUMBER"]
|
||||||
wait_seconds=$((runner_start - queued_epoch))
|
api = f"{url_base}/api/v1/repos/{repo}"
|
||||||
echo "Runner wait time: ${wait_seconds}s (queued at $created)"
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
else
|
|
||||||
echo "Runner wait time: unknown (API lookup failed)"
|
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
|
||||||
fi
|
with urllib.request.urlopen(req) as r:
|
||||||
- uses: actions/checkout@v4
|
labels = [l["name"] for l in json.loads(r.read())]
|
||||||
- name: Setup Dagger Remote Engine
|
|
||||||
env:
|
if "automerge" not in labels:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
|
||||||
run: scripts/setup_dagger_remote.sh
|
sys.exit(0)
|
||||||
- name: Run Full Check Suite
|
|
||||||
run: task check-dagger
|
body = json.dumps({"Do": "merge"}).encode()
|
||||||
|
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,26 +15,9 @@ 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: 2
|
||||||
|
|
||||||
- name: Detect Android and Linux changes
|
- name: Detect Android and Linux changes
|
||||||
id: diff
|
id: diff
|
||||||
@@ -51,62 +34,40 @@ jobs:
|
|||||||
|
|
||||||
HEAD_SHA=$(git rev-parse HEAD)
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Find the most recent successful "Build & Deploy to Play Store" task. Forgejo's API
|
# Skip if this exact commit was already successfully deployed (prevents
|
||||||
# does not expose per-run jobs (/runs/{id}/jobs returns 404), so query /actions/tasks
|
# hourly schedule from redeploying the same commit on every tick).
|
||||||
# (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/tasks?status=success&limit=100"
|
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
|
||||||
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, timeout=60) as r:
|
with urllib.request.urlopen(req) as r:
|
||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
for t in data.get("workflow_runs", []):
|
runs = [
|
||||||
if (t.get("workflow_id") == "deploy.yml"
|
r for r in data.get("workflow_runs", [])
|
||||||
and t.get("name") == "Build & Deploy to Play Store"
|
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
||||||
and t.get("status") == "success"):
|
]
|
||||||
print(t.get("head_sha") or "")
|
print(runs[0].get("commit_sha") or "")
|
||||||
sys.exit(0)
|
|
||||||
print("")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"::error::LAST_DEPLOYED_SHA lookup failed ({type(e).__name__}: {e})")
|
print(f"API check failed: {e}", file=sys.stderr)
|
||||||
print("")
|
print("")
|
||||||
PYEOF
|
PYEOF
|
||||||
)
|
)
|
||||||
|
|
||||||
if [ -z "$LAST_DEPLOYED_SHA" ]; then
|
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||||
echo "::warning::Could not determine last successfully deployed SHA — deploying all targets as a precaution"
|
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
|
||||||
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 the HEAD commit against its parent; fall back to listing HEAD's files
|
||||||
# that deploy, not just the most recent commit. Deploy all targets when the
|
# when the parent is unavailable (initial commit, shallow clone).
|
||||||
# SHA is not in local history (shallow clone or very old deploy).
|
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||||
if git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
|
|| git show --name-only --format= HEAD)
|
||||||
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 all targets as a precaution"
|
|
||||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Changed files:"
|
echo "Changed files:"
|
||||||
echo "$CHANGED"
|
echo "$CHANGED"
|
||||||
@@ -114,25 +75,13 @@ 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)'
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -qE "$android_re"; then
|
echo "$CHANGED" | grep -qE "$android_re" \
|
||||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
||||||
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
||||||
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
|
|
||||||
|
|
||||||
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
echo "$CHANGED" | grep -qE "$linux_re" \
|
||||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||||
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||||
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
|
||||||
@@ -142,23 +91,6 @@ 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
|
||||||
@@ -167,23 +99,28 @@ 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
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
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
|
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: Verify Play Store deployment
|
- name: Cleanup TLS credentials
|
||||||
run: |
|
if: always()
|
||||||
python3 -m venv /tmp/playstore-venv
|
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||||
/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
|
||||||
@@ -193,23 +130,6 @@ 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
|
||||||
@@ -218,17 +138,31 @@ 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
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
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
|
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
|
||||||
@@ -238,23 +172,6 @@ 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
|
||||||
@@ -263,17 +180,71 @@ 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
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
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
|
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
|
||||||
|
|
||||||
|
publish-website:
|
||||||
|
name: Publish Website Build History
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build-linux, deploy-playstore, deploy-apk]
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
|
||||||
|
timeout-minutes: 60
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- 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: Generate build history and deploy website
|
||||||
|
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||||
|
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"
|
||||||
|
run: task publish-website
|
||||||
|
|
||||||
|
- 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
|
||||||
@@ -288,23 +259,6 @@ 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,23 +14,6 @@ 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
|
||||||
@@ -67,23 +50,6 @@ 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
|
||||||
@@ -92,18 +58,28 @@ 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
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
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
|
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:
|
||||||
@@ -135,7 +111,7 @@ jobs:
|
|||||||
repo_labels = api_get("/labels")
|
repo_labels = api_get("/labels")
|
||||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||||
|
|
||||||
label_ids = [label_map["loop/code"]] if "loop/code" in label_map else []
|
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
||||||
|
|
||||||
title = "Firebase Tests failed — find root cause and fix"
|
title = "Firebase Tests failed — find root cause and fix"
|
||||||
body = (
|
body = (
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Monitor Agent Loop
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 */2 * * *' # every 2 hours
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
monitor:
|
||||||
|
name: Check Agent Loop Health
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check agent loop heartbeat
|
||||||
|
run: python3 scripts/agent_loop.py monitor
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Publish Dev Container
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'Dockerfile.dev'
|
|
||||||
- '.devcontainer/devcontainer.json'
|
|
||||||
- '.forgejo/workflows/publish-dev-container.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Build & Push sharedinbox-dev
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
env:
|
|
||||||
REGISTRY: codeberg.org
|
|
||||||
IMAGE: codeberg.org/guettli/sharedinbox-dev
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to Codeberg container registry
|
|
||||||
env:
|
|
||||||
FORGEJO_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
echo "$FORGEJO_TOKEN" \
|
|
||||||
| docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
run: |
|
|
||||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
|
||||||
docker build \
|
|
||||||
-t "$IMAGE:latest" \
|
|
||||||
-t "$IMAGE:$SHORT_SHA" \
|
|
||||||
-f Dockerfile.dev \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push image
|
|
||||||
run: |
|
|
||||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
|
||||||
docker push "$IMAGE:latest"
|
|
||||||
docker push "$IMAGE:$SHORT_SHA"
|
|
||||||
@@ -18,13 +18,22 @@ 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
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
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
|
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
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
name: Update Website
|
name: Deploy Website
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
|
||||||
- cron: '0 * * * *' # every hour on the hour
|
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
@@ -12,122 +10,12 @@ 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 & Deploy 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
|
||||||
@@ -136,18 +24,31 @@ 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
|
- name: Setup Dagger Remote Engine (via stunnel)
|
||||||
env:
|
env:
|
||||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
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
|
run: scripts/setup_dagger_remote.sh
|
||||||
|
|
||||||
- name: Build & Update Website
|
- name: Build & Deploy 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
|
||||||
env:
|
env:
|
||||||
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
SSH_HOST: ${{ secrets.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,7 +10,6 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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,6 +1,5 @@
|
|||||||
# --- Flutter/Dart ---
|
# --- Flutter/Dart ---
|
||||||
coverage/
|
coverage/
|
||||||
screenshots/
|
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.dart-tool/
|
.dart-tool/
|
||||||
.packages
|
.packages
|
||||||
@@ -123,4 +122,3 @@ dagger-certs
|
|||||||
/go
|
/go
|
||||||
.last_deployed_sha
|
.last_deployed_sha
|
||||||
.fail_count
|
.fail_count
|
||||||
/*.kubeconfig
|
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ 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
|
||||||
@@ -26,13 +21,13 @@ repos:
|
|||||||
- id: forbidden-files-hook
|
- id: forbidden-files-hook
|
||||||
name: check for forbidden home-directory files
|
name: check for forbidden home-directory files
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- 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)" && dagger call --progress=plain -q -m ci --source=. check-fast'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command scripts/pre_commit_check.sh'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
@@ -47,15 +42,3 @@ 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)" && 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, 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|\.forgejo/Dockerfile|DAGGER\.md)$
|
|
||||||
|
|||||||
@@ -8,45 +8,46 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
|||||||
|
|
||||||
## Issue Label Workflow
|
## Issue Label Workflow
|
||||||
|
|
||||||
Automation is handled by [agentloop](https://github.com/guettli/agentloop) running every 5 minutes via cron. Add a label to trigger an agent:
|
We use issues, follow this label state machine:
|
||||||
|
|
||||||
| Label | Trigger | Outcome |
|
- **State/ToPlan** — Issue needs a plan written by an agent before implementation
|
||||||
|---|---|---|
|
- **State/Planned** — Plan has been posted as a comment; awaiting human review
|
||||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
- **State/Ready** — Issue is approved and ready for implementation
|
||||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue routes to `loop/merge` |
|
- **State/InProgress** — Set while an agent (or human) is actively working
|
||||||
| `loop/merge` | Merge agent rebases, waits for CI, and merges the PR | Issue moves to `loop/merge-done` |
|
- **State/Question** — Agent hit a blocker or needs clarification
|
||||||
|
|
||||||
**State machine:**
|
Full lifecycle:
|
||||||
|
|
||||||
```
|
```
|
||||||
loop/plan → loop/plan-in-process → loop/plan-done
|
State/ToPlan → State/Planned (automated: agent_loop.py runs a planning agent)
|
||||||
↘ NeedSupervisor (on failure)
|
State/Planned → State/Ready (manual: human reviews the plan and approves)
|
||||||
|
State/Ready → State/InProgress (automated: agent_loop.py before starting implementation)
|
||||||
loop/code → loop/code-in-process → loop/merge (via route)
|
State/InProgress → closed (automated: after PR is merged and CI passes)
|
||||||
↘ NeedSupervisor (on failure)
|
any state → State/Question (automated or manual: when blocked)
|
||||||
|
|
||||||
loop/merge → loop/merge-in-process → loop/merge-done
|
|
||||||
↘ NeedSupervisor (on failure)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rules:**
|
List open issues ready to pick up:
|
||||||
|
|
||||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
|
||||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
|
||||||
- The merge agent merges the PR automatically once CI is green. A human still reviews the PR before it merges if branch protection requires a review.
|
|
||||||
- Planning agents only post a comment — they do NOT write code or open PRs.
|
|
||||||
- `loop/*` labels are managed by agentloop — do not set them manually while an agent is active.
|
|
||||||
|
|
||||||
**Typical lifecycle for a new feature:**
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||||
```
|
```
|
||||||
1. Create issue
|
|
||||||
2. Add label loop/plan → agent writes plan as comment
|
Rules:
|
||||||
3. Review plan, request changes or approve
|
|
||||||
4. Add label loop/code → agent implements + opens PR + hands off to merge
|
- Never start implementation on an issue without `State/Ready`
|
||||||
5. (Optional) Review PR before it merges
|
- Planning agents only post a plan comment — they do NOT write code or open PRs
|
||||||
6. Merge agent waits for CI and merges the PR automatically
|
- After `State/Planned`, a human must review the plan and manually add `State/Ready`
|
||||||
```
|
- When working via the agent loop: label transitions are set automatically
|
||||||
|
by `agent_loop.py` — do **not** set them yourself.
|
||||||
|
- When working manually: switch to `State/InProgress` as your **first action**:
|
||||||
|
```bash
|
||||||
|
fgj issue edit <NUMBER> --remove-label "State/Ready" --add-label "State/InProgress"
|
||||||
|
```
|
||||||
|
- If blocked, replace current state label with `State/Question` and leave a comment explaining the blocker
|
||||||
|
- When done and CI is green, close the issue:
|
||||||
|
```bash
|
||||||
|
fgj issue close <NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
## 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.20.8#dagger -- engine --addr tcp://0.0.0.0:8080
|
ExecStart=/usr/bin/nix run github:dagger/nix/v0.11.4#dagger -- engine --addr tcp://0.0.0.0:8080
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -188,5 +188,3 @@ 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 -->
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# Development and Testing Container for SharedInbox
|
|
||||||
# Replaces the Nix shell environment.
|
|
||||||
FROM ghcr.io/cirruslabs/flutter:3.44.0
|
|
||||||
|
|
||||||
# Install Linux desktop build and test dependencies, Go, NodeJS, python3, and utilities
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
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 \
|
|
||||||
git \
|
|
||||||
curl \
|
|
||||||
jq \
|
|
||||||
python3-pip \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
hugo \
|
|
||||||
lcov \
|
|
||||||
rsync \
|
|
||||||
openssh-client \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Task runner
|
|
||||||
RUN curl -fsSL https://taskfile.dev/install.sh \
|
|
||||||
| sh -s -- -b /usr/local/bin v3.48.0
|
|
||||||
|
|
||||||
# Install Dagger CLI
|
|
||||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
|
||||||
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
|
||||||
|
|
||||||
# Install python packages (Play Store API clients + pre-commit)
|
|
||||||
RUN pip install --break-system-packages --no-cache-dir \
|
|
||||||
google-api-python-client \
|
|
||||||
google-auth-httplib2 \
|
|
||||||
httplib2 \
|
|
||||||
pre-commit==4.5.1
|
|
||||||
|
|
||||||
# Install acpx CLI globally
|
|
||||||
RUN npm install -g acpx@0.10.0
|
|
||||||
|
|
||||||
# Setup user "ci"
|
|
||||||
RUN useradd -m -s /bin/bash ci
|
|
||||||
USER ci
|
|
||||||
ENV HOME=/home/ci
|
|
||||||
ENV PATH=/home/ci/.pub-cache/bin:$PATH
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 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`).
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Snooze Feature Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Allow users to snooze emails, moving them to a special folder and bringing them back to the Inbox at a specified time. Snooze data must be stored in the account (IMAP/JMAP) for cross-device synchronization.
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### 1. Metadata Storage (Account Sync)
|
||||||
|
- **Keyword format:** `snz:<ISO8601_TIMESTAMP>` (e.g., `snz:2026-05-10T15:00:00Z`).
|
||||||
|
- **JMAP:** Use `keywords`.
|
||||||
|
- **IMAP:** Use User Flags (keywords).
|
||||||
|
|
||||||
|
### 2. Database Changes
|
||||||
|
- **Migration v22:**
|
||||||
|
- `Emails` table:
|
||||||
|
- `snoozedUntil` (DateTime, nullable)
|
||||||
|
- `snoozedFromMailboxPath` (String, nullable) - to remember where to move it back (usually INBOX).
|
||||||
|
- Index on `snoozedUntil`.
|
||||||
|
|
||||||
|
### 3. Repository Updates (`EmailRepository`)
|
||||||
|
- New method: `Future<void> snoozeEmail(String emailId, DateTime until)`
|
||||||
|
- Optimistically update local DB.
|
||||||
|
- Enqueue `snooze` change.
|
||||||
|
- New method: `Future<int> wakeUpEmails(String accountId)`
|
||||||
|
- Find local rows where `snoozedUntil <= now`.
|
||||||
|
- Enqueue `move` back to original mailbox.
|
||||||
|
- Clear snooze metadata.
|
||||||
|
|
||||||
|
### 4. Sync Loop Integration
|
||||||
|
- In `AccountSyncManager`, call `wakeUpEmails(accountId)` at the start of each sync cycle.
|
||||||
|
- Update IMAP/JMAP sync logic to parse `snz:` keywords and update local `snoozedUntil` / `snoozedFromMailboxPath`.
|
||||||
|
|
||||||
|
### 5. UI Implementation
|
||||||
|
- **Snooze Picker:** A dialog with options like "Later today", "Tomorrow morning", "Next week", "Custom".
|
||||||
|
- **Action:** Add "Snooze" icon to `EmailListScreen` selection bar and `EmailDetailScreen`.
|
||||||
|
- **Mailbox:** Ensure a "Snoozed" mailbox exists (create if missing).
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
1. [ ] Database migration and model updates.
|
||||||
|
2. [ ] Repository implementation for `snoozeEmail` and `wakeUpEmails`.
|
||||||
|
3. [ ] Update flush logic for IMAP and JMAP to handle `snooze` mutations.
|
||||||
|
4. [ ] Update sync logic to parse snooze keywords.
|
||||||
|
5. [ ] Integrate `wakeUpEmails` into the sync loop.
|
||||||
|
6. [ ] UI: Snooze picker dialog.
|
||||||
|
7. [ ] UI: Add Snooze action to list and detail screens.
|
||||||
|
8. [ ] Testing and validation.
|
||||||
@@ -37,8 +37,6 @@ 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:
|
||||||
@@ -58,14 +56,6 @@ 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:
|
||||||
@@ -106,19 +96,34 @@ 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 via Dagger (exports generated files back to host)
|
desc: Generate Drift DB code (run after any schema change)
|
||||||
|
deps: [_preflight, _pub-get]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
generates:
|
||||||
|
- lib/**/*.g.dart
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
analyze:
|
analyze:
|
||||||
desc: Static analysis via Dagger (dart analyze --fatal-infos)
|
desc: Static analysis (flutter analyze)
|
||||||
|
deps: [_preflight, _codegen]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
|
- pubspec.yaml
|
||||||
|
- analysis_options.yaml
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. analyze
|
- scripts/run_analyze.sh
|
||||||
|
|
||||||
format:
|
format:
|
||||||
desc: Format all Dart source files via Dagger (writes back to host)
|
desc: Format all Dart source files
|
||||||
|
deps: [_preflight]
|
||||||
|
sources:
|
||||||
|
- "**/*.dart"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
- fvm dart format lib test
|
||||||
|
|
||||||
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)
|
||||||
@@ -131,9 +136,13 @@ tasks:
|
|||||||
- scripts/check_mocks_fresh.sh
|
- scripts/check_mocks_fresh.sh
|
||||||
|
|
||||||
analyze-fix:
|
analyze-fix:
|
||||||
desc: Auto-fix lint issues via Dagger (dart fix --apply, writes back to host)
|
desc: Auto-fix lint issues with dart fix --apply
|
||||||
|
deps: [_preflight]
|
||||||
|
sources:
|
||||||
|
- lib/**/*.dart
|
||||||
|
- test/**/*.dart
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
- fvm dart fix --apply
|
||||||
|
|
||||||
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)
|
||||||
@@ -168,17 +177,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:
|
||||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-backend
|
- dagger call --progress=plain -q -m ci --source=. test-backend
|
||||||
|
|
||||||
integration-ui:
|
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:
|
||||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-integration
|
- dagger call --progress=plain -q -m ci --source=. test-integration
|
||||||
|
|
||||||
sync-reliability:
|
sync-reliability:
|
||||||
desc: Run sync reliability runner (via Dagger)
|
desc: Run sync reliability runner (via Dagger)
|
||||||
cmds:
|
cmds:
|
||||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
- dagger call --progress=plain -q -m ci --source=. test-sync-reliability
|
||||||
|
|
||||||
test-android-firebase:
|
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)
|
||||||
@@ -193,7 +202,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:
|
||||||
- timeout --kill-after=10 60 dagger call --progress=plain -q -m ci --source=. graph
|
- 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)
|
||||||
@@ -209,13 +218,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) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-linux --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && 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) && timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -225,7 +234,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:
|
||||||
- timeout --kill-after=10 600 dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
- dagger call --progress=plain -q -m ci --source=. upload-to-play-store --aab build/app/outputs/bundle/release/app-release.aab --play-store-config env:PLAY_STORE_CONFIG_JSON
|
||||||
|
|
||||||
publish-android:
|
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
|
||||||
@@ -238,7 +247,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) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
- HASH=$(git rev-parse --short HEAD) && 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
|
||||||
@@ -252,7 +261,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) && scripts/silent_on_success.sh timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. deploy-apk --ssh-key env:SSH_PRIVATE_KEY --known-hosts env:SSH_KNOWN_HOSTS --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH" --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --build-number "$(git log -1 --format=%ct HEAD)"
|
- HASH=$(git rev-parse --short HEAD) && 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
|
||||||
@@ -262,7 +271,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:
|
||||||
- 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"
|
- 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"
|
||||||
|
|
||||||
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)
|
||||||
@@ -285,11 +294,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 deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
|
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; 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
|
||||||
timeout 120 dagger query '{ engine { localCache { prune(targetSpace: "20gb") } } }' 2>/dev/null || true
|
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
|
||||||
@@ -310,16 +319,7 @@ 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
|
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||||
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:
|
||||||
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
- dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||||
|
|
||||||
integration-android:
|
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,22 +417,6 @@ 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]
|
||||||
@@ -529,10 +513,18 @@ 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
|
||||||
@@ -541,14 +533,7 @@ tasks:
|
|||||||
generates:
|
generates:
|
||||||
- build/app/outputs/bundle/release/app-release.aab
|
- build/app/outputs/bundle/release/app-release.aab
|
||||||
cmds:
|
cmds:
|
||||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build appbundle --release --no-pub --build-number $(date +%s) --build-name $(date +%y%m%d-%H%M) --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||||
|
|
||||||
deploy-android-bundle:
|
|
||||||
desc: Build release AAB and upload to Play Store internal + closed-testing tracks (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
|
||||||
@@ -575,7 +560,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, _codegen]
|
deps: [_preflight, _linux-deps-check, _pub-get]
|
||||||
cmds:
|
cmds:
|
||||||
- fvm flutter run -d linux --no-pub
|
- fvm flutter run -d linux --no-pub
|
||||||
|
|
||||||
@@ -678,9 +663,8 @@ tasks:
|
|||||||
${SSH_USER}@${SSH_HOST}:public_html/
|
${SSH_USER}@${SSH_HOST}:public_html/
|
||||||
|
|
||||||
check-fast:
|
check-fast:
|
||||||
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||||
cmds:
|
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
||||||
- 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)
|
||||||
@@ -707,16 +691,6 @@ 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
|
||||||
@@ -729,17 +703,6 @@ 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]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Agentloop is working on sialoop!
|
|
||||||
@@ -16,23 +16,19 @@ android {
|
|||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlinOptions {
|
||||||
compilerOptions {
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
if (ksPath != null) {
|
// Hardcoded alias matching t.sh
|
||||||
signingConfigs {
|
keyAlias = "upload"
|
||||||
create("release") {
|
// Use the same password for both key and keystore
|
||||||
keyAlias = "upload"
|
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
storePassword = pass
|
||||||
storePassword = pass
|
keyPassword = pass
|
||||||
keyPassword = pass
|
storeFile = file("upload-keystore.jks")
|
||||||
storeFile = file(ksPath)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +44,14 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
if (ksPath != null) {
|
// Use the signing config defined above for release builds.
|
||||||
signingConfig = signingConfigs.getByName("release")
|
// If the keystore file exists (e.g. in CI or manually placed), sign it.
|
||||||
|
signingConfig = if (signingConfigs.getByName("release").storeFile?.exists() == true) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
|
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
isShrinkResources = false
|
isShrinkResources = false
|
||||||
ndk {
|
ndk {
|
||||||
@@ -66,7 +67,7 @@ flutter {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
|
||||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
// integration_test is a dev dependency; the Flutter plugin loader adds it as
|
// integration_test is a dev dependency; the Flutter plugin loader adds it as
|
||||||
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
||||||
// references its class in all variants. Make it available for release compilation
|
// references its class in all variants. Make it available for release compilation
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 1.4 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-9.5.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-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 "9.2.1" apply false
|
id("com.android.application") version "8.11.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -2,4 +2,52 @@ module dagger/ci
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require golang.org/x/sync v0.20.0
|
require (
|
||||||
|
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.16.0
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
|
||||||
|
|
||||||
|
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
|
||||||
|
|||||||
@@ -1,2 +1,97 @@
|
|||||||
|
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,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"dagger/ci/internal/dagger"
|
"dagger/ci/internal/dagger"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -149,33 +148,16 @@ 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, error) {
|
) *Ci {
|
||||||
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/",
|
||||||
@@ -191,7 +173,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.
|
||||||
@@ -199,7 +181,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:"+m.FlutterVersion).
|
From("ghcr.io/cirruslabs/flutter:3.41.6").
|
||||||
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"}).
|
||||||
@@ -356,17 +338,7 @@ 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"}).
|
||||||
// Create .ssh with strict permissions before Dagger mounts anything there,
|
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||||
// 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")
|
||||||
}
|
}
|
||||||
@@ -388,7 +360,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\"/' /etc/stalwart/config.toml.orig > /etc/stalwart/config.toml"}).
|
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"}).
|
||||||
WithDirectory("/tmp/stalwart", dataDir).
|
WithDirectory("/tmp/stalwart", dataDir).
|
||||||
WithExposedPort(8080). // JMAP
|
WithExposedPort(8080). // JMAP
|
||||||
WithExposedPort(1430). // IMAP
|
WithExposedPort(1430). // IMAP
|
||||||
@@ -440,91 +412,33 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
|||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatWrite formats Dart files and exports the modified /src directory.
|
// CheckMocks verifies that generated mocks are up to date.
|
||||||
func (m *Ci) FormatWrite() *dagger.Directory {
|
// It snapshots the committed source (including any stale *.mocks.dart) before
|
||||||
return m.setup(m.checkSrc()).
|
// running build_runner, so git diff detects real staleness instead of always
|
||||||
WithExec([]string{"dart", "format", "lib", "test"}).
|
// comparing two freshly-generated outputs.
|
||||||
Directory("/src")
|
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||||
}
|
|
||||||
|
|
||||||
// Analyze runs static analysis with dart analyze --fatal-infos.
|
|
||||||
func (m *Ci) Analyze(ctx context.Context) (string, error) {
|
|
||||||
return m.setup(m.checkSrc()).
|
|
||||||
WithExec([]string{"dart", "analyze", "--fatal-infos"}).
|
|
||||||
Stdout(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Codegen runs build_runner and exports the modified /src directory.
|
|
||||||
func (m *Ci) Codegen() *dagger.Directory {
|
|
||||||
return m.codegenBase().Directory("/src")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnalyzeFix runs dart fix --apply and exports the modified /src directory.
|
|
||||||
func (m *Ci) AnalyzeFix() *dagger.Directory {
|
|
||||||
return m.setup(m.checkSrc()).
|
|
||||||
WithExec([]string{"dart", "fix", "--apply"}).
|
|
||||||
Directory("/src")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckFast runs fast checks (hygiene, layers, format, analyze, mocks, coverage) in parallel.
|
|
||||||
func (m *Ci) CheckFast(ctx context.Context) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var eg errgroup.Group
|
|
||||||
eg.Go(func() error {
|
|
||||||
_, err := m.CheckHygiene(ctx)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
eg.Go(func() error {
|
|
||||||
_, err := m.CheckLayers(ctx)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
eg.Go(func() error {
|
|
||||||
_, err := m.Format(ctx)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
eg.Go(func() error {
|
|
||||||
_, err := m.Analyze(ctx)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
eg.Go(func() error {
|
|
||||||
_, err := m.CheckGenerated(ctx)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
eg.Go(func() error {
|
|
||||||
_, err := m.Coverage(ctx)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err := eg.Wait(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return "All fast checks passed!", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckGenerated verifies that all generated files (*.g.dart, *.mocks.dart) are up to date.
|
|
||||||
// It 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().
|
return m.pubGetLayer().
|
||||||
WithDirectory("/committed", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||||
WithDirectory("/generated", fresh, dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
WithWorkdir("/src").
|
||||||
|
WithExec([]string{"git", "init"}).
|
||||||
|
WithExec([]string{"git", "config", "user.email", "ci@sharedinbox.de"}).
|
||||||
|
WithExec([]string{"git", "config", "user.name", "CI"}).
|
||||||
|
WithExec([]string{"git", "add", "."}).
|
||||||
|
WithExec([]string{"git", "commit", "-q", "-m", "baseline"}).
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
`stale=$(find /committed -name '*.g.dart' -o -name '*.mocks.dart' | ` +
|
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||||
`while IFS= read -r f; do rel="${f#/committed/}"; diff -q "$f" "/generated/$rel" >/dev/null 2>&1 || echo "$rel"; done); ` +
|
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||||
`if [ -n "$stale" ]; then ` +
|
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||||
`echo "ERROR: Generated files are out of date — run: dart run build_runner build"; echo "$stale"; exit 1; ` +
|
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.\""}).
|
||||||
`else echo "Generated files are up to date."; fi`}).
|
|
||||||
Stdout(ctx)
|
Stdout(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage runs unit and widget tests with coverage gate.
|
// Coverage runs unit 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 test/widget --exclude-tags golden --coverage --reporter expanded --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter test test/unit --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)
|
||||||
@@ -535,7 +449,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 --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
`flutter test --concurrency=1 --reporter expanded --no-pub 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)
|
||||||
}
|
}
|
||||||
@@ -561,77 +475,49 @@ 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()
|
||||||
|
|
||||||
// Run cheap structural checks in parallel for faster fail detection.
|
if _, err := m.CheckHygiene(ctx); err != nil {
|
||||||
var fastEg errgroup.Group
|
return "Hygiene check failed", err
|
||||||
fastEg.Go(func() error {
|
}
|
||||||
_, err := m.CheckHygiene(ctx)
|
if _, err := m.CheckLayers(ctx); err != nil {
|
||||||
return err
|
return "Layer check failed", err
|
||||||
})
|
|
||||||
fastEg.Go(func() error {
|
|
||||||
_, err := m.CheckLayers(ctx)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err := fastEg.Wait(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run format, analyze, generated-code check, and coverage in parallel —
|
checkSetup := m.setup(m.checkSrc())
|
||||||
// they all share the same setup base and have no dependencies on each other.
|
|
||||||
var analyze, mocks, coverage string
|
if _, err := checkSetup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx); err != nil {
|
||||||
var checkEg errgroup.Group
|
return "Format check failed", err
|
||||||
checkEg.Go(func() error {
|
}
|
||||||
setup := m.setup(m.checkSrc())
|
|
||||||
_, err := setup.WithExec([]string{"dart", "format", "--output=none", "--set-exit-if-changed", "lib", "test"}).Stdout(ctx)
|
analyze, err := checkSetup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
||||||
return err
|
if err != nil {
|
||||||
})
|
return analyze, err
|
||||||
checkEg.Go(func() error {
|
}
|
||||||
setup := m.setup(m.checkSrc())
|
|
||||||
var err error
|
mocks, err := m.CheckMocks(ctx)
|
||||||
analyze, err = setup.WithExec([]string{"dart", "analyze", "--fatal-infos"}).Stdout(ctx)
|
if err != nil {
|
||||||
return err
|
return mocks, err
|
||||||
})
|
}
|
||||||
checkEg.Go(func() error {
|
|
||||||
var err error
|
coverage, err := m.Coverage(ctx)
|
||||||
mocks, err = m.CheckGenerated(ctx)
|
if err != nil {
|
||||||
return err
|
return coverage, 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
|
||||||
var eg errgroup.Group
|
eg, egCtx := errgroup.WithContext(ctx)
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testBackend, e = m.TestBackend(ctx)
|
testBackend, e = m.TestBackend(egCtx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
eg.Go(func() error {
|
eg.Go(func() error {
|
||||||
var e error
|
var e error
|
||||||
testIntegration, e = m.TestIntegration(ctx)
|
testIntegration, e = m.TestIntegration(egCtx)
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
if err := eg.Wait(); err != nil {
|
if err := eg.Wait(); err != nil {
|
||||||
@@ -673,8 +559,6 @@ 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)
|
||||||
|
|
||||||
@@ -682,13 +566,9 @@ func (m *Ci) BuildWebsite(
|
|||||||
Include: []string{"website/"},
|
Include: []string{"website/"},
|
||||||
}).WithDirectory("website/content/builds", buildHistory)
|
}).WithDirectory("website/content/builds", buildHistory)
|
||||||
|
|
||||||
hugo := m.Hugo().
|
return 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")
|
||||||
}
|
}
|
||||||
@@ -700,10 +580,8 @@ 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, commitHash)
|
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||||
|
|
||||||
return m.Deployer(sshKey, knownHosts).
|
return m.Deployer(sshKey, knownHosts).
|
||||||
WithDirectory("/public", public).
|
WithDirectory("/public", public).
|
||||||
@@ -763,8 +641,7 @@ 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 > /tmp/upload-keystore.jks`}).
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/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.
|
||||||
@@ -814,14 +691,7 @@ func (m *Ci) DeployApk(
|
|||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.firebaseBase().
|
built := m.firebaseBase().
|
||||||
// `flutter build apk` spawns a Gradle daemon. When this WithExec ends the
|
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||||
// container is torn down and the daemon is killed, but its journal-cache
|
|
||||||
// lock file on the persistent gradle-cache volume keeps its dead PID — the
|
|
||||||
// next gradlew invocation then times out waiting for that lock. `gradlew
|
|
||||||
// --stop` shuts the daemon down gracefully so the lock is released before
|
|
||||||
// Dagger snapshots the layer.
|
|
||||||
WithExec([]string{"/bin/bash", "-c",
|
|
||||||
`flutter build apk --debug --no-pub && (cd android && ./gradlew --stop)`}).
|
|
||||||
WithWorkdir("/src/android").
|
WithWorkdir("/src/android").
|
||||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||||
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
||||||
@@ -903,7 +773,7 @@ func withGoCache(c *dagger.Container) *dagger.Container {
|
|||||||
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
|
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
||||||
func (m *Ci) UploadToPlayStore(
|
func (m *Ci) UploadToPlayStore(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
aab *dagger.File,
|
aab *dagger.File,
|
||||||
@@ -1004,12 +874,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 fmt.Sprintf(`# CI Pipeline Graph
|
return `# CI Pipeline Graph
|
||||||
|
|
||||||
`+"```"+`mermaid
|
` + "```" + `mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph dagger ["Dagger · Check pipeline"]
|
subgraph dagger ["Dagger · Check pipeline"]
|
||||||
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
|
toolchain["toolchain\nflutter:3.41.6 + NDK + apt + precache"]
|
||||||
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"])
|
||||||
@@ -1019,7 +889,7 @@ flowchart TD
|
|||||||
|
|
||||||
pubGet --> hygiene["CheckHygiene"]
|
pubGet --> hygiene["CheckHygiene"]
|
||||||
pubGet --> layers["CheckLayers"]
|
pubGet --> layers["CheckLayers"]
|
||||||
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
|
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
||||||
|
|
||||||
codegen --> fmt["Format"]
|
codegen --> fmt["Format"]
|
||||||
codegen --> analyze["Analyze"]
|
codegen --> analyze["Analyze"]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/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
|
||||||
|
|||||||
@@ -4,18 +4,6 @@ This file contains tasks which got implemented.
|
|||||||
|
|
||||||
Tasks get moved from next.md to done.md
|
Tasks get moved from next.md to done.md
|
||||||
|
|
||||||
## Tasks (2026-05-29)
|
|
||||||
|
|
||||||
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
|
|
||||||
all features from PR #307 (issue #299) were already merged into main via separate PRs:
|
|
||||||
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
|
|
||||||
- Configurable back button position for single mail view — merged via #299/#307 features in #300
|
|
||||||
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
|
|
||||||
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
|
|
||||||
- User preferences DB schema (v34–v36: `user_preferences` table) — in main
|
|
||||||
- PR #307 and issue #299 closed.
|
|
||||||
- Issue #315 closed.
|
|
||||||
|
|
||||||
## Tasks (2026-05-26)
|
## Tasks (2026-05-26)
|
||||||
|
|
||||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"dagger": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778107833,
|
||||||
|
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
|
||||||
|
"owner": "dagger",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "dagger",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1778737229,
|
||||||
|
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"dagger": "dagger",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
description = "SharedInbox — IMAP/SMTP Flutter client";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
dagger.url = "github:dagger/nix";
|
||||||
|
dagger.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils, dagger }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
|
||||||
|
# All Linux desktop runtime libraries needed by flutter build linux and
|
||||||
|
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
||||||
|
# it for both buildInputs and LD_LIBRARY_PATH / PKG_CONFIG_PATH.
|
||||||
|
linuxDesktopLibs = with pkgs; [
|
||||||
|
gtk3
|
||||||
|
libsecret
|
||||||
|
fontconfig
|
||||||
|
libepoxy
|
||||||
|
mesa
|
||||||
|
libGL # libglvnd — vendor-neutral GL/EGL/GLX dispatch layer
|
||||||
|
at-spi2-core
|
||||||
|
glib
|
||||||
|
pango
|
||||||
|
cairo
|
||||||
|
gdk-pixbuf
|
||||||
|
harfbuzz
|
||||||
|
# Dagger remote setup dependencies
|
||||||
|
stunnel
|
||||||
|
netcat
|
||||||
|
];
|
||||||
|
|
||||||
|
fgj = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "fgj";
|
||||||
|
version = "0.4.0";
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://codeberg.org/romaintb/fgj/releases/download/v0.4.0/fgj_linux_amd64";
|
||||||
|
sha256 = "07pia03facvvxq9i1dgl7p47ccv1iqj4drpkp45gvw26d4afkbj7";
|
||||||
|
};
|
||||||
|
dontUnpack = true;
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/bin
|
||||||
|
cp $src $out/bin/fgj
|
||||||
|
chmod +x $out/bin/fgj
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
# Dagger CLI
|
||||||
|
dagger.packages.${system}.dagger
|
||||||
|
|
||||||
|
# Go compiler — for Dagger development
|
||||||
|
go
|
||||||
|
|
||||||
|
# Java JDK — required by Gradle for Android builds
|
||||||
|
|
||||||
|
# Task runner
|
||||||
|
go-task
|
||||||
|
|
||||||
|
# Flutter version manager — needed for host builds (task build-linux, task run)
|
||||||
|
fvm
|
||||||
|
|
||||||
|
# Git hooks
|
||||||
|
pre-commit
|
||||||
|
|
||||||
|
# Linux desktop build + runtime dependencies (flutter build linux / task run)
|
||||||
|
] ++ linuxDesktopLibs ++ (with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
clang
|
||||||
|
cmake
|
||||||
|
ninja
|
||||||
|
|
||||||
|
# Local IMAP/SMTP dev server for integration tests
|
||||||
|
stalwart-mail
|
||||||
|
|
||||||
|
# Headless display for UI integration tests
|
||||||
|
xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ...
|
||||||
|
|
||||||
|
# Coverage merging (flutter test --merge-coverage requires lcov)
|
||||||
|
lcov
|
||||||
|
|
||||||
|
# Website
|
||||||
|
hugo
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
jq
|
||||||
|
sqlite
|
||||||
|
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
||||||
|
(python3.withPackages (ps: with ps; [
|
||||||
|
google-api-python-client
|
||||||
|
google-auth-httplib2
|
||||||
|
httplib2
|
||||||
|
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||||
|
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||||
|
]);
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||||
|
export IN_NIX_SHELL=1
|
||||||
|
|
||||||
|
# Disable Flutter telemetry inside dev shell
|
||||||
|
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||||
|
|
||||||
|
# Expose dev headers to cmake's FindPkgConfig.
|
||||||
|
# The nix pkg-config wrapper works in bash but cmake invokes pkg-config
|
||||||
|
# as a subprocess and needs PKG_CONFIG_PATH set explicitly.
|
||||||
|
export PKG_CONFIG_PATH="${pkgs.gtk3.dev}/lib/pkgconfig:${pkgs.glib.dev}/lib/pkgconfig:${pkgs.pango.dev}/lib/pkgconfig:${pkgs.cairo.dev}/lib/pkgconfig:${pkgs.gdk-pixbuf.dev}/lib/pkgconfig:${pkgs.at-spi2-core.dev}/lib/pkgconfig:${pkgs.harfbuzz.dev}/lib/pkgconfig:${pkgs.libsecret}/lib/pkgconfig:${pkgs.fontconfig.dev}/lib/pkgconfig:${pkgs.libepoxy}/lib/pkgconfig:$PKG_CONFIG_PATH"
|
||||||
|
|
||||||
|
# Nix ld uses --no-copy-dt-needed-entries (strict mode): transitive shared-lib
|
||||||
|
# deps are not followed automatically, so link them explicitly.
|
||||||
|
export LDFLAGS="-L${pkgs.fontconfig.lib}/lib -lfontconfig $LDFLAGS"
|
||||||
|
|
||||||
|
# Make nix-built runtime libs visible to the dynamic linker so the
|
||||||
|
# Flutter Linux bundle and integration-ui tests can run.
|
||||||
|
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath linuxDesktopLibs}:$LD_LIBRARY_PATH"
|
||||||
|
|
||||||
|
# Wire the libglvnd dispatch to the nix mesa vendor ICDs so GTK/Flutter
|
||||||
|
# can create an OpenGL (EGL + GLX) context under Xvfb without a real GPU.
|
||||||
|
export __EGL_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/share/glvnd/egl_vendor.d"
|
||||||
|
export __GLX_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/lib"
|
||||||
|
export LIBGL_ALWAYS_SOFTWARE=1
|
||||||
|
export MESA_LOADER_DRIVER_OVERRIDE=softpipe
|
||||||
|
|
||||||
|
echo "SharedInbox Flutter dev environment ready."
|
||||||
|
echo " Analyze : task analyze"
|
||||||
|
echo " Unit tests : task test"
|
||||||
|
echo " Integration : task integration"
|
||||||
|
echo " All checks : task check"
|
||||||
|
echo " Run (Linux) : task run"
|
||||||
|
echo " Start Stalwart : stalwart-dev/start"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -317,7 +317,7 @@ void main() {
|
|||||||
|
|
||||||
// ── Check Sent folder ──────────────────────────────────────────────────
|
// ── Check Sent folder ──────────────────────────────────────────────────
|
||||||
// Use the drawer to switch folders (no back button on Linux desktop).
|
// Use the drawer to switch folders (no back button on Linux desktop).
|
||||||
await tester.tap(find.byTooltip('Open folders'));
|
await tester.tap(find.byTooltip('Open navigation menu'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Sent'));
|
await tester.tap(find.text('Sent'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -331,7 +331,7 @@ void main() {
|
|||||||
expect(find.text(subject), findsOneWidget);
|
expect(find.text(subject), findsOneWidget);
|
||||||
|
|
||||||
// ── Check Inbox ────────────────────────────────────────────────────────
|
// ── Check Inbox ────────────────────────────────────────────────────────
|
||||||
await tester.tap(find.byTooltip('Open folders'));
|
await tester.tap(find.byTooltip('Open navigation menu'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('INBOX'));
|
await tester.tap(find.text('INBOX'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
const int dbSchemaVersion = 41;
|
const int dbSchemaVersion = 33;
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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: []);
|
|
||||||
}
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
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,22 +192,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
enum MenuPosition { bottom, top }
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const UserPreferences({
|
|
||||||
this.menuPosition = MenuPosition.bottom,
|
|
||||||
this.mailViewButtonPosition = MenuPosition.bottom,
|
|
||||||
this.afterMailViewAction = AfterMailViewAction.nextMessage,
|
|
||||||
this.prefetchMode = PrefetchMode.wifiOnly,
|
|
||||||
this.bodyCacheLimitMb = 100,
|
|
||||||
});
|
|
||||||
final MenuPosition menuPosition;
|
|
||||||
final MenuPosition mailViewButtonPosition;
|
|
||||||
final AfterMailViewAction afterMailViewAction;
|
|
||||||
final PrefetchMode prefetchMode;
|
|
||||||
final int bodyCacheLimitMb;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
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 {
|
||||||
@@ -16,10 +15,6 @@ 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,
|
||||||
@@ -59,15 +54,9 @@ 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, preview, and notes. Fast, works offline.
|
/// if null) by subject and preview. 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,8 +20,4 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
|
|
||||||
abstract class UserPreferencesRepository {
|
|
||||||
Stream<UserPreferences> observePreferences();
|
|
||||||
Future<void> updateMenuPosition(MenuPosition position);
|
|
||||||
Future<void> updateMailViewButtonPosition(MenuPosition position);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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,9 +92,8 @@ class ShareEncryptionService {
|
|||||||
) {
|
) {
|
||||||
if (!s.startsWith(_pubKeyPrefix)) return null;
|
if (!s.startsWith(_pubKeyPrefix)) return null;
|
||||||
try {
|
try {
|
||||||
final data = Uint8List.fromList(
|
final data =
|
||||||
base64.decode(s.substring(_pubKeyPrefix.length)),
|
Uint8List.fromList(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,7 +1,6 @@
|
|||||||
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.
|
||||||
@@ -103,11 +102,17 @@ 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,7 +466,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readTaggedArg() {
|
String readTaggedArg() {
|
||||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||||
throw SieveParseException('Expected tagged argument at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected tagged argument at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? peekSizeUnit() {
|
String? peekSizeUnit() {
|
||||||
@@ -478,7 +480,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readDigits() {
|
String readDigits() {
|
||||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||||
throw SieveParseException('Expected number at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected number at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final start = _pos;
|
final start = _pos;
|
||||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||||
@@ -489,7 +493,9 @@ class _Scanner {
|
|||||||
|
|
||||||
String readQuotedString() {
|
String readQuotedString() {
|
||||||
if (_src[_pos] != '"') {
|
if (_src[_pos] != '"') {
|
||||||
throw SieveParseException('Expected " at position $_pos');
|
throw SieveParseException(
|
||||||
|
'Expected " at position $_pos',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_pos++; // skip opening quote
|
_pos++; // skip opening quote
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
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,9 +11,7 @@ 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';
|
||||||
@@ -23,7 +21,6 @@ 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')
|
||||||
@@ -31,13 +28,9 @@ 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((taskName, __) async {
|
Workmanager().executeTask((_, __) async {
|
||||||
try {
|
try {
|
||||||
if (taskName == _kPrefetchTaskName) {
|
await _doBackgroundSync();
|
||||||
await _doBodyPrefetch();
|
|
||||||
} else {
|
|
||||||
await _doBackgroundSync();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -62,31 +55,6 @@ 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(
|
||||||
@@ -108,22 +76,6 @@ 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,7 +35,10 @@ 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("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
.replaceAll(
|
||||||
|
"src='cid:${bareCid.toLowerCase()}'",
|
||||||
|
"src='$dataUri'",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
/// 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,7 +7,6 @@ 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';
|
||||||
|
|
||||||
@@ -308,71 +307,6 @@ 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).
|
|
||||||
@DataClassName('UserPreferencesRow')
|
|
||||||
class UserPreferences extends Table {
|
|
||||||
IntColumn get id => integer()();
|
|
||||||
// 'bottom' (default) | 'top'
|
|
||||||
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
|
|
||||||
// Added in schema v35: 'bottom' (default) | 'top'
|
|
||||||
TextColumn get mailViewButtonPosition =>
|
|
||||||
text().withDefault(const Constant('bottom'))();
|
|
||||||
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
|
|
||||||
TextColumn get afterMailViewAction =>
|
|
||||||
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
|
|
||||||
Set<Column> get primaryKey => {id};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Database ──────────────────────────────────────────────────────────────────
|
// ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@DriftDatabase(
|
@DriftDatabase(
|
||||||
@@ -393,10 +327,6 @@ class UserPreferences extends Table {
|
|||||||
LocalSieveScripts,
|
LocalSieveScripts,
|
||||||
LocalSieveApplied,
|
LocalSieveApplied,
|
||||||
ShareKeys,
|
ShareKeys,
|
||||||
UserPreferences,
|
|
||||||
ImageTrustedSenders,
|
|
||||||
EmailNotes,
|
|
||||||
InstalledVersions,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppDatabase extends _$AppDatabase {
|
class AppDatabase extends _$AppDatabase {
|
||||||
@@ -648,168 +578,8 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||||
}
|
}
|
||||||
if (from < 34) {
|
|
||||||
await m.createTable(userPreferences);
|
|
||||||
}
|
|
||||||
if (from >= 34 && from < 35) {
|
|
||||||
await m.addColumn(
|
|
||||||
userPreferences,
|
|
||||||
userPreferences.mailViewButtonPosition,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (from >= 34 && from < 36) {
|
|
||||||
await m.addColumn(
|
|
||||||
userPreferences,
|
|
||||||
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().
|
||||||
@@ -904,34 +674,18 @@ 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(file, setup: _setupPragmas);
|
return NativeDatabase.createInBackground(
|
||||||
|
file,
|
||||||
|
setup: (db) {
|
||||||
|
// WAL lets readers and writers proceed concurrently (different account
|
||||||
|
// sync loops share the same DB). busy_timeout makes SQLite retry for
|
||||||
|
// up to 5 s instead of immediately returning SQLITE_BUSY.
|
||||||
|
db.execute('PRAGMA journal_mode = WAL;');
|
||||||
|
db.execute('PRAGMA busy_timeout = 5000;');
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,9 +9,8 @@ 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(
|
final rows = await (_db.select(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
.get();
|
||||||
return rows
|
return rows
|
||||||
.map(
|
.map(
|
||||||
@@ -27,9 +26,10 @@ 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(
|
final row = await (_db.select(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where(
|
||||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
(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,7 +44,9 @@ 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((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.write(
|
.write(
|
||||||
LocalSieveScriptsCompanion(
|
LocalSieveScriptsCompanion(
|
||||||
name: Value(name),
|
name: Value(name),
|
||||||
@@ -76,9 +78,10 @@ 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(
|
await (_db.delete(_db.localSieveScripts)
|
||||||
_db.localSieveScripts,
|
..where(
|
||||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +92,9 @@ 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((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
..where(
|
||||||
|
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||||
|
))
|
||||||
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
.write(const LocalSieveScriptsCompanion(isActive: Value(true)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ 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(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
DraftRepositoryImpl(
|
||||||
: _imapConnect = imapConnect;
|
this._db,
|
||||||
|
this._accounts, {
|
||||||
|
ImapConnectFn? imapConnect,
|
||||||
|
}) : _imapConnect = imapConnect;
|
||||||
|
|
||||||
final AppDatabase _db;
|
final AppDatabase _db;
|
||||||
final AccountRepository _accounts;
|
final AccountRepository _accounts;
|
||||||
@@ -121,7 +124,10 @@ class DraftRepositoryImpl implements DraftRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncWithServer(imap.ImapClient client, String accountId) async {
|
Future<void> _syncWithServer(
|
||||||
|
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');
|
||||||
@@ -156,9 +162,8 @@ 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))).write(
|
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||||
DraftsCompanion(imapServerId: Value(uid)),
|
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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';
|
||||||
@@ -96,26 +95,6 @@ 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>;
|
||||||
@@ -177,7 +156,6 @@ 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.
|
||||||
@@ -259,12 +237,7 @@ 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.firstOrNull;
|
final msg = fetch.messages.first;
|
||||||
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 =
|
||||||
@@ -352,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
],
|
],
|
||||||
'fetchHTMLBodyValues': true,
|
'fetchHTMLBodyValues': true,
|
||||||
'fetchTextBodyValues': true,
|
'fetchTextBodyValues': true,
|
||||||
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
'bodyProperties': [
|
||||||
|
'partId',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'size',
|
||||||
|
'subParts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
],
|
],
|
||||||
@@ -561,7 +540,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}:$mailboxPath:$uid';
|
final emailId = '${account.id}:$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),
|
||||||
@@ -616,7 +595,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
bytes += msg.size ?? 0;
|
bytes += msg.size ?? 0;
|
||||||
final emailId = '${account.id}:$mailboxPath:$uid';
|
final emailId = '${account.id}:$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();
|
||||||
@@ -1970,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||||
|
|
||||||
final alreadyApplied = await (_db.select(
|
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
||||||
_db.localSieveApplied,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.get();
|
.get();
|
||||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||||
|
|
||||||
@@ -2072,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
|||||||
..limit(1))
|
..limit(1))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (destMailbox == null) {
|
if (destMailbox == null) {
|
||||||
log(
|
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
||||||
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
destPath = destMailbox.path;
|
destPath = destMailbox.path;
|
||||||
@@ -2832,13 +2808,11 @@ 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(emailRow.uid, 'BODY.PEEK[]');
|
final fetch = await client.uidFetchMessage(
|
||||||
final msg = fetch.messages.firstOrNull;
|
emailRow.uid,
|
||||||
if (msg == null) {
|
'BODY.PEEK[]',
|
||||||
throw StateError(
|
);
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
final msg = fetch.messages.first;
|
||||||
);
|
|
||||||
}
|
|
||||||
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) {
|
||||||
@@ -2900,14 +2874,11 @@ 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(
|
||||||
final msg = fetch.messages.firstOrNull;
|
emailRow.uid,
|
||||||
if (msg == null) {
|
'BODY.PEEK[]',
|
||||||
throw StateError(
|
);
|
||||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
return fetch.messages.first.renderMessage();
|
||||||
);
|
|
||||||
}
|
|
||||||
return msg.renderMessage();
|
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
}
|
}
|
||||||
@@ -2923,9 +2894,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 e.received_at DESC LIMIT 50'
|
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank 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 e.received_at DESC LIMIT 50';
|
' WHERE email_fts MATCH ? ORDER BY rank 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)];
|
||||||
@@ -2935,151 +2906,18 @@ 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'[^\w]+'))
|
.split(RegExp(r'\s+'))
|
||||||
|
.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 '';
|
||||||
@@ -3117,20 +2955,6 @@ 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);
|
||||||
@@ -3145,22 +2969,11 @@ 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 sortedRows) {
|
for (final row in rows) {
|
||||||
final isSent = sentPaths.contains(row.mailboxPath);
|
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
||||||
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>;
|
||||||
@@ -3181,42 +2994,68 @@ 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 ftsQuery = _toFtsQuery(query);
|
final account = (await _accounts.getAccount(accountId))!;
|
||||||
if (ftsQuery.isEmpty) return [];
|
final password = await _accounts.getPassword(accountId);
|
||||||
|
final client = await _imapConnect(
|
||||||
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
|
account,
|
||||||
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
|
_effectiveUsername(account),
|
||||||
' ORDER BY e.received_at DESC LIMIT 50';
|
password,
|
||||||
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 noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
|
final fetch = await client.uidFetchMessages(
|
||||||
|
imap.MessageSequence.fromIds(uids, isUid: true),
|
||||||
final seen = <String>{};
|
'(UID FLAGS ENVELOPE)',
|
||||||
final merged = <model.Email>[];
|
);
|
||||||
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
|
return fetch.messages
|
||||||
if (seen.add(e.id)) merged.add(e);
|
.where((msg) => msg.uid != null && msg.envelope != null)
|
||||||
|
.map((msg) {
|
||||||
|
final envelope = msg.envelope!;
|
||||||
|
final uid = msg.uid!;
|
||||||
|
final emailId = '$accountId:$uid';
|
||||||
|
return model.Email(
|
||||||
|
id: emailId,
|
||||||
|
accountId: accountId,
|
||||||
|
mailboxPath: mailboxPath,
|
||||||
|
uid: uid,
|
||||||
|
subject: envelope.subject,
|
||||||
|
sentAt: envelope.date,
|
||||||
|
receivedAt: envelope.date ?? DateTime.now(),
|
||||||
|
from: _toAddressList(envelope.from),
|
||||||
|
to: _toAddressList(envelope.to),
|
||||||
|
cc: _toAddressList(envelope.cc),
|
||||||
|
isSeen: msg.flags?.contains(r'\Seen') ?? false,
|
||||||
|
isFlagged: msg.flags?.contains(r'\Flagged') ?? false,
|
||||||
|
hasAttachment: msg.hasAttachments(),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
} finally {
|
||||||
|
await client.logout();
|
||||||
}
|
}
|
||||||
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.
|
||||||
@@ -3413,17 +3252,14 @@ 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(
|
await (_db.delete(_db.emails)
|
||||||
_db.emails,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(
|
await (_db.delete(_db.pendingChanges)
|
||||||
_db.pendingChanges,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
await (_db.delete(
|
await (_db.delete(_db.syncStates)
|
||||||
_db.syncStates,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -82,9 +82,8 @@ 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(
|
final existingRows = await (_db.select(_db.mailboxes)
|
||||||
_db.mailboxes,
|
..where((t) => t.accountId.equals(account.id)))
|
||||||
)..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};
|
||||||
|
|
||||||
@@ -321,9 +320,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> clearForResync(String accountId) async {
|
Future<void> clearForResync(String accountId) async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.mailboxes)
|
||||||
_db.mailboxes,
|
..where((t) => t.accountId.equals(accountId)))
|
||||||
)..where((t) => t.accountId.equals(accountId)))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,23 +341,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<model.Mailbox> createMailbox(String accountId, String name) async {
|
|
||||||
final account = (await _accounts.getAccount(accountId))!;
|
|
||||||
final password = await _accounts.getPassword(accountId);
|
|
||||||
switch (account.type) {
|
|
||||||
case account_model.AccountType.imap:
|
|
||||||
return _createMailboxWithRoleImap(account, password, name, null);
|
|
||||||
case account_model.AccountType.jmap:
|
|
||||||
return _createMailboxWithRoleJmap(account, password, name, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<model.Mailbox> _createMailboxWithRoleImap(
|
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,
|
||||||
@@ -381,9 +367,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
role: Value(role),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
|
||||||
_db.mailboxes,
|
|
||||||
)..where((t) => t.id.equals(id)))
|
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return _toModel(row);
|
return _toModel(row);
|
||||||
}
|
}
|
||||||
@@ -392,7 +376,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) {
|
||||||
@@ -410,10 +394,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
{
|
{
|
||||||
'accountId': jmap.accountId,
|
'accountId': jmap.accountId,
|
||||||
'create': {
|
'create': {
|
||||||
'new-mailbox': {
|
'new-mailbox': {'name': name, 'role': role},
|
||||||
'name': name,
|
|
||||||
if (role != null) 'role': role,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'0',
|
'0',
|
||||||
@@ -438,9 +419,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
|||||||
role: Value(role),
|
role: Value(role),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.mailboxes)
|
||||||
_db.mailboxes,
|
..where((t) => t.id.equals(dbId)))
|
||||||
)..where((t) => t.id.equals(dbId)))
|
|
||||||
.getSingle();
|
.getSingle();
|
||||||
return _toModel(row);
|
return _toModel(row);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,570 +0,0 @@
|
|||||||
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,9 +24,8 @@ 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(
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
_db.searchHistoryEntries,
|
..where((t) => t.query.equals(trimmed)))
|
||||||
)..where((t) => t.query.equals(trimmed)))
|
|
||||||
.go();
|
.go();
|
||||||
|
|
||||||
await _db.into(_db.searchHistoryEntries).insert(
|
await _db.into(_db.searchHistoryEntries).insert(
|
||||||
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (keepIds.isNotEmpty) {
|
if (keepIds.isNotEmpty) {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.searchHistoryEntries)
|
||||||
_db.searchHistoryEntries,
|
..where((t) => t.id.isNotIn(keepIds)))
|
||||||
)..where((t) => t.id.isNotIn(keepIds)))
|
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
await _pruneExpired();
|
await _pruneExpired();
|
||||||
|
|
||||||
final keyIdHex = _hex(keyId);
|
final keyIdHex = _hex(keyId);
|
||||||
final row = await (_db.select(
|
final row = await (_db.select(_db.shareKeys)
|
||||||
_db.shareKeys,
|
..where((t) => t.id.equals(keyIdHex)))
|
||||||
)..where((t) => t.id.equals(keyIdHex)))
|
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
|
|
||||||
if (row == null) return null;
|
if (row == null) return null;
|
||||||
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pruneExpired() async {
|
Future<void> _pruneExpired() async {
|
||||||
await (_db.delete(
|
await (_db.delete(_db.shareKeys)
|
||||||
_db.shareKeys,
|
..where(
|
||||||
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||||
|
))
|
||||||
.go();
|
.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import 'package:drift/drift.dart';
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
|
|
||||||
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
|
||||||
import 'package:sharedinbox/data/db/database.dart';
|
|
||||||
|
|
||||||
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
|
|
||||||
UserPreferencesRepositoryImpl(this._db);
|
|
||||||
|
|
||||||
final AppDatabase _db;
|
|
||||||
static const _rowId = 1;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Stream<pref.UserPreferences> observePreferences() {
|
|
||||||
return (_db.select(
|
|
||||||
_db.userPreferences,
|
|
||||||
)..where((t) => t.id.equals(_rowId)))
|
|
||||||
.watchSingleOrNull()
|
|
||||||
.map(_rowToModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> updateMenuPosition(pref.MenuPosition position) async {
|
|
||||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
|
||||||
UserPreferencesCompanion(
|
|
||||||
id: const Value(_rowId),
|
|
||||||
menuPosition: Value(position.name),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
|
|
||||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
|
||||||
UserPreferencesCompanion(
|
|
||||||
id: const Value(_rowId),
|
|
||||||
mailViewButtonPosition: Value(position.name),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> updateAfterMailViewAction(
|
|
||||||
pref.AfterMailViewAction action,
|
|
||||||
) async {
|
|
||||||
await _db.into(_db.userPreferences).insertOnConflictUpdate(
|
|
||||||
UserPreferencesCompanion(
|
|
||||||
id: const Value(_rowId),
|
|
||||||
afterMailViewAction: Value(action.name),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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) {
|
|
||||||
if (row == null) return const pref.UserPreferences();
|
|
||||||
return pref.UserPreferences(
|
|
||||||
menuPosition: pref.MenuPosition.values.firstWhere(
|
|
||||||
(e) => e.name == row.menuPosition,
|
|
||||||
orElse: () => pref.MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
|
|
||||||
(e) => e.name == row.mailViewButtonPosition,
|
|
||||||
orElse: () => pref.MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
|
|
||||||
(e) => e.name == row.afterMailViewAction,
|
|
||||||
orElse: () => pref.AfterMailViewAction.nextMessage,
|
|
||||||
),
|
|
||||||
prefetchMode: pref.PrefetchMode.fromString(row.prefetchMode),
|
|
||||||
bodyCacheLimitMb: row.bodyCacheLimitMb,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,19 +4,15 @@ 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/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';
|
||||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
|
||||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||||
@@ -25,8 +21,7 @@ import 'package:sharedinbox/core/services/undo_service.dart';
|
|||||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||||
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
||||||
import 'package:sharedinbox/data/db/database.dart'
|
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
|
||||||
hide Email, EmailBody, UserPreferences;
|
|
||||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||||
@@ -34,12 +29,10 @@ 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';
|
||||||
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
||||||
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
|
|
||||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||||
|
|
||||||
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
||||||
@@ -104,9 +97,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
|||||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
final searchHistoryRepositoryProvider =
|
||||||
ref,
|
Provider<SearchHistoryRepository>((ref) {
|
||||||
) {
|
|
||||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,10 +131,8 @@ final syncHealthProvider =
|
|||||||
.watchSingleOrNull();
|
.watchSingleOrNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
final isSyncingProvider =
|
||||||
ref,
|
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||||
accountId,
|
|
||||||
) {
|
|
||||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,9 +181,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
|||||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
final undoServiceProvider =
|
||||||
UndoService.new,
|
NotifierProvider<UndoService, List<UndoAction>>(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.
|
||||||
@@ -214,38 +203,10 @@ 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(
|
||||||
@@ -266,41 +227,3 @@ final accountConnectionStatusProvider =
|
|||||||
.read(connectionTestServiceProvider)
|
.read(connectionTestServiceProvider)
|
||||||
.testConnection(account, password);
|
.testConnection(account, password);
|
||||||
});
|
});
|
||||||
|
|
||||||
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
|
||||||
});
|
|
||||||
|
|
||||||
final userPreferencesProvider = StreamProvider.autoDispose<UserPreferences>((
|
|
||||||
ref,
|
|
||||||
) {
|
|
||||||
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,30 +5,19 @@ 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 []}) {
|
void main({List<Override> overrides = const []}) async {
|
||||||
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,
|
||||||
@@ -50,35 +39,19 @@ void main({List<Override> overrides = const []}) {
|
|||||||
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()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
// This handler runs in the parent zone — runApp cannot be called here.
|
(error, stack) {
|
||||||
// Framework errors are already handled by FlutterError.onError above.
|
// Catch unhandled async errors.
|
||||||
(error, stack) => FlutterError.reportError(
|
runApp(CrashScreen(exception: error, stackTrace: stack));
|
||||||
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});
|
||||||
|
|
||||||
@@ -86,8 +59,6 @@ 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() {
|
||||||
@@ -95,11 +66,6 @@ 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
|
||||||
@@ -109,7 +75,6 @@ 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(
|
||||||
@@ -117,7 +82,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
splashFactory: NoSplash.splashFactory,
|
|
||||||
),
|
),
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
@@ -9,9 +8,7 @@ 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';
|
||||||
@@ -22,22 +19,15 @@ 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/widgets/undo_shell.dart';
|
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/inbox',
|
initialLocation: '/accounts',
|
||||||
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(),
|
||||||
@@ -57,14 +47,6 @@ 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',
|
||||||
@@ -74,16 +56,6 @@ final router = GoRouter(
|
|||||||
path: 'about',
|
path: 'about',
|
||||||
builder: (ctx, state) => const AboutScreen(),
|
builder: (ctx, state) => const AboutScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: 'preferences',
|
|
||||||
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(
|
||||||
@@ -187,12 +159,6 @@ final router = GoRouter(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: '/bug-report',
|
|
||||||
builder: (ctx, state) => BugReportScreen(
|
|
||||||
emailId: state.uri.queryParameters['emailId'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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';
|
||||||
@@ -73,10 +72,8 @@ 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 = await launchUrl(
|
final launched =
|
||||||
url,
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -124,10 +121,8 @@ 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 = await launchUrl(
|
final launched =
|
||||||
url,
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && context.mounted) {
|
if (!launched && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -181,7 +176,9 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
selectable: true,
|
selectable: true,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(_launchUrl(context, Uri.parse(href)));
|
unawaited(
|
||||||
|
_launchUrl(context, Uri.parse(href)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -198,30 +195,22 @@ 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 info'),
|
label: const Text('Copy to clipboard'),
|
||||||
onPressed: () => unawaited(
|
onPressed: () => unawaited(
|
||||||
_copyToClipboard(context, imapCount, jmapCount),
|
_copyToClipboard(context, imapCount, jmapCount),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton.icon(
|
child: FilledButton.icon(
|
||||||
icon: const Icon(Icons.bug_report_outlined),
|
icon: const Icon(Icons.bug_report),
|
||||||
label: const Text('Public issue'),
|
label: const Text('Create 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'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -67,14 +66,6 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
unawaited(context.push('/accounts/about'));
|
unawaited(context.push('/accounts/about'));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.settings),
|
|
||||||
title: const Text('Preferences'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context); // Close drawer
|
|
||||||
unawaited(context.push('/accounts/preferences'));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -120,80 +111,20 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
final health = ref.watch(syncHealthProvider(account.id));
|
final health = ref.watch(syncHealthProvider(account.id));
|
||||||
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||||
|
|
||||||
return Column(
|
return ListTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
leading: const Icon(Icons.account_circle),
|
||||||
children: [
|
title: Text(account.displayName),
|
||||||
ListTile(
|
subtitle: Column(
|
||||||
leading: const Icon(Icons.account_circle),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title: Text(account.displayName),
|
children: [
|
||||||
subtitle: Text('${account.email}\n$typeLabel'),
|
Text('${account.email}\n$typeLabel'),
|
||||||
isThreeLine: true,
|
const SizedBox(height: 4),
|
||||||
trailing: Row(
|
health.when(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
status.when(
|
|
||||||
loading: () => const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
data: (_) =>
|
|
||||||
const Icon(Icons.check_circle, color: Colors.green),
|
|
||||||
error: (e, _) => Tooltip(
|
|
||||||
message: e.toString(),
|
|
||||||
child: const Icon(Icons.error_outline, color: Colors.red),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuButton<_AccountAction>(
|
|
||||||
onSelected: (action) => _onAction(context, action),
|
|
||||||
itemBuilder: (_) => [
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.syncLog,
|
|
||||||
child: Text('Sync log'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.verifySync,
|
|
||||||
child: Text('Verify sync health'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.forceSync,
|
|
||||||
child: Text('Force full sync'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.edit,
|
|
||||||
child: Text('Edit'),
|
|
||||||
),
|
|
||||||
if (_sieveSupported(account))
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFiltersRemote,
|
|
||||||
child: Text('Server email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.emailFiltersLocal,
|
|
||||||
child: Text('Local email filters'),
|
|
||||||
),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.send,
|
|
||||||
child: Text('Send accounts'),
|
|
||||||
),
|
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(
|
|
||||||
value: _AccountAction.delete,
|
|
||||||
child: Text('Delete'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
|
||||||
child: health.when(
|
|
||||||
data: (h) {
|
data: (h) {
|
||||||
if (h == null) return const Text('Sync health: Not verified yet');
|
if (h == null) return const Text('Sync health: Not verified yet');
|
||||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||||
return Row(
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text('Sync health: '),
|
const Text('Sync health: '),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -202,13 +133,7 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
||||||
child: Text(
|
|
||||||
h.isHealthy
|
|
||||||
? 'Healthy'
|
|
||||||
: _formatDiscrepancies(h.discrepancySummary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -216,8 +141,66 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
loading: () => const Text('Sync health: checking...'),
|
loading: () => const Text('Sync health: checking...'),
|
||||||
error: (e, _) => Text('Sync health error: $e'),
|
error: (e, _) => Text('Sync health error: $e'),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
isThreeLine: true,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
status.when(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
|
||||||
|
error: (e, _) => Tooltip(
|
||||||
|
message: e.toString(),
|
||||||
|
child: const Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<_AccountAction>(
|
||||||
|
onSelected: (action) => _onAction(context, action),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.syncLog,
|
||||||
|
child: Text('Sync log'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.verifySync,
|
||||||
|
child: Text('Verify sync health'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.forceSync,
|
||||||
|
child: Text('Force full sync'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.edit,
|
||||||
|
child: Text('Edit'),
|
||||||
|
),
|
||||||
|
if (_sieveSupported(account))
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersRemote,
|
||||||
|
child: Text('Server email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.emailFiltersLocal,
|
||||||
|
child: Text('Local email filters'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.send,
|
||||||
|
child: Text('Send accounts'),
|
||||||
|
),
|
||||||
|
const PopupMenuDivider(),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: _AccountAction.delete,
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,30 +293,6 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDiscrepancies(String? summary) {
|
|
||||||
if (summary == null) return 'Discrepancies found';
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(summary) as Map<String, dynamic>;
|
|
||||||
var missingLocally = 0;
|
|
||||||
var missingOnServer = 0;
|
|
||||||
var flagMismatches = 0;
|
|
||||||
for (final v in decoded.values) {
|
|
||||||
final m = v as Map<String, dynamic>;
|
|
||||||
missingLocally += (m['missingLocally'] as int? ?? 0);
|
|
||||||
missingOnServer += (m['missingOnServer'] as int? ?? 0);
|
|
||||||
flagMismatches += (m['flagMismatches'] as int? ?? 0);
|
|
||||||
}
|
|
||||||
final parts = <String>[];
|
|
||||||
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
|
|
||||||
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
|
|
||||||
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
|
|
||||||
if (parts.isEmpty) return 'Discrepancies found';
|
|
||||||
return 'Discrepancies found (${parts.join(', ')})';
|
|
||||||
} catch (_) {
|
|
||||||
return 'Discrepancies found';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OnboardingView extends StatelessWidget {
|
class _OnboardingView extends StatelessWidget {
|
||||||
const _OnboardingView();
|
const _OnboardingView();
|
||||||
|
|
||||||
|
|||||||
@@ -219,7 +219,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_Step.done => const Center(
|
_Step.done => const Center(
|
||||||
child: Icon(Icons.check_circle, size: 64, color: Colors.green),
|
child: Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Step.error => Center(
|
_Step.error => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -158,7 +158,10 @@ 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(accountJson: account.toJson(), password: password),
|
AccountPayload(
|
||||||
|
accountJson: account.toJson(),
|
||||||
|
password: password,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +361,9 @@ 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('Encrypted code copied to clipboard'),
|
content: Text(
|
||||||
|
'Encrypted code copied to clipboard',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,635 +0,0 @@
|
|||||||
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,90 +2,20 @@ 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 ConsumerWidget {
|
class ChangeLogScreen extends StatelessWidget {
|
||||||
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, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
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) {
|
||||||
@@ -93,12 +23,9 @@ class ChangeLogScreen extends ConsumerWidget {
|
|||||||
child: Text('Error loading changelog: ${snapshot.error}'),
|
child: Text('Error loading changelog: ${snapshot.error}'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final raw = snapshot.data ?? 'No changelog entries found.';
|
final content = snapshot.data ?? 'No changelog entries found.';
|
||||||
final content = _linkifyIssueRefs(raw);
|
|
||||||
final versions = installedVersions.value ?? {};
|
|
||||||
final annotated = _injectInstallMarkers(content, versions);
|
|
||||||
return Markdown(
|
return Markdown(
|
||||||
data: annotated,
|
data: content,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
if (href != null) {
|
if (href != null) {
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -1,422 +0,0 @@
|
|||||||
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,7 +194,9 @@ 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(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
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'),
|
||||||
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
|||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
if (_accountId == null) {
|
if (_accountId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
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'),
|
||||||
@@ -251,7 +255,9 @@ 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(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
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,7 +57,6 @@ 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'),
|
||||||
@@ -82,9 +81,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(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
context,
|
color: Colors.grey[600],
|
||||||
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -54,9 +54,8 @@ Future<Mailbox?> resolveMailboxByRole(
|
|||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final m in mailboxes.where(
|
for (final m
|
||||||
(m) => m.path != currentMailboxPath,
|
in mailboxes.where((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,15 +12,11 @@ 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/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';
|
||||||
@@ -39,7 +35,6 @@ 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) {
|
||||||
@@ -53,15 +48,6 @@ 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!,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,6 +60,10 @@ 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),
|
||||||
@@ -81,7 +71,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_replyWithRecipientDialog(context, header, body));
|
unawaited(
|
||||||
|
_replyWithRecipientDialog(context, header, body),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.forward),
|
||||||
|
tooltip: 'Forward',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_forward(context, header, body));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -97,26 +98,46 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
final destPath = await repo.deleteEmail(widget.emailId);
|
final destPath = await repo.deleteEmail(widget.emailId);
|
||||||
|
|
||||||
if (header != null) {
|
if (header != null) {
|
||||||
await ref.read(undoServiceProvider.notifier).pushAction(
|
unawaited(
|
||||||
UndoAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
id: DateTime.now().toIso8601String(),
|
UndoAction(
|
||||||
accountId: header.accountId,
|
id: DateTime.now().toIso8601String(),
|
||||||
type: UndoType.delete,
|
accountId: header.accountId,
|
||||||
emailIds: [widget.emailId],
|
type: UndoType.delete,
|
||||||
sourceMailboxPath: header.mailboxPath,
|
emailIds: [widget.emailId],
|
||||||
destinationMailboxPath: destPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
originalEmails: [header],
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: [header],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) context.pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.report_outlined),
|
||||||
|
tooltip: 'Mark as spam',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_markAsSpam(context, header));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.drive_file_move_outline),
|
||||||
|
tooltip: 'Move to folder',
|
||||||
|
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
tooltip: 'Snooze',
|
||||||
|
onPressed: header == null ? null : () => _snooze(context, header),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_isFlagged ? Icons.star : Icons.star_border,
|
_isFlagged ? Icons.star : Icons.star_border,
|
||||||
@@ -129,25 +150,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (mounted) setState(() => _isFlagged = next);
|
if (mounted) setState(() => _isFlagged = next);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.report_outlined),
|
|
||||||
tooltip: 'Mark as spam',
|
|
||||||
onPressed: header == null
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
unawaited(_markAsSpam(context, header));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
|
||||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
|
||||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'mark_unread',
|
value: 'mark_unread',
|
||||||
child: Text('Mark as unread'),
|
child: Text('Mark as unread'),
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(),
|
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'headers',
|
value: 'headers',
|
||||||
child: Text('Show Mail Headers'),
|
child: Text('Show Mail Headers'),
|
||||||
@@ -156,34 +164,21 @@ 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: 'bug_report',
|
value: 'rfc',
|
||||||
child: Text('Report a Bug'),
|
child: Text('Show Raw Email'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'forward' && header != null) {
|
if (value == 'mark_unread') {
|
||||||
unawaited(_forward(context, header, body));
|
|
||||||
} else if (value == 'move' && header != null) {
|
|
||||||
unawaited(_moveTo(context, header));
|
|
||||||
} else if (value == 'snooze' && header != null) {
|
|
||||||
unawaited(_snooze(context, header));
|
|
||||||
} else if (value == 'mark_unread') {
|
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
await repo.setFlag(widget.emailId, seen: false);
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) context.pop();
|
||||||
} else if (value == 'headers' && body != null) {
|
} else if (value == 'headers' && body != null) {
|
||||||
_showHeaders(context, body);
|
_showHeaders(context, body);
|
||||||
} else if (value == 'structure' && body != null) {
|
} else if (value == 'structure' && body != null) {
|
||||||
_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}'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -192,35 +187,19 @@ 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) {
|
data: (d) => _buildBody(context, d.$1, d.$2),
|
||||||
final trusted =
|
|
||||||
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
|
||||||
return _buildBody(context, d.$1, d.$2, trusted);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(
|
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
|
||||||
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 (!effectiveLoadImages)
|
if (!_loadRemoteImages)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -228,54 +207,19 @@ 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: () {
|
onPressed: () => setState(() => _loadRemoteImages = true),
|
||||||
setState(() => _loadRemoteImages = true);
|
|
||||||
if (senderEmail != null) {
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(userPreferencesRepositoryProvider)
|
|
||||||
.addTrustedImageSender(senderEmail),
|
|
||||||
);
|
|
||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
// SnackBar defaults to persist=true when an action
|
|
||||||
// is set, which disables the auto-dismiss timer.
|
|
||||||
// Explicitly opt back into duration-based dismiss.
|
|
||||||
persist: false,
|
|
||||||
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: effectiveLoadImages,
|
loadRemoteImages: _loadRemoteImages,
|
||||||
),
|
),
|
||||||
] 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(
|
||||||
@@ -308,40 +252,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
|
|
||||||
if (header == null) return null;
|
|
||||||
final prefs = ref.read(userPreferencesProvider).value;
|
|
||||||
final action =
|
|
||||||
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
|
|
||||||
if (action != AfterMailViewAction.nextMessage) return null;
|
|
||||||
|
|
||||||
final threads = await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.observeThreads(header.accountId, header.mailboxPath)
|
|
||||||
.first;
|
|
||||||
|
|
||||||
final currentIndex = threads.indexWhere(
|
|
||||||
(t) => t.emailIds.contains(widget.emailId),
|
|
||||||
);
|
|
||||||
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
|
|
||||||
return threads[currentIndex + 1].latestEmailId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
if (nextEmailId != null && header != null) {
|
|
||||||
context.go(
|
|
||||||
'/accounts/${header.accountId}'
|
|
||||||
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
|
|
||||||
'/emails/${Uri.encodeComponent(nextEmailId)}',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
context.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
Future<void> _downloadAndOpen(EmailAttachment att) async {
|
||||||
setState(() => _downloading.add(att.filename));
|
setState(() => _downloading.add(att.filename));
|
||||||
try {
|
try {
|
||||||
@@ -359,114 +269,6 @@ 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,
|
||||||
@@ -601,9 +403,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _archive(BuildContext context, Email header) async {
|
Future<void> _archive(BuildContext context, Email header) async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final mailbox = await resolveMailboxByRole(
|
final mailbox = await resolveMailboxByRole(
|
||||||
context,
|
context,
|
||||||
ref.read(mailboxRepositoryProvider),
|
ref.read(mailboxRepositoryProvider),
|
||||||
@@ -633,13 +432,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) context.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final mailbox = await resolveMailboxByRole(
|
final mailbox = await resolveMailboxByRole(
|
||||||
context,
|
context,
|
||||||
ref.read(mailboxRepositoryProvider),
|
ref.read(mailboxRepositoryProvider),
|
||||||
@@ -669,7 +465,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) context.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _forward(
|
Future<void> _forward(
|
||||||
@@ -685,50 +481,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
context.push(
|
context.push(
|
||||||
'/compose',
|
'/compose',
|
||||||
extra: {'prefillSubject': subject, 'prefillBody': quoted},
|
extra: {
|
||||||
|
'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 mailboxRepo = ref.read(mailboxRepositoryProvider);
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
final mailboxes =
|
final mailboxes =
|
||||||
await mailboxRepo.observeMailboxes(header.accountId).first;
|
await mailboxRepo.observeMailboxes(header.accountId).first;
|
||||||
@@ -739,8 +500,6 @@ 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(
|
||||||
@@ -758,28 +517,13 @@ 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;
|
||||||
|
|
||||||
String destination = chosen;
|
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, 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(
|
||||||
@@ -789,18 +533,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
type: UndoType.move,
|
type: UndoType.move,
|
||||||
emailIds: [widget.emailId],
|
emailIds: [widget.emailId],
|
||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
destinationMailboxPath: destination,
|
destinationMailboxPath: chosen,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
if (context.mounted) context.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _snooze(BuildContext context, Email header) async {
|
Future<void> _snooze(BuildContext context, Email header) async {
|
||||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
final until = await showModalBottomSheet<DateTime>(
|
final until = await showModalBottomSheet<DateTime>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => const SnoozePicker(),
|
builder: (ctx) => const SnoozePicker(),
|
||||||
@@ -828,7 +569,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_navigateTo(context, header, nextEmailId);
|
context.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,9 +581,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(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,7 +697,47 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => EmailHeadersDialog(headers: body.headers),
|
builder: (ctx) => AlertDialog(
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -967,7 +748,9 @@ 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('Structure not available. Try re-syncing the email.'),
|
content: Text(
|
||||||
|
'Structure not available. Try re-syncing the email.',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -979,13 +762,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => Dialog.fullscreen(
|
builder: (ctx) => AlertDialog(
|
||||||
child: Scaffold(
|
title: const Text('Mail Structure'),
|
||||||
appBar: AppBar(
|
content: SizedBox(
|
||||||
title: const Text('Mail Structure'),
|
width: double.maxFinite,
|
||||||
leading: const CloseButton(),
|
child: ListView.builder(
|
||||||
),
|
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];
|
||||||
@@ -1014,6 +796,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1071,8 +859,14 @@ class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
|||||||
SegmentedButton<_Placement>(
|
SegmentedButton<_Placement>(
|
||||||
showSelectedIcon: false,
|
showSelectedIcon: false,
|
||||||
segments: const [
|
segments: const [
|
||||||
ButtonSegment(value: _Placement.to, label: Text('To')),
|
ButtonSegment(
|
||||||
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
value: _Placement.to,
|
||||||
|
label: Text('To'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _Placement.cc,
|
||||||
|
label: Text('Cc'),
|
||||||
|
),
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
value: _Placement.skip,
|
value: _Placement.skip,
|
||||||
label: Text('Skip'),
|
label: Text('Skip'),
|
||||||
|
|||||||
@@ -8,14 +8,22 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/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/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_thread_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_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({
|
||||||
@@ -50,15 +58,6 @@ 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,7 +69,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchResults = null;
|
_searchResults = null;
|
||||||
_searchLoading = false;
|
_searchLoading = false;
|
||||||
_lastSettledQuery = null;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -127,35 +125,18 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runSearch(String query) async {
|
Future<void> _runSearch(String query) async {
|
||||||
final q = query.trim();
|
if (query.trim().isEmpty) {
|
||||||
if (q.isEmpty) {
|
setState(() => _searchResults = null);
|
||||||
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, q);
|
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||||
if (mounted && generation == _searchGeneration) {
|
if (mounted) setState(() => _searchResults = results);
|
||||||
setState(() {
|
|
||||||
_searchResults = results;
|
|
||||||
_lastSettledQuery = q;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted && generation == _searchGeneration) {
|
if (mounted) setState(() => _searchLoading = false);
|
||||||
setState(() => _searchLoading = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,21 +148,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
||||||
final prefs =
|
|
||||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
|
||||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
appBar: _buildAppBar(repo, accountAsync),
|
||||||
drawer: _selecting
|
drawer: _selecting
|
||||||
? null
|
? null
|
||||||
: FolderDrawer(
|
: FolderDrawer(
|
||||||
accountId: widget.accountId,
|
accountId: widget.accountId,
|
||||||
currentMailboxPath: widget.mailboxPath,
|
currentMailboxPath: widget.mailboxPath,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: _selecting
|
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||||
? _selectionBottomBar()
|
|
||||||
: (menuAtBottom ? _folderNavBottomBar() : null),
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSyncErrorBanner(),
|
_buildSyncErrorBanner(),
|
||||||
@@ -197,14 +173,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
PreferredSizeWidget _buildAppBar(
|
PreferredSizeWidget _buildAppBar(
|
||||||
EmailRepository emailRepo,
|
EmailRepository emailRepo,
|
||||||
AsyncValue<Account?> accountAsync, {
|
AsyncValue<Account?> accountAsync,
|
||||||
required bool menuAtBottom,
|
) {
|
||||||
}) {
|
|
||||||
final selectionCount =
|
final selectionCount =
|
||||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||||
|
|
||||||
return AppBar(
|
return AppBar(
|
||||||
automaticallyImplyLeading: !menuAtBottom,
|
|
||||||
leading: _selecting
|
leading: _selecting
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
@@ -278,14 +252,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: _onSearchChanged,
|
onChanged: _onSearchChanged,
|
||||||
onSubmitted: (value) {
|
onSubmitted: _runSearch,
|
||||||
// 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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -334,22 +301,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _folderNavBottomBar() {
|
|
||||||
return BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Builder(
|
|
||||||
builder: (context) => IconButton(
|
|
||||||
icon: const Icon(Icons.menu),
|
|
||||||
tooltip: 'Open folders',
|
|
||||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _selectionBottomBar() {
|
Widget _selectionBottomBar() {
|
||||||
return BottomAppBar(
|
return BottomAppBar(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -406,7 +357,11 @@ 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(error, maxLines: 2, overflow: TextOverflow.ellipsis),
|
content: Text(
|
||||||
|
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,
|
||||||
@@ -420,8 +375,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
child: const Text('Retry'),
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () => context.push(
|
||||||
context.push('/accounts/${widget.accountId}/sync-log'),
|
'/accounts/${widget.accountId}/sync-log',
|
||||||
|
),
|
||||||
child: const Text('View log'),
|
child: const Text('View log'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -575,8 +531,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 still return deleted rows because the
|
// Calling searchEmails here would hit the IMAP server, which still has
|
||||||
// delete is only enqueued — not yet applied to the local DB.
|
// the emails because the delete is only enqueued — not yet applied.
|
||||||
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))
|
||||||
@@ -713,93 +669,177 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
final t = threads[i];
|
final t = threads[i];
|
||||||
return EmailThreadTile(
|
final isSelected = _selectedThreadIds.contains(t.threadId);
|
||||||
thread: t,
|
final senderNames =
|
||||||
isSelected: _selectedThreadIds.contains(t.threadId),
|
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||||
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'
|
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
|
||||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
|
||||||
'/threads/${Uri.encodeComponent(t.threadId)}',
|
|
||||||
)
|
)
|
||||||
: () => context.push(
|
: () => context.push(
|
||||||
'/accounts/${widget.accountId}/mailboxes'
|
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||||
'/${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 ThreadTile(
|
return EmailTile(
|
||||||
thread: t,
|
email: e,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
leading: SizedBox(
|
leading: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
@@ -818,4 +858,25 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _swipeBackground({
|
||||||
|
required AlignmentGeometry alignment,
|
||||||
|
required Color color,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
color: color,
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label, style: const TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.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/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/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
@@ -18,12 +17,8 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
||||||
final emailRepo = ref.watch(emailRepositoryProvider);
|
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||||
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
||||||
final prefs =
|
|
||||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
|
||||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: !menuAtBottom,
|
|
||||||
title: const Text('Folders'),
|
title: const Text('Folders'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -47,21 +42,6 @@ class MailboxListScreen extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: FolderDrawer(accountId: accountId),
|
drawer: FolderDrawer(accountId: accountId),
|
||||||
bottomNavigationBar: menuAtBottom
|
|
||||||
? BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Builder(
|
|
||||||
builder: (ctx) => IconButton(
|
|
||||||
icon: const Icon(Icons.menu),
|
|
||||||
tooltip: 'Open folders',
|
|
||||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// ── Failed-mutation banner ───────────────────────────────────────
|
// ── Failed-mutation banner ───────────────────────────────────────
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ 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/filter_builder.dart';
|
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
|
||||||
|
|
||||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
final _searchHistoryProvider =
|
||||||
ref,
|
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||||
) async {
|
|
||||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,10 +36,6 @@ 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();
|
||||||
@@ -59,13 +52,6 @@ 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) {
|
||||||
@@ -148,47 +134,22 @@ 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: _advancedMode
|
title: TextField(
|
||||||
? const Text('Advanced Search')
|
controller: _ctrl,
|
||||||
: TextField(
|
focusNode: _focusNode,
|
||||||
controller: _ctrl,
|
autofocus: true,
|
||||||
focusNode: _focusNode,
|
decoration: const InputDecoration(
|
||||||
autofocus: true,
|
hintText: 'Search folders, addresses, emails…',
|
||||||
decoration: const InputDecoration(
|
border: InputBorder.none,
|
||||||
hintText: 'Search folders, addresses, emails…',
|
),
|
||||||
border: InputBorder.none,
|
onChanged: _onChanged,
|
||||||
),
|
),
|
||||||
onChanged: _onChanged,
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
if (!_advancedMode && _ctrl.text.isNotEmpty)
|
if (_ctrl.text.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -196,15 +157,6 @@ 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(),
|
||||||
@@ -212,7 +164,6 @@ 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) {
|
||||||
@@ -222,54 +173,7 @@ 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'),
|
||||||
@@ -284,9 +188,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)
|
||||||
ThreadTile(
|
EmailTile(
|
||||||
thread: EmailThread.fromEmail(e),
|
email: e,
|
||||||
locationLabel: '${e.accountId} • ${e.mailboxPath}',
|
showLocation: true,
|
||||||
onTap: () => context.push(
|
onTap: () => context.push(
|
||||||
'/accounts/${e.accountId}/mailboxes'
|
'/accounts/${e.accountId}/mailboxes'
|
||||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||||
|
|||||||
@@ -3,13 +3,8 @@ 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({
|
||||||
@@ -32,29 +27,18 @@ 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());
|
||||||
}
|
}
|
||||||
@@ -64,40 +48,9 @@ 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 {
|
||||||
@@ -110,7 +63,6 @@ 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) {
|
||||||
@@ -124,11 +76,6 @@ 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');
|
||||||
@@ -171,10 +118,6 @@ 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(
|
||||||
@@ -220,9 +163,18 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TextField(
|
||||||
controller: _tabController,
|
controller: _contentController,
|
||||||
children: [_buildVisualTab(), _buildScriptTab()],
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Script',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace'),
|
||||||
|
enabled: !_saving,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -230,220 +182,4 @@ 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,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
title: Text(
|
||||||
|
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: _buildBody(),
|
body: _buildBody(),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ 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/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';
|
||||||
@@ -30,16 +28,9 @@ class ThreadDetailScreen extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final repo = ref.watch(emailRepositoryProvider);
|
final repo = ref.watch(emailRepositoryProvider);
|
||||||
final prefs =
|
|
||||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
|
||||||
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Thread')),
|
||||||
title: const Text('Thread'),
|
|
||||||
automaticallyImplyLeading: !buttonAtBottom,
|
|
||||||
),
|
|
||||||
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
|
|
||||||
body: StreamBuilder<List<Email>>(
|
body: StreamBuilder<List<Email>>(
|
||||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -69,20 +60,6 @@ class ThreadDetailScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBackButtonBar(BuildContext context) {
|
|
||||||
return BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
tooltip: 'Back',
|
|
||||||
onPressed: () => context.pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmailMessageCard extends ConsumerStatefulWidget {
|
class _EmailMessageCard extends ConsumerStatefulWidget {
|
||||||
@@ -114,14 +91,6 @@ 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(
|
||||||
@@ -156,13 +125,13 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_expanded) _buildExpandedBody(isTrusted, senderEmail),
|
if (_expanded) _buildExpandedBody(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExpandedBody(bool isTrusted, String? senderEmail) {
|
Widget _buildExpandedBody() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -172,17 +141,6 @@ 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(
|
||||||
@@ -193,55 +151,21 @@ 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 (!effectiveLoadImages)
|
if (!_loadRemoteImages)
|
||||||
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),
|
|
||||||
// SnackBar defaults to persist=true when an
|
|
||||||
// action is set, which disables auto-dismiss.
|
|
||||||
// Explicitly opt into duration-based dismiss.
|
|
||||||
persist: false,
|
|
||||||
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: effectiveLoadImages,
|
loadRemoteImages: _loadRemoteImages,
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
@@ -305,27 +229,47 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _delete() async {
|
Future<void> _delete() async {
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final confirmed = await showDialog<bool>(
|
||||||
// Fetch data first for IMAP undo support
|
context: context,
|
||||||
final original = await repo.getEmail(widget.email.id);
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete email'),
|
||||||
final destPath = await repo.deleteEmail(widget.email.id);
|
content: const Text('Move this email to Trash?'),
|
||||||
|
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 (original != null) {
|
if (confirmed == true) {
|
||||||
unawaited(
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
// Fetch data first for IMAP undo support
|
||||||
UndoAction(
|
final original = await repo.getEmail(widget.email.id);
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.email.accountId,
|
final destPath = await repo.deleteEmail(widget.email.id);
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: [widget.email.id],
|
if (!mounted) return;
|
||||||
sourceMailboxPath: widget.email.mailboxPath,
|
if (original != null) {
|
||||||
destinationMailboxPath: destPath,
|
unawaited(
|
||||||
originalEmails: [original],
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: widget.email.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: [widget.email.id],
|
||||||
|
sourceMailboxPath: widget.email.mailboxPath,
|
||||||
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: [original],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/di.dart';
|
|
||||||
|
|
||||||
class TrustedImageSendersScreen extends ConsumerWidget {
|
|
||||||
const TrustedImageSendersScreen({super.key, this.highlightedSender});
|
|
||||||
|
|
||||||
final String? highlightedSender;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Allowed addresses for images')),
|
|
||||||
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()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:sharedinbox/core/models/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, accountId: action.accountId),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ConsumerWidget {
|
|
||||||
const _EmailTile({required this.email, required this.accountId});
|
|
||||||
|
|
||||||
final Email email;
|
|
||||||
final String accountId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
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),
|
|
||||||
trailing: const Icon(Icons.chevron_right),
|
|
||||||
onTap: () => _openEmail(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openEmail(BuildContext context, WidgetRef ref) async {
|
|
||||||
final messageId = email.messageId;
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
if (messageId == null) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 5),
|
|
||||||
content: Text('Cannot locate this email — no Message-ID.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final found = await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.findEmailByMessageId(accountId, messageId);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
if (found == null) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
duration: Duration(seconds: 5),
|
|
||||||
content: Text(
|
|
||||||
'Email no longer exists at its previous location. '
|
|
||||||
'Use Undo to restore it.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
context.go(
|
|
||||||
'/accounts/$accountId'
|
|
||||||
'/mailboxes/${Uri.encodeComponent(found.mailboxPath)}'
|
|
||||||
'/emails/${Uri.encodeComponent(found.id)}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -56,10 +55,6 @@ 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
|
||||||
@@ -89,7 +84,9 @@ 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(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
duration: Duration(seconds: 5),
|
duration: Duration(seconds: 5),
|
||||||
content: Text('Action undone.'),
|
content: Text('Action undone.'),
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
|
||||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
|
||||||
import 'package:sharedinbox/di.dart';
|
|
||||||
|
|
||||||
class UserPreferencesScreen extends ConsumerWidget {
|
|
||||||
const UserPreferencesScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final prefsAsync = ref.watch(userPreferencesProvider);
|
|
||||||
final trustedSendersAsync = ref.watch(trustedImageSendersProvider);
|
|
||||||
final trustedCount = trustedSendersAsync.value?.length ?? 0;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Preferences')),
|
|
||||||
body: prefsAsync.when(
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (_, __) =>
|
|
||||||
const Center(child: Text('Error loading preferences')),
|
|
||||||
data: (prefs) => ListView(
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
title: Text(
|
|
||||||
'Menu bar position',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Where the folder navigation menu is shown in the mailbox view.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RadioGroup<MenuPosition>(
|
|
||||||
groupValue: prefs.menuPosition,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(userPreferencesRepositoryProvider)
|
|
||||||
.updateMenuPosition(value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Bottom (default)'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Open folder navigation from a button at the bottom of the screen.',
|
|
||||||
),
|
|
||||||
value: MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Top'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Open folder navigation from the hamburger icon in the top bar.',
|
|
||||||
),
|
|
||||||
value: MenuPosition.top,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
title: Text(
|
|
||||||
'Single mail view button position',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Where the back button is shown in the single mail view.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RadioGroup<MenuPosition>(
|
|
||||||
groupValue: prefs.mailViewButtonPosition,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(userPreferencesRepositoryProvider)
|
|
||||||
.updateMailViewButtonPosition(value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Bottom (default)'),
|
|
||||||
subtitle: Text(
|
|
||||||
'Show the back button at the bottom of the screen.',
|
|
||||||
),
|
|
||||||
value: MenuPosition.bottom,
|
|
||||||
),
|
|
||||||
RadioListTile<MenuPosition>(
|
|
||||||
title: Text('Top'),
|
|
||||||
subtitle: Text('Show the back button in the top bar.'),
|
|
||||||
value: MenuPosition.top,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(),
|
|
||||||
ListTile(
|
|
||||||
title: Text(
|
|
||||||
'After mail action',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
subtitle: const Text(
|
|
||||||
'What to show after deleting, archiving, or otherwise handling a message.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RadioGroup<AfterMailViewAction>(
|
|
||||||
groupValue: prefs.afterMailViewAction,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
unawaited(
|
|
||||||
ref
|
|
||||||
.read(userPreferencesRepositoryProvider)
|
|
||||||
.updateAfterMailViewAction(value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<AfterMailViewAction>(
|
|
||||||
title: Text('Next message (default)'),
|
|
||||||
subtitle: Text('Show the next message in the mailbox.'),
|
|
||||||
value: AfterMailViewAction.nextMessage,
|
|
||||||
),
|
|
||||||
RadioListTile<AfterMailViewAction>(
|
|
||||||
title: Text('Return to mailbox'),
|
|
||||||
subtitle: Text('Return to the message list.'),
|
|
||||||
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,9 +26,8 @@ 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 = MediaQuery.of(
|
final textScale =
|
||||||
context,
|
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
||||||
).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'
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
|
||||||
|
|
||||||
final _dateFmt = DateFormat('MMM d');
|
|
||||||
final _formattedDates = <int, String>{};
|
|
||||||
|
|
||||||
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
|
||||||
|
|
||||||
String _fmtDate(DateTime dt) =>
|
|
||||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
|
||||||
|
|
||||||
/// A swipeable list tile for an [EmailThread].
|
|
||||||
///
|
|
||||||
/// Handles the [Dismissible] wrapper (archive left, delete right) and
|
|
||||||
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
|
|
||||||
/// line with the account name — used in the combined-inbox view.
|
|
||||||
class EmailThreadTile extends StatelessWidget {
|
|
||||||
const EmailThreadTile({
|
|
||||||
super.key,
|
|
||||||
required this.thread,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.isSelecting,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onLongPress,
|
|
||||||
required this.onDismissed,
|
|
||||||
this.showAccount = false,
|
|
||||||
this.accountName,
|
|
||||||
});
|
|
||||||
|
|
||||||
final EmailThread thread;
|
|
||||||
final bool isSelected;
|
|
||||||
final bool isSelecting;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final VoidCallback onLongPress;
|
|
||||||
final Future<void> Function(DismissDirection) onDismissed;
|
|
||||||
|
|
||||||
/// When true, renders an extra subtitle line with [accountName].
|
|
||||||
final bool showAccount;
|
|
||||||
final String? accountName;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final t = thread;
|
|
||||||
final senderNames =
|
|
||||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
|
||||||
|
|
||||||
final tile = ListTile(
|
|
||||||
leading: SizedBox(
|
|
||||||
width: 40,
|
|
||||||
child: isSelecting
|
|
||||||
? Checkbox(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (_) => onTap(),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
|
||||||
color:
|
|
||||||
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
senderNames.isEmpty ? '(unknown)' : senderNames,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (t.messageCount > 1)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4),
|
|
||||||
child: Text(
|
|
||||||
'[${t.messageCount}]',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (t.preview != null && t.preview!.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
t.preview!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
if (showAccount && accountName != null)
|
|
||||||
Text(
|
|
||||||
accountName!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
selected: isSelected,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (t.isFlagged)
|
|
||||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_fmtDate(t.latestDate),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: ValueKey('${t.accountId}:${t.threadId}'),
|
|
||||||
direction:
|
|
||||||
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
|
|
||||||
background: _swipeBackground(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
color: Colors.green,
|
|
||||||
icon: Icons.archive,
|
|
||||||
label: 'Archive',
|
|
||||||
),
|
|
||||||
secondaryBackground: _swipeBackground(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
color: Colors.red,
|
|
||||||
icon: Icons.delete,
|
|
||||||
label: 'Delete',
|
|
||||||
),
|
|
||||||
onDismissed: onDismissed,
|
|
||||||
child: tile,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Widget _swipeBackground({
|
|
||||||
required AlignmentGeometry alignment,
|
|
||||||
required Color color,
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
}) {
|
|
||||||
return Container(
|
|
||||||
color: color,
|
|
||||||
alignment: alignment,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: Colors.white),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(label, style: const TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
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,16 +111,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _measureHeight(String _) async {
|
Future<void> _measureHeight(String _) async {
|
||||||
try {
|
final result = await _controller!.runJavaScriptReturningResult(
|
||||||
final result = await _controller!.runJavaScriptReturningResult(
|
'document.documentElement.scrollHeight',
|
||||||
'document.documentElement.scrollHeight',
|
);
|
||||||
);
|
final h = double.tryParse(result.toString());
|
||||||
final h = double.tryParse(result.toString());
|
if (h != null && h > 0 && mounted) {
|
||||||
if (h != null && h > 0 && mounted) {
|
setState(() => _height = h);
|
||||||
setState(() => _height = h);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// WebView not ready yet; height stays at default
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,14 +187,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
final launched = await launchUrl(
|
final launched =
|
||||||
uri,
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
);
|
|
||||||
if (!launched && mounted) {
|
if (!launched && mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
context,
|
SnackBar(content: Text('Could not open: $url')),
|
||||||
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
|
||||||
|
|
||||||
final _dateFmt = DateFormat('MMM d');
|
|
||||||
// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls.
|
|
||||||
final _formattedDates = <int, String>{};
|
|
||||||
|
|
||||||
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
|
||||||
|
|
||||||
String _fmtDate(DateTime dt) =>
|
|
||||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
|
||||||
|
|
||||||
/// A list tile for an [EmailThread].
|
|
||||||
///
|
|
||||||
/// Used in inbox lists, combined inbox, and search result lists.
|
|
||||||
/// Pass a custom [leading] widget to support selection-mode checkboxes.
|
|
||||||
/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or
|
|
||||||
/// "accountId • mailboxPath") — useful in cross-mailbox views.
|
|
||||||
class ThreadTile extends StatelessWidget {
|
|
||||||
const ThreadTile({
|
|
||||||
super.key,
|
|
||||||
required this.thread,
|
|
||||||
required this.onTap,
|
|
||||||
this.leading,
|
|
||||||
this.selected = false,
|
|
||||||
this.onLongPress,
|
|
||||||
this.locationLabel,
|
|
||||||
});
|
|
||||||
|
|
||||||
final EmailThread thread;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final Widget? leading;
|
|
||||||
final bool selected;
|
|
||||||
final VoidCallback? onLongPress;
|
|
||||||
|
|
||||||
/// When non-null, appended as an extra subtitle line in primary colour.
|
|
||||||
final String? locationLabel;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final senderNames = thread.participants.isEmpty
|
|
||||||
? '(unknown)'
|
|
||||||
: thread.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: leading ??
|
|
||||||
Icon(
|
|
||||||
thread.hasUnread ? Icons.mail : Icons.mail_outline,
|
|
||||||
color:
|
|
||||||
thread.hasUnread ? Theme.of(context).colorScheme.primary : null,
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
senderNames,
|
|
||||||
style: thread.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (thread.messageCount > 1)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4),
|
|
||||||
child: Text(
|
|
||||||
'[${thread.messageCount}]',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
thread.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: thread.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (thread.preview != null && thread.preview!.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
thread.preview!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
if (locationLabel != null)
|
|
||||||
Text(
|
|
||||||
locationLabel!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (thread.isFlagged)
|
|
||||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_fmtDate(thread.latestDate),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
selected: selected,
|
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -102,7 +102,3 @@ 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)
|
|
||||||
|
|||||||