Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16c38006d | ||
|
|
148ceaa509 | ||
|
|
9752fc2d06 |
@@ -4,18 +4,14 @@
|
||||
# In systemd service:
|
||||
# ExecStartPre=docker build -t forgejo-act-runner:latest /etc/forgejo/runner
|
||||
# ExecStart=/usr/local/bin/forgejo-runner daemon --config /etc/forgejo/config.yml
|
||||
|
||||
FROM ghcr.io/catthehacker/ubuntu:go-24.04
|
||||
|
||||
# Infrastructure tools required by CI workflows
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
jq \
|
||||
stunnel4 \
|
||||
netcat-openbsd \
|
||||
&& 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
|
||||
RUN curl -fsSL https://dl.dagger.io/dagger/install.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,15 +1,111 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [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:
|
||||
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'
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Full Project Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Dagger Remote Engine
|
||||
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:
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -17,13 +17,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect Android and Linux changes
|
||||
id: diff
|
||||
shell: bash
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# On workflow_dispatch always build everything
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
@@ -32,106 +30,62 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
# Find the most recent workflow run where deploy-playstore actually succeeded
|
||||
# (not merely skipped). Bug fix: previous code used commit_sha (always None in
|
||||
# Forgejo's API) instead of head_sha, causing LAST_DEPLOYED_SHA to be empty on
|
||||
# every run and the fallback diff to only cover HEAD~1..HEAD.
|
||||
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
|
||||
import json, os, sys, urllib.request
|
||||
token = os.environ.get("FORGEJO_TOKEN", "")
|
||||
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
base_api = f"{server}/api/v1/repos/{repo}/actions"
|
||||
url = f"{base_api}/runs?workflow_id=deploy.yml&status=success&limit=10"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
data = json.loads(r.read())
|
||||
runs = [
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("status") == "success"
|
||||
]
|
||||
# Walk runs newest-first; pick the first one where deploy-playstore
|
||||
# actually ran (conclusion=success), not just skipped.
|
||||
for run in runs:
|
||||
run_id = run.get("id")
|
||||
jobs_url = f"{base_api}/runs/{run_id}/jobs"
|
||||
jobs_req = urllib.request.Request(jobs_url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(jobs_req) as jr:
|
||||
jobs_data = json.loads(jr.read())
|
||||
for job in jobs_data.get("workflow_jobs", []):
|
||||
if "Deploy to Play Store" in job.get("name", "") and (
|
||||
job.get("conclusion") == "success" or
|
||||
job.get("status") == "success"
|
||||
):
|
||||
print(run.get("head_sha") or "")
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass # skip this run if jobs API fails
|
||||
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 all targets as a precaution"
|
||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
|
||||
echo "::notice::All deploys SKIPPED — HEAD $HEAD_SHA was already successfully deployed"
|
||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_reason=commit $HEAD_SHA was already successfully deployed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff from the last successfully deployed commit to catch all changes since
|
||||
# that deploy, not just the most recent commit. Deploy all targets when the
|
||||
# SHA is not in local history (shallow clone or very old deploy).
|
||||
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 all targets as a precaution"
|
||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
|
||||
# when the parent is unavailable (initial commit, shallow clone).
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
|| git show --name-only --format= HEAD)
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
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/)'
|
||||
linux_re='^(linux/|lib/|pubspec\.yaml|pubspec\.lock)'
|
||||
|
||||
if echo "$CHANGED" | grep -qE "$android_re"; then
|
||||
echo "android=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Android deploy: TRIGGERED (android-relevant files changed)"
|
||||
echo "::notice::Android deploy TRIGGERED — android-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||
else
|
||||
echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Android deploy: SKIPPED (no android-relevant files changed)"
|
||||
echo "::notice::Android deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no android-relevant changes"
|
||||
fi
|
||||
echo "$CHANGED" | grep -qE "$android_re" \
|
||||
&& echo "android=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "android=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if echo "$CHANGED" | grep -qE "$linux_re"; then
|
||||
echo "linux=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Linux deploy: TRIGGERED (linux-relevant files changed)"
|
||||
echo "::notice::Linux deploy TRIGGERED — linux-relevant files changed since $LAST_DEPLOYED_SHA"
|
||||
else
|
||||
echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Linux deploy: SKIPPED (no linux-relevant files changed)"
|
||||
echo "::notice::Linux deploy SKIPPED — diff $LAST_DEPLOYED_SHA..HEAD has no linux-relevant changes"
|
||||
fi
|
||||
echo "$CHANGED" | grep -qE "$linux_re" \
|
||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
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: Run Android Tests on Firebase Test Lab
|
||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||
env:
|
||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task test-android-firebase
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
@@ -143,29 +97,34 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Publish Android to Play Store
|
||||
if: ${{ secrets.PLAY_STORE_CONFIG_JSON != '' }}
|
||||
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"
|
||||
run: task publish-android
|
||||
|
||||
- name: Verify Play Store deployment
|
||||
run: |
|
||||
python3 -m venv /tmp/playstore-venv
|
||||
/tmp/playstore-venv/bin/pip install google-auth requests --quiet
|
||||
/tmp/playstore-venv/bin/python3 scripts/verify_playstore_deploy.py
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-apk:
|
||||
name: Build & Deploy APK to Server
|
||||
@@ -177,23 +136,36 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Build & Deploy APK to server
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
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"
|
||||
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:
|
||||
name: Build Linux Release
|
||||
@@ -205,30 +177,83 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 100
|
||||
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
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
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
|
||||
|
||||
- name: Build & Deploy Linux to server
|
||||
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
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_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:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy-playstore, deploy-apk, build-linux]
|
||||
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||
if: |
|
||||
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
||||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||
@@ -241,7 +266,7 @@ jobs:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
|
||||
ALL_SUCCEEDED: ${{ (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||
ALL_SUCCEEDED: ${{ (needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'skipped') && (needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'skipped') && (needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'skipped') && (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') }}
|
||||
run: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, json, urllib.request, urllib.error
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
name: Firebase Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # once per day at 3 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Detect Firebase-Relevant 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 Firebase-relevant changes in last 24 hours
|
||||
id: diff
|
||||
shell: bash
|
||||
run: |
|
||||
# On workflow_dispatch always run
|
||||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SINCE=$(date -u -d '24 hours ago' '+%Y-%m-%dT%H:%M:%S')
|
||||
CHANGED=$(git log --since="$SINCE" --name-only --format= -- \
|
||||
'android/' 'integration_test/' 'lib/' 'pubspec.yaml' 'pubspec.lock' 'drift_schemas/' \
|
||||
| sort -u | grep -v '^$')
|
||||
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "Firebase-relevant files changed since $SINCE:"
|
||||
echo "$CHANGED"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No Firebase-relevant changes in the last 24 hours — skipping tests"
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.has_changes == 'true'
|
||||
|
||||
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; }
|
||||
|
||||
- name: Setup Dagger Remote Engine
|
||||
env:
|
||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
env:
|
||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task test-android-firebase
|
||||
|
||||
- name: Create issue on test failure
|
||||
if: failure()
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, json, urllib.request, urllib.error
|
||||
|
||||
token = os.environ["FORGEJO_TOKEN"]
|
||||
url_base = os.environ["FORGEJO_URL"].rstrip("/")
|
||||
run_url = os.environ["RUN_URL"]
|
||||
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
api = f"{url_base}/api/v1/repos/guettli/sharedinbox"
|
||||
|
||||
def api_get(path):
|
||||
req = urllib.request.Request(f"{api}{path}", headers=headers)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
def api_post(path, body):
|
||||
data = json.dumps(body).encode()
|
||||
req = urllib.request.Request(f"{api}{path}", data=data, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
repo_labels = api_get("/labels")
|
||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||
|
||||
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
||||
|
||||
title = "Firebase Tests failed — find root cause and fix"
|
||||
body = (
|
||||
"Firebase instrumented tests failed in the daily run.\n\n"
|
||||
f"**Failed run:** {run_url}\n\n"
|
||||
"## Steps to resolve\n\n"
|
||||
"1. **Find the root cause**: Check the test run logs linked above and identify which test(s) failed and why.\n"
|
||||
"2. **Fix if possible**: If the failure is caused by a code bug, create a fix. If it is a flaky or infrastructure issue, document the findings.\n"
|
||||
"3. Close this issue once the root cause is resolved and the tests pass.\n"
|
||||
)
|
||||
|
||||
issue = api_post("/issues", {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"labels": label_ids,
|
||||
})
|
||||
print(f"Created issue #{issue['number']}: {issue['html_url']}")
|
||||
PYEOF
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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; }
|
||||
|
||||
- name: Setup Dagger Remote Engine
|
||||
env:
|
||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Renovate
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task renovate
|
||||
@@ -1,8 +1,6 @@
|
||||
name: Update Website
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *' # every hour on the hour
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
@@ -13,31 +11,22 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build & Update Website
|
||||
name: Build & Deploy Website
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- 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; }
|
||||
|
||||
- name: Setup Dagger Remote Engine
|
||||
- name: Build & Deploy Website
|
||||
env:
|
||||
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Build & Update Website
|
||||
env:
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task publish-website
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
run: task website-deploy
|
||||
|
||||
- name: Verify Website
|
||||
env:
|
||||
SSH_HOST: ${{ env.WEBSITE_SSH_HOST }}
|
||||
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
|
||||
run: scripts/website-verify.sh
|
||||
|
||||
@@ -10,7 +10,6 @@ jobs:
|
||||
# Disabled until a self-hosted runner with label "windows-runner" is registered.
|
||||
name: Build & Deploy Windows (Nightly)
|
||||
runs-on: windows-runner
|
||||
timeout-minutes: 90
|
||||
if: false
|
||||
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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
|
||||
|
||||
- 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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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 -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh -o StrictHostKeyChecking=no "$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' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
"$SSH_USER@$SSH_HOST:public_html/"
|
||||
@@ -1,6 +1,5 @@
|
||||
# --- Flutter/Dart ---
|
||||
coverage/
|
||||
screenshots/
|
||||
.dart_tool/
|
||||
.dart-tool/
|
||||
.packages
|
||||
@@ -29,8 +28,7 @@ android/.gradle/
|
||||
android/local.properties
|
||||
android/app/google-services.json
|
||||
android/key.properties
|
||||
# android/app/src/main/java/io/flutter/plugins/ intentionally tracked so that
|
||||
# GeneratedPluginRegistrant.java (catch Throwable) is committed and used by CI.
|
||||
android/app/src/main/java/io/flutter/plugins/
|
||||
.android/
|
||||
Android/
|
||||
.gradle/
|
||||
|
||||
@@ -10,11 +10,6 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/guettli/sync-branch
|
||||
rev: v0.0.11
|
||||
hooks:
|
||||
- id: sync-branch
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-no-binary
|
||||
@@ -32,24 +27,18 @@ repos:
|
||||
- id: dart-check
|
||||
name: dart format (autofix) + check-fast (parallel)
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command 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
|
||||
always_run: true
|
||||
- id: ci-no-direct-dagger
|
||||
name: check for direct dagger calls in workflows (use Task instead)
|
||||
language: system
|
||||
entry: "bash -c 'git --no-pager grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'"
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: dagger-progress-plain
|
||||
name: ensure all dagger calls use --progress=plain
|
||||
language: system
|
||||
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 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
|
||||
always_run: true
|
||||
- id: ci-image-exists
|
||||
name: verify container images in ci/main.go are reachable
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
||||
pass_filenames: false
|
||||
files: ^(ci/main\.go|\.fvmrc)$
|
||||
|
||||
@@ -8,41 +8,32 @@ CLI tool `fgj` is available to query issues/PRs/actions.
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| `loop/plan` | Planning agent reads the issue and writes an implementation plan as a comment | Issue moves to `loop/plan-done` |
|
||||
| `loop/code` | Coding agent implements the change, creates a branch + PR | Issue moves to `loop/code-done` |
|
||||
- **State/Ready** — Issue is available to pick up
|
||||
- **State/InProgress** — Set this when you start working on an issue
|
||||
- **State/Question** — Set this when you hit a blocker or need clarification
|
||||
|
||||
**State machine:**
|
||||
List open issues ready to pick up:
|
||||
|
||||
```
|
||||
loop/plan → loop/plan-in-progress → loop/plan-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
|
||||
loop/code → loop/code-in-progress → loop/code-done
|
||||
↘ NeedSupervisor (on failure)
|
||||
```bash
|
||||
fgj issue list --json --state open | jq '[.[] | select(.labels[].name == "State/Ready")] | .[] | {number, title, html_url}'
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
Rules:
|
||||
|
||||
- Only issues authored by allowed users are picked up (guettli, guettlibot, guettlibot2, forgejo-actions).
|
||||
- An issue with `NeedSupervisor` needs human attention — investigate, fix, then re-label.
|
||||
- The coding agent opens a PR but does NOT close the issue. A human reviews the PR and closes the issue after merging.
|
||||
- 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:**
|
||||
|
||||
```
|
||||
1. Create issue
|
||||
2. Add label loop/plan → agent writes plan as comment
|
||||
3. Review plan, request changes or approve
|
||||
4. Add label loop/code → agent implements + opens PR
|
||||
5. Review PR, merge
|
||||
6. Close issue
|
||||
```
|
||||
- Never start work on an issue without `State/Ready`
|
||||
- When working via the agent loop: `State/Ready` → `State/InProgress` is set automatically
|
||||
by `agent_loop.py` before the agent starts — do **not** set it 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
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ WorkingDirectory=/home/dagger-svc
|
||||
# Replace 1003 with the actual UID of dagger-svc
|
||||
Environment=DOCKER_HOST=unix:///run/user/1003/podman/podman.sock
|
||||
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
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -188,5 +188,3 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
|
||||
## Daily Workflow
|
||||
|
||||
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
|
||||
|
||||
<!-- agentloop code test passed -->
|
||||
|
||||
@@ -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
|
||||
deps: [_nix-check]
|
||||
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}"
|
||||
msg: "Not in nix dev shell. Run: nix develop"
|
||||
cmds:
|
||||
@@ -58,14 +56,6 @@ tasks:
|
||||
cmds:
|
||||
- echo "Setup complete."
|
||||
|
||||
generate-icons:
|
||||
desc: Rasterise icon.svg → icon.png and regenerate all platform launcher icons
|
||||
deps: [_pub-get]
|
||||
cmds:
|
||||
- rsvg-convert -w 1024 -h 1024 icon.svg -o icon.png
|
||||
- rsvg-convert -w 512 -h 512 icon.svg -o playstore/icon.png
|
||||
- fvm flutter pub run flutter_launcher_icons
|
||||
|
||||
generate-changelog:
|
||||
desc: Generate assets/changelog.txt from git history
|
||||
cmds:
|
||||
@@ -106,19 +96,34 @@ tasks:
|
||||
- scripts/silent_on_success.sh fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
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:
|
||||
- dagger call --progress=plain -q -m ci --source=. codegen -o .
|
||||
- fvm flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
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:
|
||||
- dagger call --progress=plain -q -m ci --source=. analyze
|
||||
- scripts/run_analyze.sh
|
||||
|
||||
format:
|
||||
desc: Format all Dart source files via Dagger (writes back to host)
|
||||
desc: Format all Dart source files
|
||||
deps: [_preflight]
|
||||
sources:
|
||||
- "**/*.dart"
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. format-write -o .
|
||||
- fvm dart format lib test
|
||||
|
||||
check-mocks:
|
||||
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
|
||||
|
||||
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:
|
||||
- dagger call --progress=plain -q -m ci --source=. analyze-fix -o .
|
||||
- fvm dart fix --apply
|
||||
|
||||
test:
|
||||
desc: Unit tests + coverage gate (fails if any non-excluded lib/ file is missing)
|
||||
@@ -168,17 +177,17 @@ tasks:
|
||||
test-backend:
|
||||
desc: Backend tests against a local Stalwart mail server (via Dagger)
|
||||
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:
|
||||
desc: UI E2E tests on Linux via Xvfb — headless, no emulator needed (via Dagger)
|
||||
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:
|
||||
desc: Run sync reliability runner (via Dagger)
|
||||
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:
|
||||
desc: Build Android debug APKs and run instrumented tests on Firebase Test Lab (via Dagger)
|
||||
@@ -193,7 +202,7 @@ tasks:
|
||||
ci-graph:
|
||||
desc: Print a Mermaid diagram of the CI pipeline — paste into mermaid.live or any Markdown renderer
|
||||
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:
|
||||
desc: Start a Stalwart instance for local development (via Dagger)
|
||||
@@ -206,16 +215,14 @@ tasks:
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
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 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST" --commit-hash "$HASH"
|
||||
|
||||
build-android-bundle:
|
||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||
cmds:
|
||||
- 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
|
||||
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
||||
|
||||
upload-android-bundle:
|
||||
desc: Upload AAB from build/ to Play Store via Dagger
|
||||
@@ -225,11 +232,10 @@ tasks:
|
||||
- sh: test -f build/app/outputs/bundle/release/app-release.aab
|
||||
msg: "AAB not found — run build-android-bundle first"
|
||||
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:
|
||||
desc: Build cached AAB, stamp versionCode, sign, and publish to Play Store via Dagger
|
||||
deps: [generate-changelog]
|
||||
preconditions:
|
||||
- sh: test -n "$PLAY_STORE_CONFIG_JSON"
|
||||
msg: "PLAY_STORE_CONFIG_JSON is not set"
|
||||
@@ -238,31 +244,24 @@ tasks:
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
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"
|
||||
- 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
|
||||
|
||||
deploy-apk:
|
||||
desc: Build and deploy Android APK via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_BASE64"
|
||||
msg: "ANDROID_KEYSTORE_BASE64 is not set"
|
||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||
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 --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:
|
||||
desc: Build and publish website via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$SSH_PRIVATE_KEY"
|
||||
msg: "SSH_PRIVATE_KEY is not set"
|
||||
- sh: test -n "$SSH_KNOWN_HOSTS"
|
||||
msg: "SSH_KNOWN_HOSTS is not set"
|
||||
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 file:$HOME/.ssh/id_ed25519 --ssh-user "$SSH_USER" --ssh-host "$SSH_HOST"
|
||||
|
||||
check-dagger:
|
||||
desc: Run full check suite via Dagger (with OTEL timing report if python3 is available)
|
||||
@@ -285,11 +284,11 @@ tasks:
|
||||
for attempt in 1 2 3; do
|
||||
run_dagger "$@" && return 0
|
||||
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
|
||||
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
|
||||
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
|
||||
sleep 90
|
||||
else
|
||||
@@ -310,16 +309,7 @@ tasks:
|
||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
until [ -s "$PORTFILE" ]; do
|
||||
sleep 0.05
|
||||
if ! kill -0 "$RECV_PID" 2>/dev/null; then
|
||||
echo "$(_ts) otel-receiver.py died before writing port file; falling back to plain run" >&2
|
||||
retry_dagger dagger call --progress=plain -q -m ci --source=. check
|
||||
RC=$?
|
||||
rm -f "$PORTFILE" "$DAGGER_OUT" "$RC_FILE"
|
||||
exit $RC
|
||||
fi
|
||||
done
|
||||
until [ -s "$PORTFILE" ]; do sleep 0.05; done
|
||||
PORT=$(cat "$PORTFILE")
|
||||
retry_dagger env \
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:$PORT" \
|
||||
@@ -336,14 +326,6 @@ tasks:
|
||||
- |
|
||||
dagger query '{ engine { localCache { prune(maxUsedSpace: "75gb", targetSpace: "50gb") } } }'
|
||||
|
||||
renovate:
|
||||
desc: Run Renovate bot against the repository via Dagger
|
||||
preconditions:
|
||||
- sh: test -n "$RENOVATE_FORGEJO_TOKEN"
|
||||
msg: "RENOVATE_FORGEJO_TOKEN is not set"
|
||||
cmds:
|
||||
- timeout --kill-after=10 1800 dagger call --progress=plain -q -m ci --source=. renovate --renovate-token env:RENOVATE_FORGEJO_TOKEN
|
||||
|
||||
integration-android:
|
||||
desc: UI integration tests on a connected Android emulator (Stalwart on host, emulator reaches it via 10.0.2.2)
|
||||
deps: [_preflight, _android-sdk-check, _android-avd-setup]
|
||||
@@ -391,48 +373,28 @@ tasks:
|
||||
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
|
||||
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"
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL"
|
||||
# Merge with any existing latest.json so we don't overwrite the windows key
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$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"
|
||||
ssh -o StrictHostKeyChecking=no "$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"
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
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:
|
||||
desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC
|
||||
deps: [_pub-get, generate-changelog]
|
||||
@@ -454,28 +416,24 @@ tasks:
|
||||
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
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
ZIPFILE="sharedinbox-windows-x64-$HASH.zip"
|
||||
cd build/windows/x64/runner && zip -r /tmp/$ZIPFILE Release/ && cd -
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no /tmp/$ZIPFILE "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$ZIPFILE"
|
||||
DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$ZIPFILE"
|
||||
EXISTING=$(ssh "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
EXISTING=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat public_html/latest.json 2>/dev/null || echo '{}'")
|
||||
LINUX_URL=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('linux',''))" 2>/dev/null || true)
|
||||
if [ -n "$LINUX_URL" ]; then
|
||||
echo "{\"version\":\"$HASH\",\"linux\":\"$LINUX_URL\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
else
|
||||
echo "{\"version\":\"$HASH\",\"windows\":\"$DOWNLOAD_URL\"}" | \
|
||||
ssh "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json"
|
||||
fi
|
||||
echo "Uploaded $ZIPFILE and updated latest.json"
|
||||
|
||||
@@ -529,10 +487,18 @@ tasks:
|
||||
cmds:
|
||||
- ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk} fvm flutter build apk --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) | grep -Ev "was tree-shaken|Tree-shaking can be disabled"
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
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:
|
||||
desc: Build a release App Bundle (AAB) locally via fvm (not Dagger)
|
||||
deps: [_preflight, _android-sdk-check, _codegen, generate-changelog]
|
||||
dotenv: [".env"]
|
||||
method: timestamp
|
||||
sources:
|
||||
- lib/**/*.dart
|
||||
@@ -541,14 +507,7 @@ tasks:
|
||||
generates:
|
||||
- build/app/outputs/bundle/release/app-release.aab
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
- sops exec-env secrets.enc.yaml 'python3 scripts/deploy_playstore.py'
|
||||
- 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:
|
||||
desc: Build release APK and upload via scp to $ANDROID_APK_SCP_USER@$ANDROID_APK_SCP_HOST:$ANDROID_APK_SCP_PATH
|
||||
@@ -575,7 +534,7 @@ tasks:
|
||||
|
||||
run:
|
||||
desc: Run the app on Linux desktop
|
||||
deps: [_preflight, _linux-deps-check, _pub-get, _codegen]
|
||||
deps: [_preflight, _linux-deps-check, _pub-get]
|
||||
cmds:
|
||||
- fvm flutter run -d linux --no-pub
|
||||
|
||||
@@ -624,18 +583,14 @@ tasks:
|
||||
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
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
DATE_PATH=$(date -u +%Y/%m/%d)
|
||||
REMOTE_DIR="public_html/builds/$DATE_PATH"
|
||||
APK_NAME="sharedinbox-mua-$HASH.apk"
|
||||
ssh "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp \
|
||||
ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR"
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
build/app/outputs/flutter-apk/app-release.apk \
|
||||
"$SSH_USER@$SSH_HOST:$REMOTE_DIR/$APK_NAME"
|
||||
echo "Uploaded $APK_NAME to $REMOTE_DIR"
|
||||
@@ -664,23 +619,18 @@ tasks:
|
||||
website-deploy:
|
||||
desc: Deploy the website via rsync to public_html
|
||||
deps: [website-build]
|
||||
preconditions:
|
||||
- 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
|
||||
rsync -avz --delete \
|
||||
--exclude='*.apk' \
|
||||
--exclude='*.tar.gz' \
|
||||
-e "ssh -o StrictHostKeyChecking=no" \
|
||||
website/public/ \
|
||||
${SSH_USER}@${SSH_HOST}:public_html/
|
||||
|
||||
check-fast:
|
||||
desc: Pre-commit checks via Dagger (format, analyze, mocks, coverage — no integration or backend)
|
||||
cmds:
|
||||
- dagger call --progress=plain -q -m ci --source=. check-fast
|
||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||
deps: [analyze, check-coverage, check-hygiene, check-layers, check-mocks]
|
||||
|
||||
check-layers:
|
||||
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
|
||||
@@ -707,11 +657,6 @@ tasks:
|
||||
fi
|
||||
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
|
||||
|
||||
_integrations:
|
||||
internal: true
|
||||
run: once
|
||||
@@ -724,17 +669,6 @@ tasks:
|
||||
cmds:
|
||||
- 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:
|
||||
desc: Full check suite — unit tests first, then integration (merges coverage), then gate
|
||||
deps: [analyze, build-linux, test]
|
||||
|
||||
@@ -4,6 +4,7 @@ gradle-wrapper.jar
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
|
||||
@@ -16,23 +16,19 @@ android {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
val ksPath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
|
||||
|
||||
if (ksPath != null) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = "upload"
|
||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD") ?: ""
|
||||
storePassword = pass
|
||||
keyPassword = pass
|
||||
storeFile = file(ksPath)
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
// Hardcoded alias matching t.sh
|
||||
keyAlias = "upload"
|
||||
// Use the same password for both key and keystore
|
||||
val pass = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
storePassword = pass
|
||||
keyPassword = pass
|
||||
storeFile = file("upload-keystore.jks")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +44,14 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (ksPath != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
// Use the signing config defined above for release builds.
|
||||
// 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
|
||||
isShrinkResources = false
|
||||
ndk {
|
||||
@@ -66,7 +67,7 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
// 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
|
||||
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
|
||||
// references its class in all variants. Make it available for release compilation
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package io.flutter.plugins;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.Log;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
/**
|
||||
* Generated file. Do not edit.
|
||||
* This file is generated by the Flutter tool based on the
|
||||
* plugins that support the Android platform.
|
||||
*/
|
||||
@Keep
|
||||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.flutter.plugins.integration_test.IntegrationTestPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin integration_test, dev.flutter.plugins.integration_test.IntegrationTestPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.steenbakker.mobile_scanner.MobileScannerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin mobile_scanner, dev.steenbakker.mobile_scanner.MobileScannerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.crazecoder.openfile.OpenFilePlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin open_filex, com.crazecoder.openfile.OpenFilePlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin workmanager_android, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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 {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -2,4 +2,52 @@ module dagger/ci
|
||||
|
||||
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/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 (
|
||||
"context"
|
||||
"dagger/ci/internal/dagger"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -149,33 +148,16 @@ if __name__ == "__main__":
|
||||
`
|
||||
|
||||
type Ci struct {
|
||||
Source *dagger.Directory
|
||||
FlutterVersion string
|
||||
Source *dagger.Directory
|
||||
}
|
||||
|
||||
func New(
|
||||
ctx context.Context,
|
||||
// +defaultPath=".."
|
||||
source *dagger.Directory,
|
||||
) (*Ci, error) {
|
||||
fvmrcContents, err := source.File(".fvmrc").Contents(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read .fvmrc: %w", err)
|
||||
}
|
||||
var fvmrc struct {
|
||||
Flutter string `json:"flutter"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(fvmrcContents), &fvmrc); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse .fvmrc: %w", err)
|
||||
}
|
||||
if fvmrc.Flutter == "" {
|
||||
return nil, fmt.Errorf(".fvmrc is missing the 'flutter' field")
|
||||
}
|
||||
) *Ci {
|
||||
return &Ci{
|
||||
FlutterVersion: fvmrc.Flutter,
|
||||
Source: source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{
|
||||
".fvmrc",
|
||||
"lib/",
|
||||
"test/",
|
||||
"assets/",
|
||||
@@ -191,7 +173,7 @@ func New(
|
||||
"website/",
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *Ci) toolchain() *dagger.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", "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"}).
|
||||
@@ -213,8 +195,7 @@ func (m *Ci) toolchain() *dagger.Container {
|
||||
WithUser("ci").
|
||||
WithExec([]string{"/bin/sh", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`}).
|
||||
WithExec([]string{"flutter", "precache", "--linux", "--no-android", "--no-ios"})
|
||||
`yes | sdkmanager "ndk;28.2.13676358" "cmake;3.22.1" "build-tools;35.0.0" "platforms;android-34" >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }`})
|
||||
}
|
||||
|
||||
// Base is the Flutter toolchain container with mutable cache mounts attached.
|
||||
@@ -304,21 +285,6 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
|
||||
})
|
||||
}
|
||||
|
||||
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
|
||||
// Gradle dependencies survive across Dagger execution-cache misses.
|
||||
func (m *Ci) androidBase() *dagger.Container {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
|
||||
func (m *Ci) firebaseBase() *dagger.Container {
|
||||
return m.setup(m.firebaseSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||
}
|
||||
|
||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
@@ -352,23 +318,12 @@ func (m *Ci) Hugo() *dagger.Container {
|
||||
}
|
||||
|
||||
// Deploy container for rsync/ssh
|
||||
func (m *Ci) Deployer(sshKey *dagger.Secret, knownHosts *dagger.Secret) *dagger.Container {
|
||||
func (m *Ci) Deployer(sshKey *dagger.Secret) *dagger.Container {
|
||||
return dag.Container().
|
||||
From("alpine:3.21").
|
||||
WithExec([]string{"apk", "--no-cache", "add", "rsync", "openssh-client", "python3", "tar"}).
|
||||
// Create .ssh with strict permissions before Dagger mounts anything there,
|
||||
// so the directory is 700 (not Dagger's default 755).
|
||||
WithExec([]string{"sh", "-c", "mkdir -p /root/.ssh && chmod 700 /root/.ssh"}).
|
||||
// Mount the raw key outside .ssh so Dagger cannot override the directory
|
||||
// permissions we just set above.
|
||||
WithMountedSecret("/tmp/id_ed25519.raw", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
// Normalise with Python3: strip CRLF/bare-CR, ensure trailing newline.
|
||||
// Using Python3 (not tr) changes the Dagger cache key so stale cached
|
||||
// results from the old tr-based step are not reused.
|
||||
WithExec([]string{"python3", "-c",
|
||||
"import os; raw=open('/tmp/id_ed25519.raw','rb').read(); key=raw.replace(b'\\r\\n',b'\\n').replace(b'\\r',b'\\n'); key=key if key.endswith(b'\\n') else key+b'\\n'; open('/root/.ssh/id_ed25519','wb').write(key); os.chmod('/root/.ssh/id_ed25519',0o600)"}).
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -i /root/.ssh/id_ed25519")
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithEnvVariable("RSYNC_RSH", "ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519")
|
||||
}
|
||||
|
||||
// Stalwart mail server service for backend and integration tests.
|
||||
@@ -440,73 +395,11 @@ func (m *Ci) Format(ctx context.Context) (string, error) {
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// FormatWrite formats Dart files and exports the modified /src directory.
|
||||
func (m *Ci) FormatWrite() *dagger.Directory {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"dart", "format", "lib", "test"}).
|
||||
Directory("/src")
|
||||
}
|
||||
|
||||
// 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 snapshots the committed source (including any stale generated files) before
|
||||
// CheckMocks verifies that generated mocks are up to date.
|
||||
// It snapshots the committed source (including any stale *.mocks.dart) before
|
||||
// running build_runner, so git diff detects real staleness instead of always
|
||||
// comparing two freshly-generated outputs.
|
||||
func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
||||
func (m *Ci) CheckMocks(ctx context.Context) (string, error) {
|
||||
return m.pubGetLayer().
|
||||
WithDirectory("/src", m.checkSrc(), dagger.ContainerWithDirectoryOpts{Owner: "ci"}).
|
||||
WithWorkdir("/src").
|
||||
@@ -519,16 +412,16 @@ func (m *Ci) CheckGenerated(ctx context.Context) (string, error) {
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter pub run build_runner build --delete-conflicting-outputs >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -vE '^\[.*s\] \|' "$tmp" || true`}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . \\( -name '*.g.dart' -o -name '*.mocks.dart' \\) | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Generated files are out of date — run: dart run build_runner build\"; exit 1; fi; echo \"Generated files are up to date.\""}).
|
||||
WithExec([]string{"/bin/bash", "-c", "CHANGED=$(find . -name '*.mocks.dart' | xargs -r git diff --exit-code); if [ $? -ne 0 ]; then echo \"ERROR: Mocks are out of date\"; exit 1; fi; echo \"Mocks are up to date.\""}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return m.setup(m.checkSrc()).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`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"`}).
|
||||
WithExec([]string{"dart", "scripts/check_coverage.dart"}).
|
||||
Stdout(ctx)
|
||||
@@ -565,33 +458,16 @@ func (m *Ci) TestSyncReliability(ctx context.Context) (string, error) {
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// ChaosMonkeyBackend runs random IMAP/SMTP operations against Stalwart to surface crashes.
|
||||
func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
|
||||
return m.WithStalwart(m.setup(m.backendSrc())).
|
||||
WithExec([]string{"/bin/bash", "-c",
|
||||
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
|
||||
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
|
||||
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Check runs the full check suite.
|
||||
func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Run cheap structural checks in parallel for faster fail detection.
|
||||
var fastEg errgroup.Group
|
||||
fastEg.Go(func() error {
|
||||
_, err := m.CheckHygiene(ctx)
|
||||
return err
|
||||
})
|
||||
fastEg.Go(func() error {
|
||||
_, err := m.CheckLayers(ctx)
|
||||
return err
|
||||
})
|
||||
if err := fastEg.Wait(); err != nil {
|
||||
return "", err
|
||||
if _, err := m.CheckHygiene(ctx); err != nil {
|
||||
return "Hygiene check failed", err
|
||||
}
|
||||
if _, err := m.CheckLayers(ctx); err != nil {
|
||||
return "Layer check failed", err
|
||||
}
|
||||
|
||||
checkSetup := m.setup(m.checkSrc())
|
||||
@@ -605,7 +481,7 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
return analyze, err
|
||||
}
|
||||
|
||||
mocks, err := m.CheckGenerated(ctx)
|
||||
mocks, err := m.CheckMocks(ctx)
|
||||
if err != nil {
|
||||
return mocks, err
|
||||
}
|
||||
@@ -615,19 +491,16 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
return coverage, 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 eg errgroup.Group
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testBackend, e = m.TestBackend(ctx)
|
||||
testBackend, e = m.TestBackend(egCtx)
|
||||
return e
|
||||
})
|
||||
eg.Go(func() error {
|
||||
var e error
|
||||
testIntegration, e = m.TestIntegration(ctx)
|
||||
testIntegration, e = m.TestIntegration(egCtx)
|
||||
return e
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
@@ -641,7 +514,6 @@ func (m *Ci) Check(ctx context.Context) (string, error) {
|
||||
func (m *Ci) GenerateBuildHistory(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
) *dagger.Directory {
|
||||
@@ -653,7 +525,7 @@ func (m *Ci) GenerateBuildHistory(
|
||||
From("python:3.12-alpine").
|
||||
WithExec([]string{"apk", "add", "--no-cache", "openssh-client"}).
|
||||
WithMountedSecret("/root/.ssh/id_ed25519", sshKey, dagger.ContainerWithMountedSecretOpts{Mode: 0600}).
|
||||
WithMountedSecret("/root/.ssh/known_hosts", knownHosts, dagger.ContainerWithMountedSecretOpts{Mode: 0644}).
|
||||
WithExec([]string{"chmod", "700", "/root/.ssh"}).
|
||||
WithEnvVariable("SSH_USER", sshUser).
|
||||
WithEnvVariable("SSH_HOST", sshHost).
|
||||
WithDirectory("/src", scriptSource).
|
||||
@@ -666,25 +538,18 @@ func (m *Ci) GenerateBuildHistory(
|
||||
func (m *Ci) BuildWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.Directory {
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, knownHosts, sshUser, sshHost)
|
||||
buildHistory := m.GenerateBuildHistory(ctx, sshKey, sshUser, sshHost)
|
||||
|
||||
websiteSource := m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||
Include: []string{"website/"},
|
||||
}).WithDirectory("website/content/builds", buildHistory)
|
||||
|
||||
hugo := m.Hugo().
|
||||
return m.Hugo().
|
||||
WithDirectory("/src", websiteSource).
|
||||
WithWorkdir("/src/website")
|
||||
if commitHash != "" {
|
||||
hugo = hugo.WithEnvVariable("HUGO_PARAMS_GITVERSION", commitHash)
|
||||
}
|
||||
return hugo.
|
||||
WithWorkdir("/src/website").
|
||||
WithExec([]string{"hugo", "--minify"}).
|
||||
Directory("public")
|
||||
}
|
||||
@@ -693,15 +558,12 @@ func (m *Ci) BuildWebsite(
|
||||
func (m *Ci) PublishWebsite(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
// +optional
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
public := m.BuildWebsite(ctx, sshKey, knownHosts, sshUser, sshHost, commitHash)
|
||||
public := m.BuildWebsite(ctx, sshKey, sshUser, sshHost)
|
||||
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
return m.Deployer(sshKey).
|
||||
WithDirectory("/public", public).
|
||||
WithExec([]string{"rsync", "-avz", "--delete",
|
||||
"--exclude=*.apk", "--exclude=*.tar.gz",
|
||||
@@ -717,17 +579,9 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
||||
}
|
||||
|
||||
// BuildLinuxRelease builds the Linux release bundle.
|
||||
func (m *Ci) BuildLinuxRelease(
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.Directory {
|
||||
args := []string{"flutter", "build", "linux", "--release"}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
||||
return m.setup(m.linuxSrc()).
|
||||
WithExec(args).
|
||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
||||
Directory("build/linux/x64/release/bundle")
|
||||
}
|
||||
|
||||
@@ -735,49 +589,36 @@ func (m *Ci) BuildLinuxRelease(
|
||||
func (m *Ci) DeployLinux(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
bundle := m.BuildLinuxRelease(commitHash)
|
||||
bundle := m.BuildLinuxRelease()
|
||||
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
tarball := fmt.Sprintf("sharedinbox-linux-amd64-%s.tar.gz", commitHash)
|
||||
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
return m.Deployer(sshKey).
|
||||
WithDirectory("/bundle", bundle).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("tar -czf /tmp/%s -C /bundle .", tarball)}).
|
||||
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/%s %s@%s:%s/%s", tarball, sshUser, sshHost, remoteDir, tarball)}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// setupKeystore decodes the base64 keystore into the android build container.
|
||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||
return m.androidBase().
|
||||
return m.setup(m.androidSrc()).
|
||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/upload-keystore.jks`}).
|
||||
WithEnvVariable("ANDROID_KEYSTORE_PATH", "/tmp/upload-keystore.jks")
|
||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||
}
|
||||
|
||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||
func (m *Ci) BuildAndroidApk(
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.File {
|
||||
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||
WithExec(args).
|
||||
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||
}
|
||||
|
||||
@@ -785,7 +626,6 @@ func (m *Ci) BuildAndroidApk(
|
||||
func (m *Ci) DeployApk(
|
||||
ctx context.Context,
|
||||
sshKey *dagger.Secret,
|
||||
knownHosts *dagger.Secret,
|
||||
sshUser string,
|
||||
sshHost string,
|
||||
commitHash string,
|
||||
@@ -793,23 +633,24 @@ func (m *Ci) DeployApk(
|
||||
keystorePassword *dagger.Secret,
|
||||
buildNumber string,
|
||||
) (string, error) {
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
||||
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||
apkName := fmt.Sprintf("sharedinbox-mua-%s.apk", commitHash)
|
||||
|
||||
return m.Deployer(sshKey, knownHosts).
|
||||
return m.Deployer(sshKey).
|
||||
WithFile("/tmp/app.apk", apk).
|
||||
WithExec([]string{"ssh", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||
WithExec([]string{"ssh", "-o", "StrictHostKeyChecking=no", "-i", "/root/.ssh/id_ed25519", fmt.Sprintf("%s@%s", sshUser, sshHost), fmt.Sprintf("mkdir -p %s", remoteDir)}).
|
||||
WithExec([]string{"/bin/sh", "-c", fmt.Sprintf("scp -o StrictHostKeyChecking=no -i /root/.ssh/id_ed25519 /tmp/app.apk %s@%s:%s/%s", sshUser, sshHost, remoteDir, apkName)}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||
built := m.firebaseBase().
|
||||
built := m.setup(m.firebaseSrc()).
|
||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||
@@ -868,17 +709,9 @@ func (m *Ci) TestAndroidFirebase(
|
||||
|
||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||
func (m *Ci) BuildAndroidRelease(
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) *dagger.File {
|
||||
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
|
||||
if commitHash != "" {
|
||||
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||
}
|
||||
return m.androidBase().
|
||||
WithExec(args).
|
||||
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
||||
return m.setup(m.androidSrc()).
|
||||
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
||||
File("build/app/outputs/bundle/release/app-release.aab")
|
||||
}
|
||||
|
||||
@@ -950,41 +783,14 @@ func (m *Ci) PublishAndroid(
|
||||
playStoreConfig *dagger.Secret,
|
||||
keystoreBase64 *dagger.Secret,
|
||||
keystorePassword *dagger.Secret,
|
||||
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||
// +optional
|
||||
commitHash string,
|
||||
) (string, error) {
|
||||
versionCode := int(time.Now().Unix())
|
||||
aab := m.BuildAndroidRelease(commitHash)
|
||||
aab := m.BuildAndroidRelease()
|
||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||
}
|
||||
|
||||
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
||||
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
||||
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
|
||||
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
|
||||
// hardcoded 20/100 values before launching renovate.
|
||||
const patchCmd = `for f in \
|
||||
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
|
||||
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
|
||||
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
|
||||
done`
|
||||
return dag.Container().
|
||||
From("renovate/renovate:43").
|
||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
||||
WithEnvVariable("LOG_LEVEL", "info").
|
||||
WithUser("root").
|
||||
WithExec([]string{"/bin/sh", "-c", patchCmd}).
|
||||
WithUser("ubuntu").
|
||||
WithExec([]string{"renovate"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
// Graph returns a Mermaid diagram of the CI pipeline structure.
|
||||
// Paste the output into any Mermaid renderer (codeberg, github, mermaid.live)
|
||||
// or save it as a .md file to get a rendered diagram.
|
||||
@@ -993,12 +799,12 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
|
||||
//
|
||||
// dagger call --progress=plain -q -m ci --source=. graph
|
||||
func (m *Ci) Graph() string {
|
||||
return fmt.Sprintf(`# CI Pipeline Graph
|
||||
return `# CI Pipeline Graph
|
||||
|
||||
`+"```"+`mermaid
|
||||
` + "```" + `mermaid
|
||||
flowchart TD
|
||||
subgraph dagger ["Dagger · Check pipeline"]
|
||||
toolchain["toolchain\nflutter:%s + NDK + apt + precache"]`, m.FlutterVersion) + `
|
||||
toolchain["toolchain\nflutter:3.41.6 + NDK + apt"]
|
||||
pubGet["pubGetLayer\nflutter pub get"]
|
||||
codegen["codegenBase\nbuild_runner build\n(shared cache)"]
|
||||
stalwart(["Stalwart service\nIMAP · JMAP · SMTP · Sieve"])
|
||||
@@ -1008,7 +814,7 @@ flowchart TD
|
||||
|
||||
pubGet --> hygiene["CheckHygiene"]
|
||||
pubGet --> layers["CheckLayers"]
|
||||
pubGet --> mocks["CheckGenerated\n(own build_runner run)"]
|
||||
pubGet --> mocks["CheckMocks\n(own build_runner run)"]
|
||||
|
||||
codegen --> fmt["Format"]
|
||||
codegen --> analyze["Analyze"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Load .env into environment
|
||||
@@ -14,7 +13,7 @@ export SSH_PRIVATE_KEY=$(cat "$HOME/.ssh/id_ed25519")
|
||||
|
||||
# Add nix profile and nix store tools (task, dagger) to PATH
|
||||
export PATH="$HOME/.nix-profile/bin:$PATH"
|
||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger" "*fgj-*/bin/fgj"; do
|
||||
for pkg in "*go-task-*/bin/task" "*dagger-*/bin/dagger"; do
|
||||
bin=$(ls -d /nix/store/$pkg 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$bin" ] && export PATH="$(dirname "$bin"):$PATH"
|
||||
done
|
||||
|
||||
@@ -4,28 +4,6 @@ This file contains tasks which got implemented.
|
||||
|
||||
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)
|
||||
|
||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||
dependencies up to date. All required components are in main:
|
||||
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
||||
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
||||
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
||||
- `Taskfile.yml` — `renovate` task
|
||||
- Issue #257 closed.
|
||||
|
||||
## Tasks (2026-05-11)
|
||||
|
||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||
|
||||
@@ -48,28 +48,11 @@
|
||||
chmod +x $out/bin/fgj
|
||||
'';
|
||||
};
|
||||
|
||||
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
|
||||
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
||||
dagger021 = pkgs.stdenv.mkDerivation {
|
||||
pname = "dagger";
|
||||
version = "0.21.4";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
||||
};
|
||||
sourceRoot = ".";
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin
|
||||
cp dagger $out/bin/dagger
|
||||
chmod +x $out/bin/dagger
|
||||
'';
|
||||
};
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Dagger CLI
|
||||
dagger021
|
||||
dagger.packages.${system}.dagger
|
||||
|
||||
# Go compiler — for Dagger development
|
||||
go
|
||||
@@ -116,17 +99,12 @@
|
||||
httplib2
|
||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
||||
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
||||
]);
|
||||
|
||||
shellHook = ''
|
||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
||||
export IN_NIX_SHELL=1
|
||||
|
||||
# Point Dagger client at the running engine socket
|
||||
export DAGGER_HOST=unix:///run/dagger/engine.sock
|
||||
|
||||
# Disable Flutter telemetry inside dev shell
|
||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
||||
|
||||
|
||||
@@ -1,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 ──────────────────────────────────────────────────
|
||||
// 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.tap(find.text('Sent'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -331,7 +331,7 @@ void main() {
|
||||
expect(find.text(subject), findsOneWidget);
|
||||
|
||||
// ── Check Inbox ────────────────────────────────────────────────────────
|
||||
await tester.tap(find.byTooltip('Open folders'));
|
||||
await tester.tap(find.byTooltip('Open navigation menu'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('INBOX'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
const int dbSchemaVersion = 39;
|
||||
@@ -192,22 +192,6 @@ class EmailThread {
|
||||
required this.accountId,
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,10 +15,6 @@ abstract class EmailRepository {
|
||||
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].
|
||||
Stream<List<Email>> observeEmailsInThread(
|
||||
String accountId,
|
||||
|
||||
@@ -11,17 +11,4 @@ abstract class MailboxRepository {
|
||||
|
||||
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||
Future<void> clearForResync(String accountId);
|
||||
|
||||
/// Creates a new mailbox named [name] for [accountId] and tags it with
|
||||
/// [role] in the local database. For JMAP accounts the role is also sent
|
||||
/// to the server. Returns the newly created [Mailbox].
|
||||
Future<Mailbox> createMailboxWithRole(
|
||||
String accountId,
|
||||
String name,
|
||||
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);
|
||||
}
|
||||
@@ -19,8 +19,6 @@ class SyncLogEntry {
|
||||
required this.id,
|
||||
required this.result,
|
||||
this.errorMessage,
|
||||
this.stackTrace,
|
||||
this.isPermanent = false,
|
||||
required this.protocol,
|
||||
required this.emailsFetched,
|
||||
required this.emailsSkipped,
|
||||
@@ -36,8 +34,6 @@ class SyncLogEntry {
|
||||
final int id;
|
||||
final String result; // 'ok' or 'error'
|
||||
final String? errorMessage;
|
||||
final String? stackTrace;
|
||||
final bool isPermanent;
|
||||
final String protocol; // 'imap' or 'jmap'
|
||||
final int emailsFetched;
|
||||
final int emailsSkipped;
|
||||
@@ -58,8 +54,6 @@ abstract class SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -87,8 +81,6 @@ class NoOpSyncLogRepository implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
|
||||
@@ -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;
|
||||
try {
|
||||
final data = Uint8List.fromList(
|
||||
base64.decode(s.substring(_pubKeyPrefix.length)),
|
||||
);
|
||||
final data =
|
||||
Uint8List.fromList(base64.decode(s.substring(_pubKeyPrefix.length)));
|
||||
if (data.length != _keyIdLen + _pubKeyLen) return null;
|
||||
return (
|
||||
keyId: data.sublist(0, _keyIdLen),
|
||||
|
||||
@@ -108,9 +108,8 @@ class SieveInterpreter {
|
||||
}
|
||||
|
||||
bool _globMatch(String value, String pattern) {
|
||||
final regexStr = RegExp.escape(
|
||||
pattern,
|
||||
).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
final regexStr =
|
||||
RegExp.escape(pattern).replaceAll(r'\*', '.*').replaceAll(r'\?', '.');
|
||||
return RegExp('^$regexStr\$').hasMatch(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -466,7 +466,9 @@ class _Scanner {
|
||||
|
||||
String readTaggedArg() {
|
||||
if (!isAtEnd && _src[_pos] == ':') return readWord();
|
||||
throw SieveParseException('Expected tagged argument at position $_pos');
|
||||
throw SieveParseException(
|
||||
'Expected tagged argument at position $_pos',
|
||||
);
|
||||
}
|
||||
|
||||
String? peekSizeUnit() {
|
||||
@@ -478,7 +480,9 @@ class _Scanner {
|
||||
|
||||
String readDigits() {
|
||||
if (isAtEnd || !_isDigit(_src[_pos])) {
|
||||
throw SieveParseException('Expected number at position $_pos');
|
||||
throw SieveParseException(
|
||||
'Expected number at position $_pos',
|
||||
);
|
||||
}
|
||||
final start = _pos;
|
||||
while (!isAtEnd && _isDigit(_src[_pos])) {
|
||||
@@ -489,7 +493,9 @@ class _Scanner {
|
||||
|
||||
String readQuotedString() {
|
||||
if (_src[_pos] != '"') {
|
||||
throw SieveParseException('Expected " at position $_pos');
|
||||
throw SieveParseException(
|
||||
'Expected " at position $_pos',
|
||||
);
|
||||
}
|
||||
_pos++; // skip opening quote
|
||||
final buf = StringBuffer();
|
||||
|
||||
@@ -260,8 +260,6 @@ class _AccountSync implements _SyncLoop {
|
||||
accountId: account.id,
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
stackTrace: st.toString(),
|
||||
isPermanent: isPermanent,
|
||||
protocol: 'imap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
@@ -515,8 +513,6 @@ class _JmapAccountSync implements _SyncLoop {
|
||||
accountId: account.id,
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
stackTrace: st.toString(),
|
||||
isPermanent: isPermanent,
|
||||
protocol: 'jmap',
|
||||
emailsFetched: 0,
|
||||
emailsSkipped: 0,
|
||||
|
||||
@@ -11,9 +11,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
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/services/body_cache_service.dart';
|
||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||
import 'package:sharedinbox/data/db/database.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';
|
||||
|
||||
const _kTaskName = 'si_bg_sync';
|
||||
const _kPrefetchTaskName = 'si_bg_prefetch';
|
||||
const _kResourceType = 'background_check';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
@@ -31,13 +28,9 @@ void callbackDispatcher() {
|
||||
// Required so that path_provider and other plugins are available in this
|
||||
// background isolate (issue #192).
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
Workmanager().executeTask((taskName, __) async {
|
||||
Workmanager().executeTask((_, __) async {
|
||||
try {
|
||||
if (taskName == _kPrefetchTaskName) {
|
||||
await _doBodyPrefetch();
|
||||
} else {
|
||||
await _doBackgroundSync();
|
||||
}
|
||||
await _doBackgroundSync();
|
||||
} catch (_) {}
|
||||
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 {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
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(
|
||||
AppDatabase db,
|
||||
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.toLowerCase()}"', 'src="$dataUri"')
|
||||
.replaceAll("src='cid:${bareCid.toLowerCase()}'", "src='$dataUri'");
|
||||
.replaceAll(
|
||||
"src='cid:${bareCid.toLowerCase()}'",
|
||||
"src='$dataUri'",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:drift/native.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
@@ -193,9 +192,6 @@ class SyncLogs extends Table {
|
||||
DateTimeColumn get finishedAt => dateTime()();
|
||||
// Added in schema v13: raw protocol log when account.verbose == true.
|
||||
TextColumn get protocolLog => text().nullable()();
|
||||
// Added in schema v33: stack trace and permanent flag for error entries.
|
||||
TextColumn get errorStackTrace => text().nullable()();
|
||||
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Per-mailbox breakdown for a single sync cycle.
|
||||
@@ -307,60 +303,6 @@ class LocalSieveApplied extends Table {
|
||||
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};
|
||||
}
|
||||
|
||||
/// 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@DriftDatabase(
|
||||
@@ -381,16 +323,13 @@ class UserPreferences extends Table {
|
||||
LocalSieveScripts,
|
||||
LocalSieveApplied,
|
||||
ShareKeys,
|
||||
UserPreferences,
|
||||
ImageTrustedSenders,
|
||||
EmailNotes,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => dbSchemaVersion;
|
||||
int get schemaVersion => 32;
|
||||
|
||||
Future<void> _createEmailFts() async {
|
||||
await customStatement('''
|
||||
@@ -631,38 +570,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
if (from < 32) {
|
||||
await m.createTable(localSieveApplied);
|
||||
}
|
||||
if (from >= 7 && from < 33) {
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ class LocalSieveRepository {
|
||||
final AppDatabase _db;
|
||||
|
||||
Future<List<SieveScript>> listScripts(String accountId) async {
|
||||
final rows = await (_db.select(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
final rows = await (_db.select(_db.localSieveScripts)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
return rows
|
||||
.map(
|
||||
@@ -27,9 +26,10 @@ class LocalSieveRepository {
|
||||
|
||||
Future<String> getScriptContent(String accountId, String blobId) async {
|
||||
final rowId = int.parse(blobId);
|
||||
final row = await (_db.select(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||
final row = await (_db.select(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
if (row == null) throw Exception('Local script not found: $blobId');
|
||||
return row.content;
|
||||
@@ -44,7 +44,9 @@ class LocalSieveRepository {
|
||||
if (id != null) {
|
||||
final rowId = int.parse(id);
|
||||
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(
|
||||
LocalSieveScriptsCompanion(
|
||||
name: Value(name),
|
||||
@@ -76,9 +78,10 @@ class LocalSieveRepository {
|
||||
|
||||
Future<void> deleteScript(String accountId, String scriptId) async {
|
||||
final rowId = int.parse(scriptId);
|
||||
await (_db.delete(
|
||||
_db.localSieveScripts,
|
||||
)..where((t) => t.id.equals(rowId) & t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.localSieveScripts)
|
||||
..where(
|
||||
(t) => t.id.equals(rowId) & t.accountId.equals(accountId),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
@@ -89,7 +92,9 @@ class LocalSieveRepository {
|
||||
.write(const LocalSieveScriptsCompanion(isActive: Value(false)));
|
||||
final rowId = int.parse(scriptId);
|
||||
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)));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
|
||||
class DraftRepositoryImpl implements DraftRepository {
|
||||
DraftRepositoryImpl(this._db, this._accounts, {ImapConnectFn? imapConnect})
|
||||
: _imapConnect = imapConnect;
|
||||
DraftRepositoryImpl(
|
||||
this._db,
|
||||
this._accounts, {
|
||||
ImapConnectFn? imapConnect,
|
||||
}) : _imapConnect = imapConnect;
|
||||
|
||||
final AppDatabase _db;
|
||||
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.
|
||||
try {
|
||||
await client.createMailbox('Drafts');
|
||||
@@ -156,9 +162,8 @@ class DraftRepositoryImpl implements DraftRepository {
|
||||
? uidList.first.toString()
|
||||
: null;
|
||||
if (uid != null) {
|
||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id))).write(
|
||||
DraftsCompanion(imapServerId: Value(uid)),
|
||||
);
|
||||
await (_db.update(_db.drafts)..where((t) => t.id.equals(row.id)))
|
||||
.write(DraftsCompanion(imapServerId: Value(uid)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,26 +95,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.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) {
|
||||
List<model.EmailAddress> parseAddresses(String json) {
|
||||
final list = jsonDecode(json) as List<dynamic>;
|
||||
@@ -176,7 +156,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
return;
|
||||
}
|
||||
|
||||
if (threadEmails.isEmpty) return;
|
||||
final latest = threadEmails.last;
|
||||
|
||||
// Collect unique participants across the whole thread.
|
||||
@@ -258,12 +237,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, '(BODY.PEEK[])');
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
final msg = fetch.messages.first;
|
||||
final textBody = msg.decodeTextPlainPart();
|
||||
final rawHtml = msg.decodeTextHtmlPart();
|
||||
final htmlBody =
|
||||
@@ -351,7 +325,13 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
],
|
||||
'fetchHTMLBodyValues': true,
|
||||
'fetchTextBodyValues': true,
|
||||
'bodyProperties': ['partId', 'type', 'name', 'size', 'subParts'],
|
||||
'bodyProperties': [
|
||||
'partId',
|
||||
'type',
|
||||
'name',
|
||||
'size',
|
||||
'subParts',
|
||||
],
|
||||
},
|
||||
'0',
|
||||
],
|
||||
@@ -1969,9 +1949,8 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
.getSingleOrNull();
|
||||
final inboxPath = inboxMailbox?.path ?? 'INBOX';
|
||||
|
||||
final alreadyApplied = await (_db.select(
|
||||
_db.localSieveApplied,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
final alreadyApplied = await (_db.select(_db.localSieveApplied)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.get();
|
||||
final appliedIds = alreadyApplied.map((r) => r.messageId).toSet();
|
||||
|
||||
@@ -2071,9 +2050,7 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (destMailbox == null) {
|
||||
log(
|
||||
'Sieve: JMAP mailbox "$folder" not found for account ${account.id}',
|
||||
);
|
||||
log('Sieve: JMAP mailbox "$folder" not found for account ${account.id}');
|
||||
return;
|
||||
}
|
||||
destPath = destMailbox.path;
|
||||
@@ -2831,13 +2808,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
// Content-Transfer-Encoding) and getPart() can decode the part correctly.
|
||||
// A partial BODY.PEEK[n] fetch omits those headers, causing
|
||||
// decodeContentBinary() to return raw base64 instead of decoded bytes.
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
final fetch = await client.uidFetchMessage(
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
final msg = fetch.messages.first;
|
||||
final part = msg.getPart(attachment.fetchPartId) ?? msg;
|
||||
final bytes = part.decodeContentBinary();
|
||||
if (bytes == null) {
|
||||
@@ -2899,14 +2874,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
);
|
||||
try {
|
||||
await client.selectMailboxByPath(emailRow.mailboxPath);
|
||||
final fetch = await client.uidFetchMessage(emailRow.uid, 'BODY.PEEK[]');
|
||||
final msg = fetch.messages.firstOrNull;
|
||||
if (msg == null) {
|
||||
throw StateError(
|
||||
'IMAP server returned no message for UID ${emailRow.uid}.',
|
||||
);
|
||||
}
|
||||
return msg.renderMessage();
|
||||
final fetch = await client.uidFetchMessage(
|
||||
emailRow.uid,
|
||||
'BODY.PEEK[]',
|
||||
);
|
||||
return fetch.messages.first.renderMessage();
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
@@ -2983,20 +2955,6 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
}) async {
|
||||
if (query.length < 2) return [];
|
||||
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)
|
||||
..where((t) {
|
||||
Expression<bool> cond = const Constant(true);
|
||||
@@ -3011,22 +2969,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
..limit(100))
|
||||
.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 results = <model.EmailAddress>[];
|
||||
final lowerQuery = query.toLowerCase();
|
||||
for (final row in sortedRows) {
|
||||
final isSent = sentPaths.contains(row.mailboxPath);
|
||||
final fields = isSent
|
||||
? [row.toAddresses, row.ccJson, row.fromJson]
|
||||
: [row.fromJson, row.toAddresses, row.ccJson];
|
||||
for (final jsonStr in fields) {
|
||||
for (final row in rows) {
|
||||
for (final jsonStr in [row.fromJson, row.toAddresses, row.ccJson]) {
|
||||
final list = jsonDecode(jsonStr) as List<dynamic>;
|
||||
for (final e in list) {
|
||||
final map = e as Map<String, dynamic>;
|
||||
@@ -3305,17 +3252,14 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
await _db.customStatement('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
await _db.transaction(() async {
|
||||
await (_db.delete(
|
||||
_db.emails,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.emails)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.pendingChanges,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.pendingChanges)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
await (_db.delete(
|
||||
_db.syncStates,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.syncStates)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -79,15 +79,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
);
|
||||
try {
|
||||
final mailboxes = await client.listMailboxes(recursive: true);
|
||||
|
||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||
// folders the server doesn't tag with a special-use attribute.
|
||||
final existingRows = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.accountId.equals(account.id)))
|
||||
.get();
|
||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||
|
||||
for (final mb in mailboxes) {
|
||||
final path = mb.path;
|
||||
final id = '${account.id}:$path';
|
||||
@@ -105,12 +96,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
log('STATUS skipped for $path: $e');
|
||||
}
|
||||
|
||||
// Use the server-assigned role when available; fall back to the
|
||||
// existing DB role so that manually-created folders (e.g. a user
|
||||
// who just created their Archive folder) keep their role across syncs
|
||||
// when the IMAP server does not expose a special-use attribute.
|
||||
final role = _imapRole(mb) ?? existingRoles[id];
|
||||
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
@@ -119,7 +104,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
name: mb.name,
|
||||
unreadCount: Value(unread),
|
||||
totalCount: Value(total),
|
||||
role: Value(role),
|
||||
role: Value(_imapRole(mb)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -321,127 +306,8 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
|
||||
@override
|
||||
Future<void> clearForResync(String accountId) async {
|
||||
await (_db.delete(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.accountId.equals(accountId)))
|
||||
await (_db.delete(_db.mailboxes)
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<model.Mailbox> createMailboxWithRole(
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) 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, role);
|
||||
case account_model.AccountType.jmap:
|
||||
return _createMailboxWithRoleJmap(account, password, name, role);
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
account_model.Account account,
|
||||
String password,
|
||||
String name,
|
||||
String? role,
|
||||
) async {
|
||||
final client = await _imapConnect(
|
||||
account,
|
||||
_effectiveUsername(account),
|
||||
password,
|
||||
);
|
||||
try {
|
||||
await client.createMailbox(name);
|
||||
} finally {
|
||||
await client.logout();
|
||||
}
|
||||
final id = '${account.id}:$name';
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
accountId: account.id,
|
||||
path: name,
|
||||
name: name,
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
final row = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.id.equals(id)))
|
||||
.getSingle();
|
||||
return _toModel(row);
|
||||
}
|
||||
|
||||
Future<model.Mailbox> _createMailboxWithRoleJmap(
|
||||
account_model.Account account,
|
||||
String password,
|
||||
String name,
|
||||
String? role,
|
||||
) 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 responses = await jmap.call([
|
||||
[
|
||||
'Mailbox/set',
|
||||
{
|
||||
'accountId': jmap.accountId,
|
||||
'create': {
|
||||
'new-mailbox': {
|
||||
'name': name,
|
||||
if (role != null) 'role': role,
|
||||
},
|
||||
},
|
||||
},
|
||||
'0',
|
||||
],
|
||||
]);
|
||||
final result = _responseArgs(responses, 0, 'Mailbox/set');
|
||||
final created = result['created'] as Map<String, dynamic>?;
|
||||
final newId =
|
||||
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
|
||||
if (newId == null) {
|
||||
throw Exception(
|
||||
'Failed to create mailbox "$name": server returned no ID',
|
||||
);
|
||||
}
|
||||
final dbId = '${account.id}:$newId';
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: dbId,
|
||||
accountId: account.id,
|
||||
path: newId,
|
||||
name: name,
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
final row = await (_db.select(
|
||||
_db.mailboxes,
|
||||
)..where((t) => t.id.equals(dbId)))
|
||||
.getSingle();
|
||||
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 {
|
||||
// Remove existing entry for same query (deduplication).
|
||||
await (_db.delete(
|
||||
_db.searchHistoryEntries,
|
||||
)..where((t) => t.query.equals(trimmed)))
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.query.equals(trimmed)))
|
||||
.go();
|
||||
|
||||
await _db.into(_db.searchHistoryEntries).insert(
|
||||
@@ -44,9 +43,8 @@ class SearchHistoryRepositoryImpl implements SearchHistoryRepository {
|
||||
.get();
|
||||
|
||||
if (keepIds.isNotEmpty) {
|
||||
await (_db.delete(
|
||||
_db.searchHistoryEntries,
|
||||
)..where((t) => t.id.isNotIn(keepIds)))
|
||||
await (_db.delete(_db.searchHistoryEntries)
|
||||
..where((t) => t.id.isNotIn(keepIds)))
|
||||
.go();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,9 +40,8 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
await _pruneExpired();
|
||||
|
||||
final keyIdHex = _hex(keyId);
|
||||
final row = await (_db.select(
|
||||
_db.shareKeys,
|
||||
)..where((t) => t.id.equals(keyIdHex)))
|
||||
final row = await (_db.select(_db.shareKeys)
|
||||
..where((t) => t.id.equals(keyIdHex)))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (row == null) return null;
|
||||
@@ -56,9 +55,10 @@ class ShareKeyRepositoryImpl implements ShareKeyRepository {
|
||||
}
|
||||
|
||||
Future<void> _pruneExpired() async {
|
||||
await (_db.delete(
|
||||
_db.shareKeys,
|
||||
)..where((t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc())))
|
||||
await (_db.delete(_db.shareKeys)
|
||||
..where(
|
||||
(t) => t.expiresAt.isSmallerThanValue(DateTime.now().toUtc()),
|
||||
))
|
||||
.go();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
required String accountId,
|
||||
required bool success,
|
||||
String? errorMessage,
|
||||
String? stackTrace,
|
||||
bool isPermanent = false,
|
||||
required String protocol,
|
||||
required int emailsFetched,
|
||||
required int emailsSkipped,
|
||||
@@ -32,8 +30,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
accountId: accountId,
|
||||
result: success ? 'ok' : 'error',
|
||||
errorMessage: Value(errorMessage),
|
||||
errorStackTrace: Value(stackTrace),
|
||||
isPermanent: Value(isPermanent),
|
||||
protocol: Value(protocol),
|
||||
itemsSynced: Value(emailsFetched),
|
||||
emailsSkipped: Value(emailsSkipped),
|
||||
@@ -79,8 +75,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
|
||||
id: r.id,
|
||||
result: r.result,
|
||||
errorMessage: r.errorMessage,
|
||||
stackTrace: r.errorStackTrace,
|
||||
isPermanent: r.isPermanent,
|
||||
protocol: r.protocol,
|
||||
emailsFetched: r.itemsSynced,
|
||||
emailsSkipped: r.emailsSkipped,
|
||||
|
||||
@@ -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:sharedinbox/core/models/account.dart' as model;
|
||||
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/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_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/share_key_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_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/connection_test_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/sync/account_sync_manager.dart';
|
||||
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart'
|
||||
hide Email, EmailBody, UserPreferences;
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
|
||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.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/email_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/share_key_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/user_preferences_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||
|
||||
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
||||
@@ -104,9 +97,8 @@ final undoRepositoryProvider = Provider<UndoRepository>((ref) {
|
||||
return UndoRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final searchHistoryRepositoryProvider = Provider<SearchHistoryRepository>((
|
||||
ref,
|
||||
) {
|
||||
final searchHistoryRepositoryProvider =
|
||||
Provider<SearchHistoryRepository>((ref) {
|
||||
return SearchHistoryRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
@@ -139,10 +131,8 @@ final syncHealthProvider =
|
||||
.watchSingleOrNull();
|
||||
});
|
||||
|
||||
final isSyncingProvider = StreamProvider.autoDispose.family<bool, String>((
|
||||
ref,
|
||||
accountId,
|
||||
) {
|
||||
final isSyncingProvider =
|
||||
StreamProvider.autoDispose.family<bool, String>((ref, accountId) {
|
||||
return ref.watch(syncManagerProvider).watchSyncing(accountId);
|
||||
});
|
||||
|
||||
@@ -191,9 +181,8 @@ final manageSieveProbeServiceProvider = Provider<ManageSieveProbeService>((
|
||||
return ManageSieveProbeService(ref.watch(accountRepositoryProvider));
|
||||
});
|
||||
|
||||
final undoServiceProvider = NotifierProvider<UndoService, List<UndoAction>>(
|
||||
UndoService.new,
|
||||
);
|
||||
final undoServiceProvider =
|
||||
NotifierProvider<UndoService, List<UndoAction>>(UndoService.new);
|
||||
|
||||
/// Loads email header + body and marks the email as seen.
|
||||
/// Owned by [EmailDetailScreen]; decouples data loading from the widget tree.
|
||||
@@ -214,38 +203,10 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> {
|
||||
repo.getEmailBody(_emailId),
|
||||
]);
|
||||
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);
|
||||
}
|
||||
|
||||
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 =
|
||||
StreamProvider.autoDispose.family<model.Account?, String>((ref, accountId) {
|
||||
return ref.watch(accountRepositoryProvider).observeAccounts().map(
|
||||
@@ -266,37 +227,3 @@ final accountConnectionStatusProvider =
|
||||
.read(connectionTestServiceProvider)
|
||||
.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),
|
||||
);
|
||||
});
|
||||
|
||||
/// 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/misc.dart' show Override;
|
||||
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/services/notification_service.dart';
|
||||
import 'package:sharedinbox/core/sync/background_sync.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/router.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(
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
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.
|
||||
ErrorWidget.builder = (details) => CrashScreen(
|
||||
exception: details.exception,
|
||||
@@ -50,35 +39,19 @@ void main({List<Override> overrides = const []}) {
|
||||
if (Platform.isAndroid) {
|
||||
await initNotifications();
|
||||
await registerBackgroundSync();
|
||||
await _registerPrefetchTaskFromStoredPrefs();
|
||||
}
|
||||
runApp(
|
||||
ProviderScope(overrides: overrides, child: const SharedInboxApp()),
|
||||
);
|
||||
},
|
||||
// This handler runs in the parent zone — runApp cannot be called here.
|
||||
// Framework errors are already handled by FlutterError.onError above.
|
||||
(error, stack) => FlutterError.reportError(
|
||||
FlutterErrorDetails(exception: error, stack: stack),
|
||||
),
|
||||
(error, stack) {
|
||||
// Catch unhandled async errors.
|
||||
runApp(CrashScreen(exception: error, stackTrace: 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 {
|
||||
const SharedInboxApp({super.key});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/sieve_script.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
|
||||
import 'package:sharedinbox/ui/screens/about_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
@@ -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/add_account_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/combined_inbox_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/compose_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/edit_account_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/sync_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/inbox',
|
||||
initialLocation: '/accounts',
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (ctx, state, child) => UndoShell(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/inbox',
|
||||
builder: (ctx, state) => const CombinedInboxScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/accounts',
|
||||
builder: (ctx, state) => const AccountListScreen(),
|
||||
@@ -57,14 +47,6 @@ final router = GoRouter(
|
||||
GoRoute(
|
||||
path: 'undo-log',
|
||||
builder: (ctx, state) => const UndoLogScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':actionId',
|
||||
builder: (ctx, state) => UndoLogDetailScreen(
|
||||
action: state.extra as UndoAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: 'changelog',
|
||||
@@ -74,16 +56,6 @@ final router = GoRouter(
|
||||
path: 'about',
|
||||
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(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
@@ -187,12 +159,6 @@ final router = GoRouter(
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/bug-report',
|
||||
builder: (ctx, state) => BugReportScreen(
|
||||
emailId: state.uri.queryParameters['emailId'],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class AboutScreen extends ConsumerStatefulWidget {
|
||||
@@ -20,22 +19,57 @@ class AboutScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||
late final Future<String?> _deviceModelFuture;
|
||||
late final Stream<List<Account>> _accountsStream;
|
||||
String? _deviceModel;
|
||||
|
||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||
_deviceModelFuture = getDeviceModel();
|
||||
unawaited(
|
||||
_deviceModelFuture.then((model) {
|
||||
if (mounted) setState(() => _deviceModel = model);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildMarkdown(
|
||||
BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
int imapCount,
|
||||
int jmapCount,
|
||||
) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
final physH = (size.height * pixelRatio).toInt();
|
||||
final version =
|
||||
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||
final versionDisplay = _gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||
: version;
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
|
||||
final gitCommitLine = _gitHash.isNotEmpty
|
||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||
: '';
|
||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||
'| Property | Value |\n'
|
||||
'|----------|-------|\n'
|
||||
'| App Version | $versionDisplay |\n'
|
||||
'$gitCommitLine'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
|
||||
static String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
|
||||
Future<void> _copyToClipboard(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
@@ -45,20 +79,10 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
String? deviceModel;
|
||||
try {
|
||||
deviceModel = await _deviceModelFuture;
|
||||
} catch (_) {}
|
||||
if (!context.mounted) return;
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: deviceModel,
|
||||
),
|
||||
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
@@ -71,32 +95,6 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Could not open browser.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Error: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createIssue(
|
||||
BuildContext context,
|
||||
int imapCount,
|
||||
@@ -106,28 +104,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
try {
|
||||
pkg = await _packageInfoFuture;
|
||||
} catch (_) {}
|
||||
String? deviceModel;
|
||||
try {
|
||||
deviceModel = await _deviceModelFuture;
|
||||
} catch (_) {}
|
||||
if (!context.mounted) return;
|
||||
final body = Uri.encodeComponent(
|
||||
buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: deviceModel,
|
||||
),
|
||||
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
||||
);
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||
);
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
final launched =
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
if (!launched && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -171,17 +157,21 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return Markdown(
|
||||
data: buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: snapshot.data,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: _deviceModel,
|
||||
data: _buildMarkdown(
|
||||
context,
|
||||
snapshot.data,
|
||||
imapCount,
|
||||
jmapCount,
|
||||
),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) {
|
||||
if (href != null) {
|
||||
unawaited(_launchUrl(context, Uri.parse(href)));
|
||||
unawaited(
|
||||
launchUrl(
|
||||
Uri.parse(href),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -198,30 +188,22 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.copy),
|
||||
label: const Text('Copy info'),
|
||||
label: const Text('Copy to clipboard'),
|
||||
onPressed: () => unawaited(
|
||||
_copyToClipboard(context, imapCount, jmapCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
label: const Text('Public issue'),
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
label: const Text('Create issue'),
|
||||
onPressed: () => unawaited(
|
||||
_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:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -67,14 +66,6 @@ class AccountListScreen extends ConsumerWidget {
|
||||
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 typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Text('${account.email}\n$typeLabel'),
|
||||
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'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
|
||||
child: health.when(
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: Text(account.displayName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${account.email}\n$typeLabel'),
|
||||
const SizedBox(height: 4),
|
||||
health.when(
|
||||
data: (h) {
|
||||
if (h == null) return const Text('Sync health: Not verified yet');
|
||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Sync health: '),
|
||||
Icon(
|
||||
@@ -202,13 +133,7 @@ class _AccountTile extends ConsumerWidget {
|
||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
h.isHealthy
|
||||
? 'Healthy'
|
||||
: _formatDiscrepancies(h.discrepancySummary),
|
||||
),
|
||||
),
|
||||
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||
],
|
||||
);
|
||||
@@ -216,8 +141,66 @@ class _AccountTile extends ConsumerWidget {
|
||||
loading: () => const Text('Sync health: checking...'),
|
||||
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 {
|
||||
const _OnboardingView();
|
||||
|
||||
|
||||
@@ -32,15 +32,11 @@ enum _Step { generatingKey, showingPubKey, scanning, importing, done, error }
|
||||
class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
_Step _step = _Step.generatingKey;
|
||||
ShareKeyMaterial? _keyMaterial;
|
||||
DateTime? _keyExpiresAt;
|
||||
String? _pubKeyQr;
|
||||
String? _errorMessage;
|
||||
bool _scannerActive = false;
|
||||
|
||||
MobileScannerController? _scannerController;
|
||||
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||
// MissingPluginException on some Android builds).
|
||||
bool _scannerFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -65,7 +61,6 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
);
|
||||
setState(() {
|
||||
_keyMaterial = material;
|
||||
_keyExpiresAt = DateTime.now().toUtc().add(const Duration(minutes: 20));
|
||||
_pubKeyQr = qr;
|
||||
_step = _Step.showingPubKey;
|
||||
});
|
||||
@@ -81,37 +76,8 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
setState(() {
|
||||
_step = _Step.scanning;
|
||||
_scannerActive = true;
|
||||
_scannerController = MobileScannerController();
|
||||
});
|
||||
if (_cameraScanSupported()) {
|
||||
unawaited(_initScanner());
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||
// the plugin exists but something else failed — the MobileScanner widget
|
||||
// will surface those via its own error builder.
|
||||
Future<void> _initScanner() async {
|
||||
bool available = false;
|
||||
try {
|
||||
await const MethodChannel(
|
||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||
).invokeMethod<int>('state');
|
||||
available = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; text fallback will be shown.
|
||||
} catch (_) {
|
||||
// Plugin registered but state check failed; let the scanner widget
|
||||
// handle it via its errorBuilder.
|
||||
available = true;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
setState(() => _scannerController = MobileScannerController());
|
||||
} else {
|
||||
setState(() => _scannerFailed = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onScanned(String rawValue) async {
|
||||
@@ -219,7 +185,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
),
|
||||
),
|
||||
_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(
|
||||
child: Padding(
|
||||
@@ -274,7 +244,7 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ExpiryHint(expiresAt: _keyExpiresAt!),
|
||||
const _ExpiryHint(),
|
||||
const SizedBox(height: 32),
|
||||
if (_errorMessage != null) ...[
|
||||
Text(
|
||||
@@ -296,14 +266,11 @@ class _AccountReceiveScreenState extends ConsumerState<AccountReceiveScreen> {
|
||||
}
|
||||
|
||||
Widget _buildScannerView(BuildContext context) {
|
||||
// Fall back to text input when the platform has no camera support or when
|
||||
// the scanner plugin fails to initialise at runtime (MissingPluginException).
|
||||
if (!_cameraScanSupported() || _scannerFailed) {
|
||||
// On platforms where the camera scanner is not available (Linux desktop),
|
||||
// fall back to a text-input field.
|
||||
if (!_cameraScanSupported()) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -404,37 +371,8 @@ bool _cameraScanSupported() =>
|
||||
Platform.isMacOS ||
|
||||
Platform.isWindows;
|
||||
|
||||
class _ExpiryHint extends StatefulWidget {
|
||||
const _ExpiryHint({required this.expiresAt});
|
||||
|
||||
final DateTime expiresAt;
|
||||
|
||||
@override
|
||||
State<_ExpiryHint> createState() => _ExpiryHintState();
|
||||
}
|
||||
|
||||
class _ExpiryHintState extends State<_ExpiryHint> {
|
||||
late Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _formatRemaining() {
|
||||
final remaining = widget.expiresAt.difference(DateTime.now().toUtc());
|
||||
if (remaining.isNegative) return 'expired';
|
||||
final minutes = remaining.inMinutes;
|
||||
final seconds = remaining.inSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
class _ExpiryHint extends StatelessWidget {
|
||||
const _ExpiryHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -444,7 +382,7 @@ class _ExpiryHintState extends State<_ExpiryHint> {
|
||||
Icon(Icons.timer_outlined, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'This key expires in ${_formatRemaining()}',
|
||||
'This key expires in 20 minutes',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -45,42 +45,12 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
bool _scannerActive = true;
|
||||
|
||||
MobileScannerController? _scannerController;
|
||||
// True when the scanner plugin fails to initialise at runtime (e.g.
|
||||
// MissingPluginException on some Android builds).
|
||||
bool _scannerFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (_cameraScanSupported()) {
|
||||
unawaited(_initScanner());
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flight: probe the scanner's permission-state method to verify the
|
||||
// plugin is registered. MissingPluginException is thrown on Android builds
|
||||
// where the plugin is not linked (issue #204). All other exceptions mean
|
||||
// the plugin exists but something else failed — the MobileScanner widget
|
||||
// will surface those via its own error builder.
|
||||
Future<void> _initScanner() async {
|
||||
bool available = false;
|
||||
try {
|
||||
await const MethodChannel(
|
||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||
).invokeMethod<int>('state');
|
||||
available = true;
|
||||
} on MissingPluginException {
|
||||
// Plugin not registered on this device; text fallback will be shown.
|
||||
} catch (_) {
|
||||
// Plugin registered but state check failed; let the scanner widget
|
||||
// handle it via its errorBuilder.
|
||||
available = true;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (available) {
|
||||
setState(() => _scannerController = MobileScannerController());
|
||||
} else {
|
||||
setState(() => _scannerFailed = true);
|
||||
_scannerController = MobileScannerController();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +128,10 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
for (final account in selected) {
|
||||
final password = await repo.getPassword(account.id);
|
||||
payloads.add(
|
||||
AccountPayload(accountJson: account.toJson(), password: password),
|
||||
AccountPayload(
|
||||
accountJson: account.toJson(),
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,12 +178,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
}
|
||||
|
||||
Widget _buildScanStep(BuildContext context) {
|
||||
if (!_cameraScanSupported() || _scannerFailed) {
|
||||
if (!_cameraScanSupported()) {
|
||||
return _buildTextFallbackView(context);
|
||||
}
|
||||
if (_scannerController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -358,7 +328,9 @@ class _AccountSendScreenState extends ConsumerState<AccountSendScreen> {
|
||||
unawaited(Clipboard.setData(ClipboardData(text: _encryptedQr!)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
@@ -12,9 +13,7 @@ class ChangeLogScreen extends StatelessWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ChangeLog')),
|
||||
body: FutureBuilder<String>(
|
||||
future: DefaultAssetBundle.of(
|
||||
context,
|
||||
).loadString('assets/changelog.txt'),
|
||||
future: rootBundle.loadString('assets/changelog.txt'),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -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);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Failed to open file: $e'),
|
||||
@@ -211,7 +213,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
|
||||
Future<void> _send() async {
|
||||
if (_accountId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Select an account first'),
|
||||
@@ -251,7 +255,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||
if (mounted) context.pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text('Send failed: $e'),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -18,38 +17,20 @@ class CrashScreen extends StatelessWidget {
|
||||
final StackTrace? stackTrace;
|
||||
final String gitHash;
|
||||
|
||||
String get _buildMode {
|
||||
if (kDebugMode) return 'debug';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'release';
|
||||
}
|
||||
|
||||
Future<String> _fetchVersion() async {
|
||||
Future<String> _buildReport() async {
|
||||
String version = 'unknown';
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
return '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _buildReport() async {
|
||||
final version = await _fetchVersion();
|
||||
version = '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {}
|
||||
final platform =
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
final versionDisplay = gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)'
|
||||
: version;
|
||||
final gitLine = gitHash.isNotEmpty
|
||||
? 'Git Commit: [$gitHash](https://codeberg.org/guettli/sharedinbox/commit/$gitHash)\n'
|
||||
: '';
|
||||
final timestamp = DateTime.now().toUtc().toIso8601String();
|
||||
return 'App Version: $versionDisplay\n'
|
||||
'Build Mode: $_buildMode\n'
|
||||
return 'App Version: $version\n'
|
||||
'$gitLine'
|
||||
'Platform: $platform\n'
|
||||
'Dart: ${Platform.version}\n'
|
||||
'Timestamp: $timestamp\n\n'
|
||||
'Platform: $platform\n\n'
|
||||
'Error:\n```\n$exception\n```\n\n'
|
||||
'Stack Trace:\n```\n$stackTrace\n```';
|
||||
}
|
||||
@@ -75,49 +56,8 @@ class CrashScreen extends StatelessWidget {
|
||||
style: Theme.of(ctx).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<String>(
|
||||
future: _fetchVersion(),
|
||||
builder: (context, snapshot) => Text(
|
||||
'v${snapshot.data ?? '…'} • $_buildMode • '
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (gitHash.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<PackageInfo>(
|
||||
future: PackageInfo.fromPlatform(),
|
||||
builder: (_, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
final version =
|
||||
'${snapshot.data!.version}+${snapshot.data!.buildNumber}';
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
'https://codeberg.org/guettli/sharedinbox/commit/$gitHash',
|
||||
);
|
||||
await launchUrl(
|
||||
url,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'App Version: $version',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final url = Uri.parse(
|
||||
|
||||
@@ -38,7 +38,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
var _sieveSsl = true;
|
||||
var _verbose = false;
|
||||
final _jmapUrlCtrl = TextEditingController();
|
||||
bool _hasStoredPassword = false;
|
||||
|
||||
// -- "Try connection" state ------------------------------------------------
|
||||
bool _tryTesting = false;
|
||||
@@ -51,7 +50,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_smtpHostCtrl.addListener(_rebuild);
|
||||
_sieveHostCtrl.addListener(_rebuild);
|
||||
_imapHostCtrl.addListener(_rebuild);
|
||||
_passwordCtrl.addListener(_rebuild);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@@ -65,11 +63,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await repo.getPassword(account.id);
|
||||
_hasStoredPassword = true;
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
_account = account;
|
||||
_displayNameCtrl.text = account.displayName;
|
||||
_usernameCtrl.text = account.username;
|
||||
@@ -91,7 +84,6 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
_smtpHostCtrl.removeListener(_rebuild);
|
||||
_sieveHostCtrl.removeListener(_rebuild);
|
||||
_imapHostCtrl.removeListener(_rebuild);
|
||||
_passwordCtrl.removeListener(_rebuild);
|
||||
for (final c in [
|
||||
_displayNameCtrl,
|
||||
_usernameCtrl,
|
||||
@@ -275,12 +267,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
),
|
||||
_field(
|
||||
_passwordCtrl,
|
||||
_hasStoredPassword
|
||||
? 'New password (leave blank to keep)'
|
||||
: 'Password',
|
||||
'New password (leave blank to keep)',
|
||||
key: const Key('editPasswordField'),
|
||||
obscure: true,
|
||||
required: !_hasStoredPassword,
|
||||
required: false,
|
||||
),
|
||||
if (account.type == AccountType.jmap) ...[
|
||||
const Divider(height: 32),
|
||||
@@ -355,17 +345,10 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
||||
testing: _tryTesting,
|
||||
okMessage: _tryOk,
|
||||
errorMessage: _tryErr,
|
||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||
? _tryConnection
|
||||
: null,
|
||||
onPressed: _tryConnection,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||
? _save
|
||||
: null,
|
||||
child: const Text('Save'),
|
||||
),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||
|
||||
enum _MissingFolderChoice { chooseExisting, createNew }
|
||||
|
||||
/// Resolves a mailbox by role, prompting the user to choose or create one when
|
||||
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
|
||||
Future<Mailbox?> resolveMailboxByRole(
|
||||
BuildContext context,
|
||||
MailboxRepository mailboxRepo,
|
||||
String accountId,
|
||||
String currentMailboxPath,
|
||||
String role, {
|
||||
required String dialogTitle,
|
||||
required String createFolderName,
|
||||
}) async {
|
||||
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
|
||||
if (!context.mounted) return null;
|
||||
if (mailbox != null) return mailbox;
|
||||
|
||||
final choice = await showDialog<_MissingFolderChoice>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(dialogTitle),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
|
||||
child: const Text('Choose existing folder'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
|
||||
child: Text('Create "$createFolderName"'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!context.mounted || choice == null) return null;
|
||||
|
||||
switch (choice) {
|
||||
case _MissingFolderChoice.chooseExisting:
|
||||
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
|
||||
if (!context.mounted) return null;
|
||||
final chosen = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (ctx) => ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
const ListTile(
|
||||
title: Text(
|
||||
'Move to…',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
for (final m in mailboxes.where(
|
||||
(m) => m.path != currentMailboxPath,
|
||||
))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: Text(m.name),
|
||||
onTap: () => Navigator.pop(ctx, m.path),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (chosen == null || !context.mounted) return null;
|
||||
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
|
||||
case _MissingFolderChoice.createNew:
|
||||
mailbox = await mailboxRepo.createMailboxWithRole(
|
||||
accountId,
|
||||
createFolderName,
|
||||
role,
|
||||
);
|
||||
if (!context.mounted) return null;
|
||||
}
|
||||
|
||||
return mailbox;
|
||||
}
|
||||
@@ -12,14 +12,10 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.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/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.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/snooze_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -38,7 +34,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
bool _isFlagged = false;
|
||||
bool _loadRemoteImages = false;
|
||||
final Set<String> _downloading = {};
|
||||
bool _notesSynced = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -52,15 +47,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
if (email != null && mounted) {
|
||||
setState(() => _isFlagged = email.isFlagged);
|
||||
}
|
||||
if (!_notesSynced && email?.messageId != null) {
|
||||
_notesSynced = true;
|
||||
unawaited(
|
||||
ref.read(noteRepositoryProvider).syncNotes(
|
||||
email!.accountId,
|
||||
email.messageId!,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,40 +70,33 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_replyWithRecipientDialog(context, header, body));
|
||||
unawaited(_reply(context, header, body, replyAll: false));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.archive),
|
||||
tooltip: 'Archive',
|
||||
icon: const Icon(Icons.reply_all),
|
||||
tooltip: 'Reply all',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_archive(context, header));
|
||||
unawaited(_reply(context, header, body, replyAll: true));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_forward(context, header, body));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||
tooltip: 'Mark as unread',
|
||||
onPressed: () async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
|
||||
if (header != null) {
|
||||
await ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [header],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -132,17 +111,43 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
if (mounted) setState(() => _isFlagged = next);
|
||||
},
|
||||
),
|
||||
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(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: 'Delete',
|
||||
onPressed: () async {
|
||||
final destPath = await repo.deleteEmail(widget.emailId);
|
||||
|
||||
if (header != null) {
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.delete,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: destPath,
|
||||
originalEmails: [header],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(value: 'forward', child: Text('Forward')),
|
||||
const PopupMenuItem(value: 'move', child: Text('Move to folder')),
|
||||
const PopupMenuItem(value: 'snooze', child: Text('Snooze')),
|
||||
const PopupMenuItem(value: 'spam', child: Text('Mark as spam')),
|
||||
const PopupMenuItem(
|
||||
value: 'mark_unread',
|
||||
child: Text('Mark as unread'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
@@ -151,36 +156,18 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
value: 'structure',
|
||||
child: Text('Show Mail Structure'),
|
||||
),
|
||||
const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')),
|
||||
const PopupMenuDivider(),
|
||||
const PopupMenuItem(
|
||||
value: 'bug_report',
|
||||
child: Text('Report a Bug'),
|
||||
value: 'rfc',
|
||||
child: Text('Show Raw Email'),
|
||||
),
|
||||
],
|
||||
onSelected: (value) async {
|
||||
if (value == 'forward' && header != null) {
|
||||
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 == 'spam' && header != null) {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
} else if (value == 'mark_unread') {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
} else if (value == 'headers' && body != null) {
|
||||
onSelected: (value) {
|
||||
if (value == 'headers' && body != null) {
|
||||
_showHeaders(context, body);
|
||||
} else if (value == 'structure' && body != null) {
|
||||
_showStructure(context, body);
|
||||
} else if (value == 'rfc') {
|
||||
unawaited(_showRaw(context, header));
|
||||
} else if (value == 'bug_report') {
|
||||
unawaited(
|
||||
context.push('/bug-report?emailId=${widget.emailId}'),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -189,35 +176,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error: $e')),
|
||||
data: (d) {
|
||||
final trusted =
|
||||
ref.watch(trustedImageSendersProvider).value ?? const <String>[];
|
||||
return _buildBody(context, d.$1, d.$2, trusted);
|
||||
},
|
||||
data: (d) => _buildBody(context, d.$1, d.$2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(
|
||||
BuildContext ctx,
|
||||
Email? header,
|
||||
EmailBody body,
|
||||
List<String> trustedSenders,
|
||||
) {
|
||||
Widget _buildBody(BuildContext ctx, Email? header, EmailBody body) {
|
||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||
final senderEmail = header?.from.isNotEmpty == true
|
||||
? header!.from.first.email.toLowerCase()
|
||||
: null;
|
||||
final isTrusted =
|
||||
senderEmail != null && trustedSenders.contains(senderEmail);
|
||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (header != null) ...[_buildHeader(ctx, header), const Divider()],
|
||||
if (hasHtml) ...[
|
||||
if (!effectiveLoadImages)
|
||||
if (!_loadRemoteImages)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
@@ -225,50 +196,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.image_outlined, size: 18),
|
||||
label: const Text('Load remote images'),
|
||||
onPressed: () {
|
||||
setState(() => _loadRemoteImages = true);
|
||||
if (senderEmail != null) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.addTrustedImageSender(senderEmail),
|
||||
);
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 3),
|
||||
content: const Text(
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
onPressed: () {
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/accounts/trusted-senders',
|
||||
extra: senderEmail,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressed: () => setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: effectiveLoadImages,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
body.textBody ?? '',
|
||||
style: Theme.of(ctx).textTheme.bodyMedium,
|
||||
),
|
||||
if (header?.messageId != null) _buildNotesSection(ctx, header!),
|
||||
if (body.attachments.isNotEmpty) ...[
|
||||
const Divider(),
|
||||
Padding(
|
||||
@@ -301,40 +241,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 {
|
||||
setState(() => _downloading.add(att.filename));
|
||||
try {
|
||||
@@ -352,114 +258,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) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -505,78 +303,17 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||
}
|
||||
|
||||
Future<void> _replyWithRecipientDialog(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body,
|
||||
) async {
|
||||
final account =
|
||||
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
||||
final ownEmail = account?.email.toLowerCase() ?? '';
|
||||
|
||||
final seen = <String>{};
|
||||
final candidates = <_Candidate>[];
|
||||
|
||||
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
|
||||
final key = addr.email.toLowerCase();
|
||||
if (key == ownEmail || seen.contains(key)) return;
|
||||
seen.add(key);
|
||||
candidates.add(_Candidate(addr, defaultPlacement));
|
||||
}
|
||||
|
||||
for (final addr in header.from) {
|
||||
addIfNew(addr, _Placement.to);
|
||||
}
|
||||
for (final addr in header.to) {
|
||||
addIfNew(addr, _Placement.to);
|
||||
}
|
||||
for (final addr in header.cc) {
|
||||
addIfNew(addr, _Placement.cc);
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (candidates.length <= 1) {
|
||||
final to = candidates
|
||||
.where((c) => c.placement == _Placement.to)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
final cc = candidates
|
||||
.where((c) => c.placement == _Placement.cc)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
await _composeReply(context, header, body, to: to, cc: cc);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<List<_Candidate>>(
|
||||
context: context,
|
||||
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
|
||||
);
|
||||
|
||||
if (confirmed == null || !context.mounted) return;
|
||||
|
||||
final to = confirmed
|
||||
.where((c) => c.placement == _Placement.to)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
final cc = confirmed
|
||||
.where((c) => c.placement == _Placement.cc)
|
||||
.map((c) => c.address.email)
|
||||
.join(', ');
|
||||
await _composeReply(context, header, body, to: to, cc: cc);
|
||||
}
|
||||
|
||||
Future<void> _composeReply(
|
||||
Future<void> _reply(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body, {
|
||||
required String to,
|
||||
required String cc,
|
||||
required bool replyAll,
|
||||
}) async {
|
||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||
? header.subject!
|
||||
: 'Re: ${header.subject ?? ''}';
|
||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
@@ -593,78 +330,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _archive(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
header.accountId,
|
||||
header.mailboxPath,
|
||||
'archive',
|
||||
dialogTitle: 'No archive folder found',
|
||||
createFolderName: 'Archive',
|
||||
);
|
||||
|
||||
if (mailbox == null || !context.mounted) return;
|
||||
|
||||
await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.moveEmail(widget.emailId, mailbox.path);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: mailbox.path,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
header.accountId,
|
||||
header.mailboxPath,
|
||||
'junk',
|
||||
dialogTitle: 'No spam folder found',
|
||||
createFolderName: 'Junk',
|
||||
);
|
||||
|
||||
if (mailbox == null || !context.mounted) return;
|
||||
|
||||
await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.moveEmail(widget.emailId, mailbox.path);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
UndoAction(
|
||||
id: DateTime.now().toIso8601String(),
|
||||
accountId: header.accountId,
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: mailbox.path,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) _navigateTo(context, header, nextEmailId);
|
||||
}
|
||||
|
||||
Future<void> _forward(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
@@ -678,50 +343,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/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 {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final mailboxes =
|
||||
await mailboxRepo.observeMailboxes(header.accountId).first;
|
||||
@@ -732,8 +362,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
const createNewSentinel = '__create_new__';
|
||||
|
||||
final chosen = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (ctx) => ListView(
|
||||
@@ -751,28 +379,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
title: Text(m.name),
|
||||
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;
|
||||
|
||||
String destination = chosen;
|
||||
if (chosen == createNewSentinel) {
|
||||
final name = await _promptNewFolderName(context);
|
||||
if (name == null || !context.mounted) return;
|
||||
final mailbox = await mailboxRepo.createMailbox(header.accountId, name);
|
||||
destination = mailbox.path;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.moveEmail(widget.emailId, destination);
|
||||
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
@@ -782,18 +395,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
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 {
|
||||
final nextEmailId = await _getNextEmailIdIfNeeded(header);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final until = await showModalBottomSheet<DateTime>(
|
||||
context: context,
|
||||
builder: (ctx) => const SnoozePicker(),
|
||||
@@ -821,7 +431,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
_navigateTo(context, header, nextEmailId);
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,9 +443,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
.fetchRawRfc822(widget.emailId);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Failed to fetch raw email: $e')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to fetch raw email: $e')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -949,7 +559,47 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -960,7 +610,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
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;
|
||||
@@ -972,13 +624,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog.fullscreen(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Mail Structure'),
|
||||
leading: const CloseButton(),
|
||||
),
|
||||
body: ListView.builder(
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Mail Structure'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final row = rows[i];
|
||||
@@ -1007,90 +658,14 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Placement { to, cc, skip }
|
||||
|
||||
class _Candidate {
|
||||
_Candidate(this.address, this.placement);
|
||||
final EmailAddress address;
|
||||
_Placement placement;
|
||||
}
|
||||
|
||||
class _ReplyAllDialog extends StatefulWidget {
|
||||
const _ReplyAllDialog({required this.candidates});
|
||||
final List<_Candidate> candidates;
|
||||
|
||||
@override
|
||||
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
|
||||
}
|
||||
|
||||
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
||||
late final List<_Candidate> _candidates;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_candidates = [
|
||||
for (final c in widget.candidates) _Candidate(c.address, c.placement),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Reply All'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final c in _candidates)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
c.address.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SegmentedButton<_Placement>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: _Placement.to, label: Text('To')),
|
||||
ButtonSegment(value: _Placement.cc, label: Text('Cc')),
|
||||
ButtonSegment(
|
||||
value: _Placement.skip,
|
||||
label: Text('Skip'),
|
||||
),
|
||||
],
|
||||
selected: {c.placement},
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => c.placement = s.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, _candidates),
|
||||
child: const Text('Reply'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1137,13 +712,10 @@ class _UnsubscribeChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final uri = _parseUnsubscribeUri(header);
|
||||
if (uri == null) return const SizedBox.shrink();
|
||||
return Tooltip(
|
||||
message: uri.toString(),
|
||||
child: ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
),
|
||||
return ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,21 @@ import 'package:intl/intl.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/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/di.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/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 {
|
||||
const EmailListScreen({
|
||||
@@ -50,11 +57,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Pagination: number of threads currently requested from the DB.
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
// Incremented on every search start; stale completions are ignored when the
|
||||
// generation has advanced (prevents out-of-order IMAP responses from
|
||||
// overwriting fresh results with results for an older query).
|
||||
int _searchGeneration = 0;
|
||||
bool get _selecting =>
|
||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||
|
||||
@@ -126,19 +128,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
setState(() => _searchResults = null);
|
||||
return;
|
||||
}
|
||||
final generation = ++_searchGeneration;
|
||||
setState(() => _searchLoading = true);
|
||||
try {
|
||||
final results = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.searchEmails(widget.accountId, widget.mailboxPath, query.trim());
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchResults = results);
|
||||
}
|
||||
if (mounted) setState(() => _searchResults = results);
|
||||
} finally {
|
||||
if (mounted && generation == _searchGeneration) {
|
||||
setState(() => _searchLoading = false);
|
||||
}
|
||||
if (mounted) setState(() => _searchLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,21 +147,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
||||
appBar: _buildAppBar(repo, accountAsync),
|
||||
drawer: _selecting
|
||||
? null
|
||||
: FolderDrawer(
|
||||
accountId: widget.accountId,
|
||||
currentMailboxPath: widget.mailboxPath,
|
||||
),
|
||||
bottomNavigationBar: _selecting
|
||||
? _selectionBottomBar()
|
||||
: (menuAtBottom ? _folderNavBottomBar() : null),
|
||||
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||
body: Column(
|
||||
children: [
|
||||
_buildSyncErrorBanner(),
|
||||
@@ -180,14 +172,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
PreferredSizeWidget _buildAppBar(
|
||||
EmailRepository emailRepo,
|
||||
AsyncValue<Account?> accountAsync, {
|
||||
required bool menuAtBottom,
|
||||
}) {
|
||||
AsyncValue<Account?> accountAsync,
|
||||
) {
|
||||
final selectionCount =
|
||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: !menuAtBottom,
|
||||
leading: _selecting
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
@@ -310,22 +300,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() {
|
||||
return BottomAppBar(
|
||||
child: Row(
|
||||
@@ -382,7 +356,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
return MaterialBanner(
|
||||
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(
|
||||
Icons.sync_problem,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
@@ -396,8 +374,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
context.push('/accounts/${widget.accountId}/sync-log'),
|
||||
onPressed: () => context.push(
|
||||
'/accounts/${widget.accountId}/sync-log',
|
||||
),
|
||||
child: const Text('View log'),
|
||||
),
|
||||
TextButton(
|
||||
@@ -441,26 +420,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _batchMoveToRole(
|
||||
String role, {
|
||||
required String dialogTitle,
|
||||
required String createFolderName,
|
||||
}) async {
|
||||
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
|
||||
final ids = _selectedEmailIds;
|
||||
_clearSelection();
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
widget.accountId,
|
||||
widget.mailboxPath,
|
||||
role,
|
||||
dialogTitle: dialogTitle,
|
||||
createFolderName: createFolderName,
|
||||
);
|
||||
|
||||
if (!mounted || mailbox == null) return;
|
||||
|
||||
final mailbox = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
.findMailboxByRole(widget.accountId, role);
|
||||
if (!mounted) return;
|
||||
if (mailbox == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(notFoundMessage),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
||||
@@ -486,11 +463,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||
}
|
||||
|
||||
Future<void> _batchArchive() => _batchMoveToRole(
|
||||
'archive',
|
||||
dialogTitle: 'No archive folder found',
|
||||
createFolderName: 'Archive',
|
||||
);
|
||||
Future<void> _batchArchive() =>
|
||||
_batchMoveToRole('archive', 'No archive folder found');
|
||||
|
||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||
if (!mounted || !_searching) return;
|
||||
@@ -569,11 +543,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchMarkSpam() => _batchMoveToRole(
|
||||
'junk',
|
||||
dialogTitle: 'No spam folder found',
|
||||
createFolderName: 'Junk',
|
||||
);
|
||||
Future<void> _batchMarkSpam() =>
|
||||
_batchMoveToRole('junk', 'No spam folder found');
|
||||
|
||||
Future<void> _batchMove() async {
|
||||
final ids = _selectedEmailIds;
|
||||
@@ -689,93 +660,177 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
final t = threads[i];
|
||||
return EmailThreadTile(
|
||||
thread: t,
|
||||
isSelected: _selectedThreadIds.contains(t.threadId),
|
||||
isSelecting: _selecting,
|
||||
final isSelected = _selectedThreadIds.contains(t.threadId);
|
||||
final senderNames =
|
||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
||||
|
||||
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
|
||||
? () => _toggleThreadSelection(t)
|
||||
: t.messageCount > 1
|
||||
? () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/threads/${Uri.encodeComponent(t.threadId)}',
|
||||
)
|
||||
: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||
),
|
||||
onLongPress: () => _toggleThreadSelection(t),
|
||||
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.
|
||||
Widget _buildEmailList(List<Email> emails) {
|
||||
return ListView.builder(
|
||||
itemCount: emails.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = emails[i];
|
||||
final t = EmailThread.fromEmail(e);
|
||||
final isSelected = _selectedSearchIds.contains(e.id);
|
||||
return ThreadTile(
|
||||
thread: t,
|
||||
return EmailTile(
|
||||
email: e,
|
||||
selected: isSelected,
|
||||
leading: SizedBox(
|
||||
width: 40,
|
||||
@@ -794,4 +849,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/mailbox.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
@@ -18,12 +17,8 @@ class MailboxListScreen extends ConsumerWidget {
|
||||
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
||||
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !menuAtBottom,
|
||||
title: const Text('Folders'),
|
||||
actions: [
|
||||
IconButton(
|
||||
@@ -47,21 +42,6 @@ class MailboxListScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
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(
|
||||
children: [
|
||||
// ── Failed-mutation banner ───────────────────────────────────────
|
||||
|
||||
@@ -8,11 +8,10 @@ import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/utils/logger.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
|
||||
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
|
||||
ref,
|
||||
) async {
|
||||
final _searchHistoryProvider =
|
||||
FutureProvider.autoDispose<List<String>>((ref) async {
|
||||
return ref.watch(searchHistoryRepositoryProvider).getRecentSearches();
|
||||
});
|
||||
|
||||
@@ -189,9 +188,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
if (r.emails.isNotEmpty) ...[
|
||||
const _SectionHeader('Messages'),
|
||||
for (final e in r.emails)
|
||||
ThreadTile(
|
||||
thread: EmailThread.fromEmail(e),
|
||||
locationLabel: '${e.accountId} • ${e.mailboxPath}',
|
||||
EmailTile(
|
||||
email: e,
|
||||
showLocation: true,
|
||||
onTap: () => context.push(
|
||||
'/accounts/${e.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
||||
|
||||
@@ -137,7 +137,9 @@ class _SieveScriptsScreenState extends ConsumerState<SieveScriptsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.isLocal ? 'Local Filters' : 'Remote Filters'),
|
||||
title: Text(
|
||||
widget.isLocal ? 'Local Filters' : 'Remote Filters',
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
||||
|
||||
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
|
||||
|
||||
@@ -25,57 +21,6 @@ String _fmtBytes(int bytes) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('## Sync Entry');
|
||||
buf.writeln();
|
||||
buf.writeln('| Property | Value |');
|
||||
buf.writeln('|----------|-------|');
|
||||
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
|
||||
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
|
||||
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
|
||||
if (entry.protocol.isNotEmpty) {
|
||||
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
|
||||
}
|
||||
final statusLabel = entry.isOk
|
||||
? 'OK'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent)'
|
||||
: 'Error';
|
||||
buf.writeln('| Status | $statusLabel |');
|
||||
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
|
||||
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
|
||||
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
|
||||
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
|
||||
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
|
||||
if (entry.mailboxStats.isNotEmpty) {
|
||||
buf.writeln();
|
||||
buf.writeln('### Per mailbox');
|
||||
buf.writeln();
|
||||
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
|
||||
buf.writeln('|---------|---------|------------|----------|');
|
||||
for (final m in entry.mailboxStats) {
|
||||
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
|
||||
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
|
||||
}
|
||||
}
|
||||
if (entry.errorMessage != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('**Error:**');
|
||||
buf.writeln();
|
||||
buf.writeln(entry.errorMessage);
|
||||
}
|
||||
if (entry.stackTrace != null) {
|
||||
buf.writeln();
|
||||
buf.writeln('**Stack trace:**');
|
||||
buf.writeln();
|
||||
buf.writeln('```');
|
||||
buf.write(entry.stackTrace);
|
||||
buf.writeln('```');
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
class SyncLogScreen extends ConsumerStatefulWidget {
|
||||
const SyncLogScreen({super.key, required this.accountId});
|
||||
|
||||
@@ -124,41 +69,6 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
ref.read(syncManagerProvider).syncNow(widget.accountId);
|
||||
}
|
||||
|
||||
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
|
||||
final accounts =
|
||||
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
||||
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
|
||||
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
|
||||
|
||||
PackageInfo? pkg;
|
||||
try {
|
||||
pkg = await PackageInfo.fromPlatform();
|
||||
} catch (_) {}
|
||||
|
||||
final deviceModel = await getDeviceModel();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final syncMd = _buildSyncEntryMarkdown(entry);
|
||||
final aboutMd = buildAboutMarkdown(
|
||||
context: context,
|
||||
pkg: pkg,
|
||||
imapCount: imapCount,
|
||||
jmapCount: jmapCount,
|
||||
deviceModel: deviceModel,
|
||||
);
|
||||
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 3),
|
||||
content: Text('Copied to clipboard'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -186,20 +96,16 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
|
||||
? const Center(child: Text('No sync entries yet'))
|
||||
: ListView.builder(
|
||||
itemCount: _entries.length,
|
||||
itemBuilder: (ctx, i) => _SyncLogTile(
|
||||
entry: _entries[i],
|
||||
onCopy: () => _copyEntry(_entries[i], ctx),
|
||||
),
|
||||
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncLogTile extends StatelessWidget {
|
||||
const _SyncLogTile({required this.entry, required this.onCopy});
|
||||
const _SyncLogTile({required this.entry});
|
||||
|
||||
final SyncLogEntry entry;
|
||||
final VoidCallback onCopy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -209,12 +115,6 @@ class _SyncLogTile extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final errorColor = theme.colorScheme.error;
|
||||
|
||||
final subtitleText = entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: entry.isPermanent
|
||||
? 'Error (permanent) · took $durationLabel'
|
||||
: 'Error · took $durationLabel';
|
||||
|
||||
return ExpansionTile(
|
||||
leading: Icon(
|
||||
entry.isOk ? Icons.check_circle : Icons.error_outline,
|
||||
@@ -225,20 +125,11 @@ class _SyncLogTile extends StatelessWidget {
|
||||
style: entry.isOk ? null : TextStyle(color: errorColor),
|
||||
),
|
||||
subtitle: Text(
|
||||
subtitleText,
|
||||
entry.isOk
|
||||
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
|
||||
: 'Error · took $durationLabel',
|
||||
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
tooltip: 'Copy as markdown',
|
||||
onPressed: onCopy,
|
||||
),
|
||||
const Icon(Icons.expand_more),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
|
||||
@@ -280,31 +171,6 @@ class _SyncLogTile extends StatelessWidget {
|
||||
style: TextStyle(color: errorColor, fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (entry.stackTrace != null) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: Text(
|
||||
'Stack trace',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
entry.stackTrace!,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
color: Colors.red[300],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (entry.protocolLog != null) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, bottom: 2),
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
@@ -29,16 +28,9 @@ class ThreadDetailScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Thread'),
|
||||
automaticallyImplyLeading: !buttonAtBottom,
|
||||
),
|
||||
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
|
||||
appBar: AppBar(title: const Text('Thread')),
|
||||
body: StreamBuilder<List<Email>>(
|
||||
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
|
||||
builder: (context, snapshot) {
|
||||
@@ -68,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 {
|
||||
@@ -113,14 +91,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
|
||||
@override
|
||||
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.contains(senderEmail);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
@@ -155,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(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
@@ -171,17 +141,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
FutureBuilder<EmailBody>(
|
||||
future: _bodyFuture,
|
||||
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) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
@@ -192,51 +151,21 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
final body = snapshot.data!;
|
||||
final hasHtml = (body.htmlBody ?? '').trim().isNotEmpty;
|
||||
final effectiveLoadImages = _loadRemoteImages || isTrusted;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasHtml) ...[
|
||||
if (!effectiveLoadImages)
|
||||
if (!_loadRemoteImages)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.image_outlined, size: 16),
|
||||
label: const Text('Load remote images'),
|
||||
onPressed: () {
|
||||
setState(() => _loadRemoteImages = true);
|
||||
if (senderEmail != null) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.addTrustedImageSender(senderEmail),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 3),
|
||||
content: const Text(
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
onPressed: () {
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/accounts/trusted-senders',
|
||||
extra: senderEmail,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressed: () =>
|
||||
setState(() => _loadRemoteImages = true),
|
||||
),
|
||||
SecureEmailWebView(
|
||||
htmlBody: body.htmlBody!,
|
||||
loadRemoteImages: effectiveLoadImages,
|
||||
loadRemoteImages: _loadRemoteImages,
|
||||
),
|
||||
] else
|
||||
SelectableText(
|
||||
@@ -300,27 +229,47 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
// Fetch data first for IMAP undo support
|
||||
final original = await repo.getEmail(widget.email.id);
|
||||
|
||||
final destPath = await repo.deleteEmail(widget.email.id);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete email'),
|
||||
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 (original != null) {
|
||||
unawaited(
|
||||
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],
|
||||
if (confirmed == true) {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
// Fetch data first for IMAP undo support
|
||||
final original = await repo.getEmail(widget.email.id);
|
||||
|
||||
final destPath = await repo.deleteEmail(widget.email.id);
|
||||
|
||||
if (!mounted) return;
|
||||
if (original != null) {
|
||||
unawaited(
|
||||
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,63 +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')),
|
||||
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 "Load remote images" in an email to add the sender.',
|
||||
),
|
||||
);
|
||||
}
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
|
||||
final _dateTimeFmt = DateFormat('yyyy-MM-dd HH:mm:ss');
|
||||
|
||||
class UndoLogDetailScreen extends ConsumerWidget {
|
||||
const UndoLogDetailScreen({super.key, required this.action});
|
||||
|
||||
final UndoAction action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Undo Log Detail'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(undoServiceProvider.notifier)
|
||||
.undo(actionId: action.id);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
content: Text('Action undone.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Undo'),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
_SectionHeader(text: 'Transaction', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: const Text('Account'),
|
||||
subtitle: Text(action.accountId),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
: (action.type == UndoType.snooze
|
||||
? Icons.access_time
|
||||
: Icons.move_to_inbox),
|
||||
color: action.type == UndoType.delete
|
||||
? Colors.redAccent
|
||||
: (action.type == UndoType.snooze
|
||||
? Colors.orangeAccent
|
||||
: Colors.blueAccent),
|
||||
),
|
||||
title: const Text('Action'),
|
||||
subtitle: Text(action.type.name.toUpperCase()),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: const Text('Timestamp'),
|
||||
subtitle: Text(_dateTimeFmt.format(action.timestamp.toLocal())),
|
||||
),
|
||||
_SectionHeader(text: 'Folders', theme: theme),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_open),
|
||||
title: const Text('Source'),
|
||||
subtitle: Text(action.sourceMailboxPath),
|
||||
),
|
||||
if (action.type == UndoType.move &&
|
||||
action.destinationMailboxPath != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.drive_file_move),
|
||||
title: const Text('Destination'),
|
||||
subtitle: Text(action.destinationMailboxPath!),
|
||||
),
|
||||
_SectionHeader(
|
||||
text: 'Emails (${action.emailIds.length})',
|
||||
theme: theme,
|
||||
),
|
||||
if (action.originalEmails.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'${action.emailIds.length} email(s) — details not available',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.text, required this.theme});
|
||||
|
||||
final String text;
|
||||
final ThemeData theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailTile extends StatelessWidget {
|
||||
const _EmailTile({required this.email});
|
||||
|
||||
final Email email;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sender = email.from.isNotEmpty
|
||||
? (email.from.first.name ?? email.from.first.email)
|
||||
: '(Unknown Sender)';
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.email_outlined),
|
||||
title: Text(email.subject ?? '(No Subject)'),
|
||||
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
@@ -56,10 +55,6 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
final extraCount = count > 1 ? ' (+${count - 1} more)' : '';
|
||||
|
||||
return ListTile(
|
||||
onTap: () => context.go(
|
||||
'/accounts/undo-log/${action.id}',
|
||||
extra: action,
|
||||
),
|
||||
leading: Icon(
|
||||
action.type == UndoType.delete
|
||||
? Icons.delete_outline
|
||||
@@ -89,7 +84,9 @@ class _UndoActionTile extends ConsumerWidget {
|
||||
.read(undoServiceProvider.notifier)
|
||||
.undo(actionId: action.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
duration: Duration(seconds: 5),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||
|
||||
const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||
|
||||
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
|
||||
String buildAboutMarkdown({
|
||||
required BuildContext context,
|
||||
PackageInfo? pkg,
|
||||
required int imapCount,
|
||||
required int jmapCount,
|
||||
String? deviceModel,
|
||||
}) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final physW = (size.width * pixelRatio).toInt();
|
||||
final physH = (size.height * pixelRatio).toInt();
|
||||
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
|
||||
final versionDisplay = _gitHash.isNotEmpty
|
||||
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
|
||||
: version;
|
||||
final osName = _capitalize(Platform.operatingSystem);
|
||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final textScale = MediaQuery.of(
|
||||
context,
|
||||
).textScaler.scale(1.0).toStringAsFixed(1);
|
||||
|
||||
final gitCommitLine = _gitHash.isNotEmpty
|
||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||
: '';
|
||||
final deviceModelLine =
|
||||
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
||||
|
||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||
'| Property | Value |\n'
|
||||
'|----------|-------|\n'
|
||||
'| App Version | $versionDisplay |\n'
|
||||
'$gitCommitLine'
|
||||
'| Platform | ${Platform.operatingSystem} |\n'
|
||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||
'$deviceModelLine'
|
||||
'| Resolution | ${physW}x$physH px'
|
||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||
'| Locale | $locale |\n'
|
||||
'| Text Scale | $textScale× |\n'
|
||||
'| DB Schema Version | $dbSchemaVersion |\n'
|
||||
'| IMAP Accounts | $imapCount |\n'
|
||||
'| JMAP Accounts | $jmapCount |\n';
|
||||
}
|
||||
|
||||
/// Fetches device model string, or null when unavailable.
|
||||
Future<String?> getDeviceModel() async {
|
||||
try {
|
||||
final info = DeviceInfoPlugin();
|
||||
if (Platform.isAndroid) {
|
||||
final android = await info.androidInfo;
|
||||
return '${android.manufacturer} / ${android.model}';
|
||||
} else if (Platform.isIOS) {
|
||||
final ios = await info.iosInfo;
|
||||
return ios.utsname.machine;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _capitalize(String s) =>
|
||||
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,13 +31,10 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #1976D2; }
|
||||
* { box-sizing: border-box; max-width: 100%; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
td, th { overflow-wrap: break-word; word-break: break-word; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
||||
* { box-sizing: border-box; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -111,16 +108,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
);
|
||||
|
||||
Future<void> _measureHeight(String _) async {
|
||||
try {
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
} catch (_) {
|
||||
// WebView not ready yet; height stays at default
|
||||
final result = await _controller!.runJavaScriptReturningResult(
|
||||
'document.documentElement.scrollHeight',
|
||||
);
|
||||
final h = double.tryParse(result.toString());
|
||||
if (h != null && h > 0 && mounted) {
|
||||
setState(() => _height = h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,14 +184,12 @@ class _SecureEmailWebViewState extends State<SecureEmailWebView> {
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
final launched =
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Could not open: $url')));
|
||||
ScaffoldMessenger.of(context).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}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/sharedinbox.png"
|
||||
DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
@@ -31,8 +31,6 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
gtk_window_set_icon_from_file(window, "sharedinbox.png", nullptr);
|
||||
|
||||
// Show AFTER adding FlView so GTK's first layout pass allocates the full
|
||||
// window content area (1280×800) to FlView, not the default 1×1.
|
||||
gtk_widget_show_all(GTK_WIDGET(window));
|
||||
|
||||
|
Before Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,66 @@
|
||||
# Next
|
||||
|
||||
## Introduction
|
||||
|
||||
Continue the momentum from the safety hardening and infrastructure work.
|
||||
The focus is on making the app ready for real-world use with robust error
|
||||
handling and performance optimizations.
|
||||
|
||||
Create several small commits. Every commit should be self contained.
|
||||
|
||||
while working create/append to plan.log, so that the user sees what you are working on.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 0. deploy-android
|
||||
|
||||
Make `task deploy-android` work.
|
||||
|
||||
### 0.5 Debug duration of deploy-android
|
||||
|
||||
Is there a way to make deploy-android faster?
|
||||
|
||||
Use `task --verbose` to see what gets done.
|
||||
|
||||
Maybe avoid doing things again, when nothing changed.
|
||||
Taskfile has features to avoid calling things again, when the input has not changed.
|
||||
|
||||
### 1. Fix Android E2E Race Condition (aliceTile)
|
||||
|
||||
The Android E2E test `integration_test/app_e2e_test.dart` is flaky. It fails
|
||||
at `tap(aliceTile)` with "0 widgets" even though `pumpUntil` found it.
|
||||
The current "double pumpUntil" fix isn't reliable enough.
|
||||
Investigate if the animation state or the Drift stream propagation is the
|
||||
culprit.
|
||||
|
||||
### 2. Implement Global Crash Screen
|
||||
|
||||
Wrap `main()` in `runZonedGuarded` to catch unhandled async errors.
|
||||
Implement a `CrashScreen` widget that shows the stack trace and a
|
||||
"Copy to Clipboard" button for user reporting.
|
||||
|
||||
### 3. Database-Backed Threading
|
||||
|
||||
Currently, emails are grouped into threads in-memory in the repository.
|
||||
Refactor to store thread relationships in the local SQLite database.
|
||||
This is necessary for performance on mailboxes with thousands of messages.
|
||||
|
||||
### 4. Implement Undo for Bulk Actions
|
||||
|
||||
Add a global "Undo" snackbar after deleting or moving emails.
|
||||
The system needs to handle the three sync states:
|
||||
- Queued (easy to undo)
|
||||
- In-progress (cancel network call)
|
||||
- Finished (requires a reverse move/un-delete)
|
||||
|
||||
### 5. Transition to Real Account Testing
|
||||
|
||||
Prepare the integration tests to run against a real test account
|
||||
(`si3e2e@thomas-guettler.de`) instead of the local Stalwart server.
|
||||
This verifies the app against real-world network latency and RFC edge cases.
|
||||
|
||||
### 6. Coverage Gate Maintenance
|
||||
|
||||
Reduce the `_excluded` list in `scripts/check_coverage.dart`.
|
||||
Add a test to ensure the exclusion list doesn't contain files that no longer
|
||||
exist ("ghost paths").
|
||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 78 KiB |