Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 7cc44af77a feat: confirm PR #307 features already merged — close issue #299 (#315)
All features from PR #307 (issue #299 — configurable back button position
for single mail view) were already merged into main via separate PRs:
- configurable menu bar position (#298/#303)
- configurable back button / mail view button position (#300/#308)
- configurable after-mail action (#300/#308)
- archive button + resolveMailboxByRole (#287/#291, #286/#290)
- user preferences DB schema v34–v36

PR #307 and issue #299 have been closed as superseded.

Also fix duplicate entry in scripts/check_coverage.dart (line 81 was a
duplicate of line 79, causing a Dart constant evaluation error).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:27:13 +02:00
Bot of Thomas Güttler 91083218d4 fix: diff from last deployed SHA to catch all changes since last deploy (#320) (#332) 2026-05-29 17:34:21 +02:00
Bot of Thomas Güttler adc4eb6f6d feat: remove publish-website from deploy.yml, schedule website.yml hourly (#325) (#330) 2026-05-29 12:53:18 +02:00
Bot of Thomas Güttler 05d00bdf09 fix: move overflow actions into popup menu so three-dot menu is always visible (#312) (#323) 2026-05-28 07:19:11 +02:00
Bot of Thomas Güttler c45775be92 fix: move sync health report to own row below each account (#311) (#322) 2026-05-28 06:53:11 +02:00
47fc534a8d fix: disable github-actions manager to suppress GitHub token warning (#285) (#306)
## Summary

- Disables the `github-actions` Renovate manager in `renovate.json`
- Removes the previous `fileMatch` override that pointed Renovate at Forgejo workflow files
- Stops Renovate from scanning workflow YAML files for action version updates, eliminating GitHub API calls and the "GitHub token is required" warning

## Test plan

- [ ] Verify `renovate.json` is valid JSON (done locally with `python3 -m json.tool`)
- [ ] Confirm the next Renovate run no longer produces the GitHub token warning in its logs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/306
2026-05-28 05:03:02 +02:00
Bot of Thomas Güttler a5928c1aa6 fix: add _tea_get and merged-PR catch-up to close issues on merge (#305) (#310) 2026-05-28 00:07:13 +02:00
Bot of Thomas Güttler 7f3cd43d6e feat: add --dangerously-skip-permissions to claude --resume output (#304) (#309) 2026-05-27 23:48:12 +02:00
Bot of Thomas Güttler f0f210e5ab feat: configurable next action after single mail view (#300) (#308) 2026-05-27 23:33:14 +02:00
Bot of Thomas Güttler 41550eb4b5 feat: configurable menu bar position for mailbox view (#298) (#303) 2026-05-27 22:07:12 +02:00
Bot of Thomas Güttler 633fc5d9da fix: show full discrepancy details in account list (#296) (#301) 2026-05-27 21:20:19 +02:00
Bot of Thomas Güttler 14f64cd2a5 feat: show URL tooltip on long-press of unsubscribe chip (#294) (#295) 2026-05-27 21:02:30 +02:00
Bot of Thomas Güttler 5ddfe68467 feat: catch up Renovate PRs with passing CI in agent loop (#289) (#293) 2026-05-27 20:09:13 +02:00
Bot of Thomas Güttler f42522e6d0 Merge pull request 'chore(deps): update gradle to v8.14.5' (#274) from renovate/gradle-8.x into main 2026-05-27 20:02:49 +02:00
guettlibotandBot of Thomas Güttler db78d590ca chore(deps): update opentelemetry-go monorepo to v0.19.0 (#279) 2026-05-27 20:00:52 +02:00
Bot of Thomas Güttler dbb29fb76a fix: rename workflow to Update Website and guard verify step (#282) (#283) 2026-05-27 20:00:39 +02:00
guettlibotandBot of Thomas Güttler 2d2d12cc24 chore(deps): update dependency flutter to v3.44.0 (#278) 2026-05-27 20:00:08 +02:00
guettlibotandBot of Thomas Güttler 3f0b3e5096 fix(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 (#275) 2026-05-27 19:59:21 +02:00
guettlibotandBot of Thomas Güttler 38fab3f5fc chore(deps): update gradle to v8.14.5 (#274) 2026-05-27 19:58:36 +02:00
Bot of Thomas Güttler e2b08e07b7 fix: prevent HTML email content from being cut off (#288) (#292) 2026-05-27 19:52:14 +02:00
Bot of Thomas Güttler c0dd13be5d feat: align single and multi-mail actions, add archive (#287) (#291) 2026-05-27 19:36:13 +02:00
Bot of Thomas Güttler 4e32984ecc fix: prompt to create or pick folder when archive is missing (#286) (#290) 2026-05-27 19:06:37 +02:00
Bot of Thomas Güttler 2f975829e5 feat: auto-merge safe Renovate PRs via CI (#277) (#284) 2026-05-27 09:37:15 +02:00
Bot of Thomas Güttler 73bbfd2694 fix: add explicit note that app settings are never uploaded (#280) (#281) 2026-05-27 08:25:20 +02:00
Thomas SharedInbox 49e6b335d9 better err msg in agent-loop. 2026-05-27 08:14:42 +02:00
guettlibot 96bd351512 chore(deps): update gradle to v8.14.5 2026-05-27 06:06:19 +00:00
Thomas SharedInboxandClaude Sonnet 4.6 e8234981c5 fix(renovate): run sed as root to patch read-only dist files
The /usr/local/renovate/dist directory is owned by root.
Temporarily switch to root for the sed patch, then back to ubuntu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:55:31 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cf94c7c1fb fix(renovate): patch forgejo+gitea pr-cache.js at /dist/ path
Files are under dist/ not lib/, and we need to patch both
forgejo and gitea platform caches since platform=forgejo is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:39:13 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 92183a3eb2 chore(renovate): diagnostic step to find pr-cache.js location
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:29:09 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4e8a5ff968 fix(renovate): use find to locate pr-cache.js before patching
The file is not at the assumed path; use find to locate it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:19:48 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 33f1c5a9d4 fix(renovate): patch pr-cache.js to use limit=10 for Codeberg
Codeberg's API times out (504) on GET /pulls?state=all&limit=100
but completes in ~9s at limit=10. Patch the compiled pr-cache.js
in the renovate:43 image before running to replace the hardcoded
20/100 page sizes with 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:18:02 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 0552b7a48c fix(renovate): pre-seed PR cache to avoid Codeberg 504 on initial sync
Codeberg's API times out (504) when fetching 100 closed PRs
(GET /pulls?state=all&limit=100), but succeeds with limit=20.
Renovate uses limit=100 on the first run and limit=20 on incremental
syncs. Pre-seeding the repository cache with one dummy entry tricks
Renovate into using the limit=20 incremental path from the start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:09:41 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2f0da5b475 fix(renovate): upgrade to renovate:43 with forgejo platform
renovate/renovate:39 did not support "forgejo" as a platform name;
v43 does. Upgrade the image and restore the correct platform name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:28:15 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 a1f8bb5994 fix: use RENOVATE_PLATFORM=gitea for renovate/renovate:39
renovate/renovate:39 does not recognise "forgejo" as a platform name;
the correct value is "gitea", which covers Forgejo/Gitea instances
including Codeberg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:27:15 +02:00
Bot of Thomas Güttler 6714e330cc Merge pull request 'feat: run Firebase tests once daily via dedicated workflow (#272)' (#273) from issue-272-fix into main 2026-05-26 17:20:37 +02:00
Thomas SharedInbox a8d6ec5861 fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:22:23 +02:00
Thomas SharedInbox 491a220fbb fix: use commit_sha instead of head_sha to detect already-deployed commits
Forgejo's API returns head_sha=null in workflow run objects; the correct
field is commit_sha. The skip-check always got None, so every hourly
schedule triggered a full redeploy of the same commit.
2026-05-26 15:21:50 +02:00
Thomas SharedInbox e22c4aa88d fix: use Dagger for website deploy and record Renovate Bot completion (#267, #268) 2026-05-26 15:09:59 +02:00
Thomas SharedInbox 4bc24072f0 feat: run Firebase tests once daily via dedicated workflow (#272) 2026-05-26 15:09:55 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 720c54433a feat: run Firebase tests once daily via dedicated workflow (#272)
Move Android Firebase instrumented tests out of deploy.yml into a new
firebase-tests.yml workflow that runs once per day (3 AM UTC) and only
when Firebase-relevant files changed in the last 24 hours. On failure,
the workflow automatically creates a Forgejo issue labelled "Ready" with
instructions to find the root cause and fix it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:48:10 +02:00
Thomas SharedInbox dd26086220 docs: record Renovate Bot completion and close issue #257 (#268)
All required components (renovate.json, ci/main.go Renovate() function,
.forgejo/workflows/renovate.yml, Taskfile.yml renovate task) were already
in main. Closed issue #257.
2026-05-26 08:19:49 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2747ff0dca fix: use Dagger for website deploy instead of bare hugo call (#267)
Replace `task website-deploy` (which calls `hugo` directly and fails
because Hugo is not installed on the CI runner) with the Dagger-based
`task publish-website`, matching the pattern used by other jobs in
deploy.yml. Also adds Dagger remote engine setup, runner tool checks,
SSH_KNOWN_HOSTS secret, a timeout, and TLS credential cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:01:37 +02:00
Bot of Thomas Güttler f57a8c502d feat: syncLog add Copy button, stack trace, isPermanent (#266) (#269) 2026-05-26 07:55:07 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c4efb56a0c feat: syncLog add Copy button, stack trace, isPermanent, Android device info (#266)
- Schema v33: add error_stack_trace and is_permanent columns to sync_logs
- SyncLogEntry gains stackTrace and isPermanent fields; SyncLogRepository.log()
  gains matching optional parameters; IMAP and JMAP sync loops forward the
  stack trace string and isPermanent flag when writing error entries
- New lib/ui/utils/about_markdown.dart utility shared by AboutScreen and the
  sync log copy feature; builds the markdown table including device info
- AboutScreen uses the utility (refactored to remove duplicate _buildMarkdown)
- SyncLogScreen: subtitle shows "Error (permanent)" for permanent errors;
  expanded view shows stack trace in red monospace; each tile has a Copy
  button that copies a markdown summary of the entry plus the About section
- Migration test updated for v33; new repo test for stackTrace/isPermanent
- check_coverage.dart excludes lib/ui/utils/about_markdown.dart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 07:49:56 +02:00
c97e3d505f fix: skip deploy when HEAD already successfully deployed (#264) (#265)
## Summary

- The hourly `deploy.yml` schedule re-deployed the same commit repeatedly because it always diffed `HEAD~1..HEAD` — once a commit touching `lib/`/`pubspec.*` became HEAD, every hourly tick would detect "android changes" and deploy again.
- Fix: at the start of the `check-changes` job, query the Forgejo workflow runs API for the last successful `deploy.yml` run. If its `head_sha` matches current HEAD, output `android=false` / `linux=false` immediately, skipping all downstream jobs.
- `workflow_dispatch` bypasses this check (always deploys), matching the existing behaviour.

## Test plan

- [ ] Verify the `check-changes` job exits early on the next scheduled run after a successful deploy of the same commit
- [ ] Verify a new commit still triggers deployment normally
- [ ] Verify `workflow_dispatch` still deploys unconditionally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/265
2026-05-26 07:35:18 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2bb7ac11df feat: add runner tools check and LOG_LEVEL to Renovate Bot (#257)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:24:47 +02:00
Bot of Thomas Güttler 8709e9f38d feat: add Locale, Text Scale, DB Schema Version, Device Model to About page (#258) (#263) 2026-05-25 22:18:09 +02:00
Bot of Thomas Güttler 7997ff0980 feat: Reply All dialog on Reply button, add Mark as Spam (#260) (#261) 2026-05-25 21:51:08 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 2359c7d586 feat: run Renovate via Dagger on daily schedule (#257, #216)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:27:01 +02:00
66 changed files with 3274 additions and 553 deletions
+48
View File
@@ -109,3 +109,51 @@ jobs:
- name: Cleanup TLS credentials - name: Cleanup TLS credentials
if: always() if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
merge-renovate:
name: Auto-merge Renovate PR
needs: [check]
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Merge if automerge label is set
env:
FORGEJO_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error, sys
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
pr_number = os.environ["PR_NUMBER"]
api = f"{url_base}/api/v1/repos/{repo}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
with urllib.request.urlopen(req) as r:
labels = [l["name"] for l in json.loads(r.read())]
if "automerge" not in labels:
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
sys.exit(0)
body = json.dumps({"Do": "merge"}).encode()
req = urllib.request.Request(
f"{api}/pulls/{pr_number}/merge",
data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as r:
print(f"PR #{pr_number} merged successfully")
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already been merged" in err or "has been merged" in err:
print(f"PR #{pr_number} already merged — OK")
else:
print(f"Merge failed: {err}")
sys.exit(1)
PYEOF
+48 -88
View File
@@ -17,11 +17,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 0
- name: Detect Android and Linux changes - name: Detect Android and Linux changes
id: diff id: diff
shell: bash shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: | run: |
# On workflow_dispatch always build everything # On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
@@ -30,10 +32,49 @@ jobs:
exit 0 exit 0
fi fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files HEAD_SHA=$(git rev-parse HEAD)
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \ # Skip if this exact commit was already successfully deployed (prevents
|| git show --name-only --format= HEAD) # hourly schedule from redeploying the same commit on every tick).
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
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"
]
print(runs[0].get("commit_sha") or "")
except Exception as e:
print(f"API check failed: {e}", file=sys.stderr)
print("")
PYEOF
)
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$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. Falls back to HEAD~1 when
# LAST_DEPLOYED_SHA is unknown or not in local history.
if [ -n "$LAST_DEPLOYED_SHA" ] && 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
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
fi
echo "Changed files:" echo "Changed files:"
echo "$CHANGED" echo "$CHANGED"
@@ -49,44 +90,6 @@ jobs:
&& echo "linux=true" >> "$GITHUB_OUTPUT" \ && echo "linux=true" >> "$GITHUB_OUTPUT" \
|| echo "linux=false" >> "$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: deploy-playstore:
name: Build & Deploy to Play Store name: Build & Deploy to Play Store
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -208,55 +211,12 @@ jobs:
if: always() if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux] needs: [deploy-playstore, deploy-apk, build-linux]
if: | if: |
always() && vars.DEPLOY_HEALTH_ISSUE != '' && ( 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-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' || needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure' needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
@@ -269,7 +229,7 @@ jobs:
FORGEJO_TOKEN: ${{ github.token }} FORGEJO_TOKEN: ${{ github.token }}
FORGEJO_URL: ${{ github.server_url }} FORGEJO_URL: ${{ github.server_url }}
DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }} DEPLOY_HEALTH_ISSUE: ${{ vars.DEPLOY_HEALTH_ISSUE }}
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') }} 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') }}
run: | run: |
python3 - << 'PYEOF' python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error import os, json, urllib.request, urllib.error
+132
View File
@@ -0,0 +1,132 @@
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; }
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
- 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
+6
View File
@@ -14,6 +14,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - 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; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel) - name: Setup Dagger Remote Engine (via stunnel)
env: env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }} DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
+29 -4
View File
@@ -1,6 +1,8 @@
name: Deploy Website name: Update Website
on: on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push: push:
branches: [main] branches: [main]
paths: paths:
@@ -11,22 +13,45 @@ on:
jobs: jobs:
deploy: deploy:
name: Build & Deploy Website name: Build & Update Website
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: recursive submodules: recursive
- name: Build & Deploy Website - 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: Build & Update Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }} SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }} SSH_HOST: ${{ secrets.SSH_HOST }}
run: task website-deploy DAGGER_NO_NAG: "1"
run: task publish-website
- name: Verify Website - name: Verify Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env: env:
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }} SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh run: scripts/website-verify.sh
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.41.6" "flutter": "3.44.0"
} }
+2 -2
View File
@@ -33,12 +33,12 @@ repos:
- id: ci-no-direct-dagger - id: ci-no-direct-dagger
name: check for direct dagger calls in workflows (use Task instead) name: check for direct dagger calls in workflows (use Task instead)
language: system language: system
entry: "bash -c 'git grep \"dagger call\" .forgejo/workflows/ && echo \"ERROR: Direct dagger calls found in workflows. Use Taskfile instead.\" && exit 1 || exit 0'" 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'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
- id: dagger-progress-plain - id: dagger-progress-plain
name: ensure all dagger calls use --progress=plain name: ensure all dagger calls use --progress=plain
language: system language: system
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'" entry: "bash -c 'git --no-pager grep \"dagger call\" -- \":!.pre-commit-config.yaml\" | grep -v \"\\-\\-progress=plain\" && echo \"ERROR: All dagger calls must include --progress=plain\" && exit 1 || exit 0'"
pass_filenames: false pass_filenames: false
always_run: true always_run: true
+1 -1
View File
@@ -294,7 +294,7 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2 echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2 echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
+1 -1
View File
@@ -67,7 +67,7 @@ flutter {
dependencies { dependencies {
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26. // Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// integration_test is a dev dependency; the Flutter plugin loader adds it as // integration_test is a dev dependency; the Flutter plugin loader adds it as
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main) // debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
// references its class in all variants. Make it available for release compilation // references its class in all variants. Make it available for release compilation
@@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine;
public final class GeneratedPluginRegistrant { public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant"; private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) { 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 { try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) { } catch (Exception e) {
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
+4 -4
View File
@@ -44,10 +44,10 @@ require (
google.golang.org/protobuf v1.36.11 // 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/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0 replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0 replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
+13 -1
View File
@@ -844,12 +844,24 @@ func (m *Ci) PublishAndroid(
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg. // Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) { 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(). return dag.Container().
From("renovate/renovate:39"). From("renovate/renovate:43").
WithSecretVariable("RENOVATE_TOKEN", renovateToken). WithSecretVariable("RENOVATE_TOKEN", renovateToken).
WithEnvVariable("RENOVATE_PLATFORM", "forgejo"). WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org"). WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox"). WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithUser("root").
WithExec([]string{"/bin/sh", "-c", patchCmd}).
WithUser("ubuntu").
WithExec([]string{"renovate"}). WithExec([]string{"renovate"}).
Stdout(ctx) Stdout(ctx)
} }
+22
View File
@@ -4,6 +4,28 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `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) ## Tasks (2026-05-11)
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering - **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
+2 -2
View File
@@ -317,7 +317,7 @@ void main() {
// ── Check Sent folder ────────────────────────────────────────────────── // ── Check Sent folder ──────────────────────────────────────────────────
// Use the drawer to switch folders (no back button on Linux desktop). // Use the drawer to switch folders (no back button on Linux desktop).
await tester.tap(find.byTooltip('Open navigation menu')); await tester.tap(find.byTooltip('Open folders'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Sent')); await tester.tap(find.text('Sent'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -331,7 +331,7 @@ void main() {
expect(find.text(subject), findsOneWidget); expect(find.text(subject), findsOneWidget);
// ── Check Inbox ──────────────────────────────────────────────────────── // ── Check Inbox ────────────────────────────────────────────────────────
await tester.tap(find.byTooltip('Open navigation menu')); await tester.tap(find.byTooltip('Open folders'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('INBOX')); await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
+1
View File
@@ -0,0 +1 @@
const int dbSchemaVersion = 36;
+14
View File
@@ -0,0 +1,14 @@
enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
}
@@ -11,4 +11,13 @@ abstract class MailboxRepository {
/// Deletes all locally-cached mailbox rows for [accountId]. /// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String 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,
);
} }
@@ -19,6 +19,8 @@ class SyncLogEntry {
required this.id, required this.id,
required this.result, required this.result,
this.errorMessage, this.errorMessage,
this.stackTrace,
this.isPermanent = false,
required this.protocol, required this.protocol,
required this.emailsFetched, required this.emailsFetched,
required this.emailsSkipped, required this.emailsSkipped,
@@ -34,6 +36,8 @@ class SyncLogEntry {
final int id; final int id;
final String result; // 'ok' or 'error' final String result; // 'ok' or 'error'
final String? errorMessage; final String? errorMessage;
final String? stackTrace;
final bool isPermanent;
final String protocol; // 'imap' or 'jmap' final String protocol; // 'imap' or 'jmap'
final int emailsFetched; final int emailsFetched;
final int emailsSkipped; final int emailsSkipped;
@@ -54,6 +58,8 @@ abstract class SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -81,6 +87,8 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -0,0 +1,8 @@
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);
}
+4
View File
@@ -260,6 +260,8 @@ class _AccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap', protocol: 'imap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
@@ -513,6 +515,8 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id, accountId: account.id,
success: false, success: false,
errorMessage: e.toString(), errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap', protocol: 'jmap',
emailsFetched: 0, emailsFetched: 0,
emailsSkipped: 0, emailsSkipped: 0,
+42 -1
View File
@@ -6,6 +6,7 @@ import 'package:drift/native.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
part 'database.g.dart'; part 'database.g.dart';
@@ -192,6 +193,9 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()(); DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true. // Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()(); 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. /// Per-mailbox breakdown for a single sync cycle.
@@ -303,6 +307,23 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId}; Set<Column> get primaryKey => {accountId, messageId};
} }
/// 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'))();
@override
Set<Column> get primaryKey => {id};
}
// ── Database ────────────────────────────────────────────────────────────────── // ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase( @DriftDatabase(
@@ -323,13 +344,14 @@ class LocalSieveApplied extends Table {
LocalSieveScripts, LocalSieveScripts,
LocalSieveApplied, LocalSieveApplied,
ShareKeys, ShareKeys,
UserPreferences,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override @override
int get schemaVersion => 32; int get schemaVersion => dbSchemaVersion;
Future<void> _createEmailFts() async { Future<void> _createEmailFts() async {
await customStatement(''' await customStatement('''
@@ -570,6 +592,25 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) { if (from < 32) {
await m.createTable(localSieveApplied); 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,
);
}
}, },
); );
} }
@@ -79,6 +79,14 @@ class MailboxRepositoryImpl implements MailboxRepository {
); );
try { try {
final mailboxes = await client.listMailboxes(recursive: true); 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) { for (final mb in mailboxes) {
final path = mb.path; final path = mb.path;
final id = '${account.id}:$path'; final id = '${account.id}:$path';
@@ -96,6 +104,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
log('STATUS skipped for $path: $e'); 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( await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert( MailboxesCompanion.insert(
id: id, id: id,
@@ -104,7 +118,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name, name: mb.name,
unreadCount: Value(unread), unreadCount: Value(unread),
totalCount: Value(total), totalCount: Value(total),
role: Value(_imapRole(mb)), role: Value(role),
), ),
); );
} }
@@ -310,4 +324,104 @@ class MailboxRepositoryImpl implements MailboxRepository {
..where((t) => t.accountId.equals(accountId))) ..where((t) => t.accountId.equals(accountId)))
.go(); .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);
}
}
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, '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);
}
} }
@@ -13,6 +13,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -30,6 +32,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId, accountId: accountId,
result: success ? 'ok' : 'error', result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage), errorMessage: Value(errorMessage),
errorStackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol), protocol: Value(protocol),
itemsSynced: Value(emailsFetched), itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped), emailsSkipped: Value(emailsSkipped),
@@ -75,6 +79,8 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id, id: r.id,
result: r.result, result: r.result,
errorMessage: r.errorMessage, errorMessage: r.errorMessage,
stackTrace: r.errorStackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol, protocol: r.protocol,
emailsFetched: r.itemsSynced, emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped, emailsSkipped: r.emailsSkipped,
@@ -0,0 +1,68 @@
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),
),
);
}
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,
),
);
}
}
+15 -1
View File
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model; import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
@@ -13,6 +14,7 @@ import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/undo_repository.dart'; import 'package:sharedinbox/core/repositories/undo_repository.dart';
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
@@ -21,7 +23,8 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart'; import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart'; import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart'; import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody; import 'package:sharedinbox/data/db/database.dart'
hide Email, EmailBody, UserPreferences;
import 'package:sharedinbox/data/db/local_sieve_repository.dart'; import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart'; import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart'; import 'package:sharedinbox/data/jmap/sieve_repository.dart';
@@ -33,6 +36,7 @@ import 'package:sharedinbox/data/repositories/search_history_repository_impl.dar
import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart'; import 'package:sharedinbox/data/repositories/share_key_repository_impl.dart';
import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart'; import 'package:sharedinbox/data/repositories/sync_log_repository_impl.dart';
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart'; import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart'; import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
/// Swappable IMAP connection factory — override in tests to use plaintext. /// Swappable IMAP connection factory — override in tests to use plaintext.
@@ -227,3 +231,13 @@ final accountConnectionStatusProvider =
.read(connectionTestServiceProvider) .read(connectionTestServiceProvider)
.testConnection(account, password); .testConnection(account, password);
}); });
final userPreferencesRepositoryProvider =
Provider<UserPreferencesRepository>((ref) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
});
final userPreferencesProvider =
StreamProvider.autoDispose<UserPreferences>((ref) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
+5
View File
@@ -20,6 +20,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart'; import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/undo_log_screen.dart'; import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'package:sharedinbox/ui/widgets/undo_shell.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart';
final router = GoRouter( final router = GoRouter(
@@ -56,6 +57,10 @@ final router = GoRouter(
path: 'about', path: 'about',
builder: (ctx, state) => const AboutScreen(), builder: (ctx, state) => const AboutScreen(),
), ),
GoRoute(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute( GoRoute(
path: ':accountId/edit', path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen( builder: (ctx, state) => EditAccountScreen(
+37 -51
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -8,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget { class AboutScreen extends ConsumerStatefulWidget {
@@ -19,57 +19,22 @@ class AboutScreen extends ConsumerStatefulWidget {
class _AboutScreenState extends ConsumerState<AboutScreen> { class _AboutScreenState extends ConsumerState<AboutScreen> {
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform(); final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
late final Future<String?> _deviceModelFuture;
late final Stream<List<Account>> _accountsStream; late final Stream<List<Account>> _accountsStream;
String? _deviceModel;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts(); _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( Future<void> _copyToClipboard(
BuildContext context, BuildContext context,
int imapCount, int imapCount,
@@ -79,10 +44,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: _buildMarkdown(context, pkg, imapCount, jmapCount), text: buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
), ),
); );
if (context.mounted) { if (context.mounted) {
@@ -128,9 +103,19 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
pkg = await _packageInfoFuture; pkg = await _packageInfoFuture;
} catch (_) {} } catch (_) {}
String? deviceModel;
try {
deviceModel = await _deviceModelFuture;
} catch (_) {}
if (!context.mounted) return; if (!context.mounted) return;
final body = Uri.encodeComponent( final body = Uri.encodeComponent(
_buildMarkdown(context, pkg, imapCount, jmapCount), buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
),
); );
final url = Uri.parse( final url = Uri.parse(
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body', 'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
@@ -181,11 +166,12 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Markdown( return Markdown(
data: _buildMarkdown( data: buildAboutMarkdown(
context, context: context,
snapshot.data, pkg: snapshot.data,
imapCount, imapCount: imapCount,
jmapCount, jmapCount: jmapCount,
deviceModel: _deviceModel,
), ),
selectable: true, selectable: true,
onTapLink: (text, href, title) { onTapLink: (text, href, title) {
+112 -71
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -66,6 +67,14 @@ class AccountListScreen extends ConsumerWidget {
unawaited(context.push('/accounts/about')); unawaited(context.push('/accounts/about'));
}, },
), ),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context); // Close drawer
unawaited(context.push('/accounts/preferences'));
},
),
], ],
), ),
), ),
@@ -111,20 +120,80 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id)); final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP'; final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile( return Column(
leading: const Icon(Icons.account_circle), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(account.displayName), children: [
subtitle: Column( ListTile(
crossAxisAlignment: CrossAxisAlignment.start, leading: const Icon(Icons.account_circle),
children: [ title: Text(account.displayName),
Text('${account.email}\n$typeLabel'), subtitle: Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4), isThreeLine: true,
health.when( 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(
data: (h) { data: (h) {
if (h == null) return const Text('Sync health: Not verified yet'); if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row( return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Sync health: '), const Text('Sync health: '),
Icon( Icon(
@@ -133,7 +202,13 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange, color: h.isHealthy ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'), Expanded(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)), Text(' ($date)', style: const TextStyle(fontSize: 10)),
], ],
); );
@@ -141,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'), loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'), error: (e, _) => Text('Sync health error: $e'),
), ),
], ),
), ],
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
); );
} }
@@ -293,6 +310,30 @@ class _AccountTile extends ConsumerWidget {
} }
} }
String _formatDiscrepancies(String? summary) {
if (summary == null) return 'Discrepancies found';
try {
final decoded = jsonDecode(summary) as Map<String, dynamic>;
var missingLocally = 0;
var missingOnServer = 0;
var flagMismatches = 0;
for (final v in decoded.values) {
final m = v as Map<String, dynamic>;
missingLocally += (m['missingLocally'] as int? ?? 0);
missingOnServer += (m['missingOnServer'] as int? ?? 0);
flagMismatches += (m['flagMismatches'] as int? ?? 0);
}
final parts = <String>[];
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
if (parts.isEmpty) return 'Discrepancies found';
return 'Discrepancies found (${parts.join(', ')})';
} catch (_) {
return 'Discrepancies found';
}
}
class _OnboardingView extends StatelessWidget { class _OnboardingView extends StatelessWidget {
const _OnboardingView(); const _OnboardingView();
+79
View File
@@ -0,0 +1,79 @@
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;
}
+329 -56
View File
@@ -13,9 +13,11 @@ import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart'; import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -70,61 +72,25 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_reply(context, header, body, replyAll: false)); unawaited(
_replyWithRecipientDialog(context, header, body),
);
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.reply_all), icon: const Icon(Icons.archive),
tooltip: 'Reply all', tooltip: 'Archive',
onPressed: header == null onPressed: header == null
? null ? null
: () { : () {
unawaited(_reply(context, header, body, replyAll: true)); unawaited(_archive(context, header));
}, },
), ),
IconButton(
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 {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
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( IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
tooltip: 'Delete', tooltip: 'Delete',
onPressed: () async { onPressed: () async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final destPath = await repo.deleteEmail(widget.emailId); final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) { if (header != null) {
@@ -143,11 +109,44 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
}, },
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'forward',
child: Text('Forward'),
),
const PopupMenuItem(
value: 'move',
child: Text('Move to folder'),
),
const PopupMenuItem(
value: 'snooze',
child: Text('Snooze'),
),
const PopupMenuItem(
value: 'spam',
child: Text('Mark as spam'),
),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuDivider(),
const PopupMenuItem( const PopupMenuItem(
value: 'headers', value: 'headers',
child: Text('Show Mail Headers'), child: Text('Show Mail Headers'),
@@ -161,8 +160,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text('Show Raw Email'), child: Text('Show Raw Email'),
), ),
], ],
onSelected: (value) { onSelected: (value) async {
if (value == 'headers' && body != null) { 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) {
_showHeaders(context, body); _showHeaders(context, body);
} else if (value == 'structure' && body != null) { } else if (value == 'structure' && body != null) {
_showStructure(context, body); _showStructure(context, body);
@@ -241,6 +252,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex =
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async { Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename)); setState(() => _downloading.add(att.filename));
try { try {
@@ -303,17 +347,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
return '\n\n— On $date, $from wrote:\n$quoted'; return '\n\n— On $date, $from wrote:\n$quoted';
} }
Future<void> _reply( 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(
BuildContext context, BuildContext context,
Email header, Email header,
EmailBody? body, { EmailBody? body, {
required bool replyAll, required String to,
required String cc,
}) async { }) async {
final to = header.from.isNotEmpty ? header.from.first.email : '';
final subject = (header.subject?.startsWith('Re:') ?? false) final subject = (header.subject?.startsWith('Re:') ?? false)
? header.subject! ? header.subject!
: 'Re: ${header.subject ?? ''}'; : 'Re: ${header.subject ?? ''}';
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
final quoted = await _quotedBody(header, body); final quoted = await _quotedBody(header, body);
if (!context.mounted) return; if (!context.mounted) return;
unawaited( unawaited(
@@ -330,6 +435,78 @@ 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( Future<void> _forward(
BuildContext context, BuildContext context,
Email header, Email header,
@@ -352,6 +529,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
Future<void> _moveTo(BuildContext context, Email header) async { Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes = final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first; await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -400,10 +579,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _snooze(BuildContext context, Email header) async { Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>( final until = await showModalBottomSheet<DateTime>(
context: context, context: context,
builder: (ctx) => const SnoozePicker(), builder: (ctx) => const SnoozePicker(),
@@ -431,7 +613,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
), ),
); );
context.pop(); _navigateTo(context, header, nextEmailId);
} }
} }
@@ -670,6 +852,94 @@ 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(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, _candidates),
child: const Text('Reply'),
),
],
);
}
}
class _MimeRow { class _MimeRow {
const _MimeRow(this.depth, this.label); const _MimeRow(this.depth, this.label);
final int depth; final int depth;
@@ -712,10 +982,13 @@ class _UnsubscribeChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header); final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink(); if (uri == null) return const SizedBox.shrink();
return ActionChip( return Tooltip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16), message: uri.toString(),
label: const Text('Unsubscribe'), child: ActionChip(
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication), avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
),
); );
} }
} }
+57 -24
View File
@@ -8,8 +8,10 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart'; import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
@@ -147,16 +149,21 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId)); final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: _buildAppBar(repo, accountAsync), appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
drawer: _selecting drawer: _selecting
? null ? null
: FolderDrawer( : FolderDrawer(
accountId: widget.accountId, accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath, currentMailboxPath: widget.mailboxPath,
), ),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null, bottomNavigationBar: _selecting
? _selectionBottomBar()
: (menuAtBottom ? _folderNavBottomBar() : null),
body: Column( body: Column(
children: [ children: [
_buildSyncErrorBanner(), _buildSyncErrorBanner(),
@@ -172,12 +179,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
PreferredSizeWidget _buildAppBar( PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo, EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, AsyncValue<Account?> accountAsync, {
) { required bool menuAtBottom,
}) {
final selectionCount = final selectionCount =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length; _searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar( return AppBar(
automaticallyImplyLeading: !menuAtBottom,
leading: _selecting leading: _selecting
? IconButton( ? IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -300,6 +309,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
Widget _selectionBottomBar() { Widget _selectionBottomBar() {
return BottomAppBar( return BottomAppBar(
child: Row( child: Row(
@@ -420,24 +445,26 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
); );
} }
Future<void> _batchMoveToRole(String role, String notFoundMessage) async { Future<void> _batchMoveToRole(
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
_clearSelection(); _clearSelection();
final mailbox = await ref
.read(mailboxRepositoryProvider) final mailbox = await resolveMailboxByRole(
.findMailboxByRole(widget.accountId, role); context,
if (!mounted) return; ref.read(mailboxRepositoryProvider),
if (mailbox == null) { widget.accountId,
ScaffoldMessenger.of( widget.mailboxPath,
context, role,
).showSnackBar( dialogTitle: dialogTitle,
SnackBar( createFolderName: createFolderName,
duration: const Duration(seconds: 5), );
content: Text(notFoundMessage),
), if (!mounted || mailbox == null) return;
);
return;
}
final repo = ref.read(emailRepositoryProvider); final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo. // Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -463,8 +490,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
} }
Future<void> _batchArchive() => Future<void> _batchArchive() => _batchMoveToRole(
_batchMoveToRole('archive', 'No archive folder found'); 'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _refreshSearchAndPopIfEmpty() async { Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return; if (!mounted || !_searching) return;
@@ -543,8 +573,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
} }
} }
Future<void> _batchMarkSpam() => Future<void> _batchMarkSpam() => _batchMoveToRole(
_batchMoveToRole('junk', 'No spam folder found'); 'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMove() async { Future<void> _batchMove() async {
final ids = _selectedEmailIds; final ids = _selectedEmailIds;
+18
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -17,8 +18,12 @@ class MailboxListScreen extends ConsumerWidget {
final mailboxRepo = ref.watch(mailboxRepositoryProvider); final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider); final emailRepo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(accountId)); final accountAsync = ref.watch(accountByIdProvider(accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !menuAtBottom,
title: const Text('Folders'), title: const Text('Folders'),
actions: [ actions: [
IconButton( IconButton(
@@ -42,6 +47,19 @@ class MailboxListScreen extends ConsumerWidget {
], ],
), ),
drawer: FolderDrawer(accountId: accountId), drawer: FolderDrawer(accountId: accountId),
bottomNavigationBar: menuAtBottom
? BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
],
),
)
: null,
body: Column( body: Column(
children: [ children: [
// ── Failed-mutation banner ─────────────────────────────────────── // ── Failed-mutation banner ───────────────────────────────────────
+139 -5
View File
@@ -1,11 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.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/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss'); final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -21,6 +25,57 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; 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 { class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId}); const SyncLogScreen({super.key, required this.accountId});
@@ -69,6 +124,41 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -96,16 +186,20 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet')) ? const Center(child: Text('No sync entries yet'))
: ListView.builder( : ListView.builder(
itemCount: _entries.length, itemCount: _entries.length,
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]), itemBuilder: (ctx, i) => _SyncLogTile(
entry: _entries[i],
onCopy: () => _copyEntry(_entries[i], ctx),
),
), ),
); );
} }
} }
class _SyncLogTile extends StatelessWidget { class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry}); const _SyncLogTile({required this.entry, required this.onCopy});
final SyncLogEntry entry; final SyncLogEntry entry;
final VoidCallback onCopy;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -115,6 +209,12 @@ class _SyncLogTile extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final errorColor = theme.colorScheme.error; 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( return ExpansionTile(
leading: Icon( leading: Icon(
entry.isOk ? Icons.check_circle : Icons.error_outline, entry.isOk ? Icons.check_circle : Icons.error_outline,
@@ -125,11 +225,20 @@ class _SyncLogTile extends StatelessWidget {
style: entry.isOk ? null : TextStyle(color: errorColor), style: entry.isOk ? null : TextStyle(color: errorColor),
), ),
subtitle: Text( subtitle: Text(
entry.isOk subtitleText,
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor), 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: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12), padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -171,6 +280,31 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12), 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) ...[ if (entry.protocolLog != null) ...[
const Padding( const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2), padding: EdgeInsets.only(top: 6, bottom: 2),
+23 -1
View File
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Thread')), appBar: AppBar(
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
body: StreamBuilder<List<Email>>( body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
), ),
); );
} }
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
} }
class _EmailMessageCard extends ConsumerStatefulWidget { class _EmailMessageCard extends ConsumerStatefulWidget {
+145
View File
@@ -0,0 +1,145 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/user_preferences.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);
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,
),
],
),
),
],
),
),
);
}
}
+75
View File
@@ -0,0 +1,75 @@
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)}';
+5 -2
View File
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="$csp"> <meta http-equiv="Content-Security-Policy" content="$csp">
<style> <style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; } body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
img { max-width: 100%; height: auto; } img { max-width: 100%; height: auto; }
a { color: #1976D2; } a { color: #1976D2; }
* { box-sizing: border-box; } * { 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; }
</style> </style>
</head> </head>
<body> <body>
+32 -8
View File
@@ -249,6 +249,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
drift: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -643,10 +659,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1072,26 +1088,26 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.31.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.17"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
@@ -1284,6 +1300,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.0" version: "6.3.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
workmanager: workmanager:
dependency: "direct main" dependency: "direct main"
description: description:
+1
View File
@@ -61,6 +61,7 @@ dependencies:
# App version metadata for crash reports # App version metadata for crash reports
package_info_plus: ^10.1.0 package_info_plus: ^10.1.0
share_plus: ^13.1.0 share_plus: ^13.1.0
device_info_plus: ^13.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+8 -2
View File
@@ -5,6 +5,12 @@
], ],
"labels": ["dependencies"], "labels": ["dependencies"],
"github-actions": { "github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"] "enabled": false
} },
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"]
}
]
} }
+201 -57
View File
@@ -11,15 +11,18 @@ Flow
a. pending_issue type=="plan" post resume comment, set State/Planned, exit 0 a. pending_issue type=="plan" post resume comment, set State/Planned, exit 0
b. pending_issue + open PR check PR branch CI, merge/fix/wait as needed b. pending_issue + open PR check PR branch CI, merge/fix/wait as needed
c. Catch-up: orphaned issue-N-fix PRs with passing CI merge them c. Catch-up: orphaned issue-N-fix PRs with passing CI merge them
d. Main CI running save pending-ci state, exit 0 d. Catch-up: close issues for PRs already merged (e.g., merged manually after
e. Main CI failed start fix-CI agent (pushes fix to main), exit 0 State/Question was set because CI path filter didn't trigger) → exit 0
f. Main CI ok + pending_issue close the issue, exit 0 (dead code path e. Catch-up: Renovate PRs with passing CI merge them
f. Main CI running save pending-ci state, exit 0
g. Main CI failed start fix-CI agent (pushes fix to main), exit 0
h. Main CI ok + pending_issue close the issue, exit 0 (dead code path
section 2b always returns first) section 2b always returns first)
g. Main CI ok (or no run yet) find oldest ToPlan issue, start plan agent, i. Main CI ok (or no run yet) find oldest ToPlan issue, start plan agent,
save state, exit 0 save state, exit 0
h. No ToPlan issues find oldest Ready issue, start issue agent, j. No ToPlan issues find oldest Ready issue, start issue agent,
save state, exit 0 save state, exit 0
i. No Ready issues print "nothing to do", exit 0 k. No Ready issues print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes. Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
Plan agents must NOT write any code or create PRs; they only post a plan comment. Plan agents must NOT write any code or create PRs; they only post a plan comment.
@@ -32,7 +35,7 @@ Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
To resume the Claude conversation, look up the session UUID first: To resume the Claude conversation, look up the session UUID first:
scripts/agent_loop.py list # shows NAME and UUID columns scripts/agent_loop.py list # shows NAME and UUID columns
claude --resume <uuid> # use the UUID, NOT the session name claude --resume <uuid> --dangerously-skip-permissions # use the UUID, NOT the session name
""" """
import argparse import argparse
@@ -43,10 +46,12 @@ import shlex
import subprocess import subprocess
import sys import sys
import time import time
import urllib.error
import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# Cron runs with a minimal PATH; ensure Nix profile binaries (tea, claude) and ~/go/bin (fgj) are found. # Cron runs with a minimal PATH; ensure Nix profile binaries (claude) and ~/go/bin (fgj) are found.
os.environ["PATH"] = ( os.environ["PATH"] = (
f"{Path.home()}/.nix-profile/bin" f"{Path.home()}/.nix-profile/bin"
f":{Path.home()}/go/bin" f":{Path.home()}/go/bin"
@@ -97,22 +102,51 @@ def _fgj(*args: str) -> None:
) )
def _tea_get(path: str) -> dict | list | None: def _fgj_run_list(limit: int = 20) -> list[dict]:
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT """Return workflow runs via fgj actions run list."""
silently fails (exits 0) when unauthenticated, so writes must go via fgj.""" result = subprocess.run(
cmd = ["tea", "api", path] ["fgj", "--hostname", "codeberg.org", "actions", "run", "list",
result = subprocess.run(cmd, capture_output=True, text=True) "--repo", REPO, "--json", "-L", str(limit)],
capture_output=True, text=True,
)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError( raise RuntimeError(
f"tea api {path} failed:\n{result.stderr or result.stdout}" f"fgj actions run list failed:\n{result.stderr or result.stdout}"
) )
out = result.stdout.strip() out = result.stdout.strip()
if not out: if not out:
return None return []
data = json.loads(out) try:
if isinstance(data, dict) and "message" in data and "url" in data: data = json.loads(out)
raise RuntimeError(f"tea api {path} returned error: {data['message']}") except json.JSONDecodeError as exc:
return data raise RuntimeError(
f"fgj actions run list returned non-JSON:\n{out[:500]}"
) from exc
return data if isinstance(data, list) else []
def _tea_get(path: str) -> dict:
"""Make an authenticated GET request to the Codeberg API and return parsed JSON.
Tries FORGEJO_TOKEN env var first, then ``fgj auth token`` for the token.
"""
token = os.environ.get("FORGEJO_TOKEN", "")
if not token:
r = subprocess.run(
["fgj", "--hostname", "codeberg.org", "auth", "token"],
capture_output=True, text=True,
)
if r.returncode == 0:
token = r.stdout.strip()
url = f"https://codeberg.org/api/v1{path}"
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"GET {path}: HTTP {e.code} {e.reason}") from e
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None: def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
@@ -181,9 +215,8 @@ def _latest_main_ci_run() -> dict | None:
event=push and prettyref=main, so filtering by event alone is not enough. event=push and prettyref=main, so filtering by event alone is not enough.
We also require workflow_id == "ci.yml". We also require workflow_id == "ci.yml".
""" """
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", []) for run in data.get("workflow_runs", []):
for run in runs:
if (run.get("event") == "push" if (run.get("event") == "push"
and run.get("prettyref") == "main" and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"): and run.get("workflow_id") == "ci.yml"):
@@ -194,23 +227,22 @@ def _latest_main_ci_run() -> dict | None:
def _latest_ci_run_for_branch(branch: str) -> dict | None: def _latest_ci_run_for_branch(branch: str) -> dict | None:
"""Return the latest CI run for a specific branch, or None. """Return the latest CI run for a specific branch, or None.
Forgejo's workflow_runs API has no top-level head_branch field. For pull_request events the branch is embedded in the JSON ``event_payload``
For push events the branch is in ``prettyref``; for pull_request field; for push events it appears directly in ``prettyref``.
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
""" """
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20") data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
runs = (data or {}).get("workflow_runs", []) for run in data.get("workflow_runs", []):
for run in runs:
if run.get("event") == "pull_request": if run.get("event") == "pull_request":
try: payload_str = run.get("event_payload", "")
payload = json.loads(run.get("event_payload", "{}")) if payload_str:
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch: try:
return run payload = json.loads(payload_str)
except (json.JSONDecodeError, AttributeError): if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
pass return run
elif run.get("event") == "push": except (json.JSONDecodeError, AttributeError):
if run.get("prettyref") == branch: pass
return run elif run.get("event") == "push" and run.get("prettyref") == branch:
return run
return None return None
@@ -252,26 +284,76 @@ def _open_issue_prs() -> list[dict]:
return issue_prs return issue_prs
def _open_renovate_prs() -> list[dict]:
"""Return all open PRs from Renovate (renovate/* branches), oldest-first."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
prs = json.loads(result.stdout)
renovate_prs = [
pr for pr in prs
if (pr.get("head", {}).get("ref") or "").startswith("renovate/")
]
renovate_prs.sort(key=lambda p: p["number"])
return renovate_prs
def _merged_issue_prs() -> list[dict]:
"""Return recently merged PRs with issue-{N}-fix branches, oldest-first.
Used for catch-up: if the loop set State/Question (e.g., no CI run detected)
but the PR was later merged manually, we still want to close the issue.
"""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "closed", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
try:
prs = json.loads(result.stdout)
except json.JSONDecodeError:
return []
merged = []
for pr in prs:
if not pr.get("merged"):
continue
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
merged.append(pr)
merged.sort(key=lambda p: p["number"])
return merged
def _latest_ci_run_for_pr(pr_number: int) -> dict | None: def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number.""" """Return the latest CI run triggered by a pull_request event for the given PR number."""
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50") pr_ref = f"#{pr_number}"
runs = (data or {}).get("workflow_runs", []) for run in _fgj_run_list(limit=50):
for run in runs: if run.get("event") == "pull_request" and run.get("prettyref") == pr_ref:
try: return run
payload = json.loads(run.get("event_payload", "{}"))
if payload.get("pull_request", {}).get("number") == pr_number:
return run
except (json.JSONDecodeError, AttributeError):
pass
return None return None
def _get_issue_labels(issue: int) -> list[str]: def _get_issue_labels(issue: int) -> list[str]:
"""Return label names for an issue.""" """Return label names for an issue."""
data = _tea_get(f"repos/{REPO}/issues/{issue}") result = subprocess.run(
if not data: ["fgj", "--hostname", "codeberg.org", "issue", "view", str(issue),
"--repo", REPO, "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return [] return []
return [lbl["name"] for lbl in data.get("labels", [])] try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return []
return [lbl["name"] for lbl in data.get("issue", {}).get("labels", [])]
def _merge_pr(pr_number: int) -> None: def _merge_pr(pr_number: int) -> None:
@@ -287,8 +369,11 @@ def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: in
"merged" PR closed after a retry "merged" PR closed after a retry
"fallback" all options exhausted; caller should set State/Question "fallback" all options exhausted; caller should set State/Question
""" """
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}") try:
mergeable = (pr_data or {}).get("mergeable") pr_data = _tea_get(f"/repos/{REPO}/pulls/{pr_number}")
except RuntimeError:
pr_data = {}
mergeable = pr_data.get("mergeable")
if mergeable is False: if mergeable is False:
prompt = ( prompt = (
@@ -512,7 +597,7 @@ def cmd_list() -> int:
sessions.sort(reverse=True) sessions.sort(reverse=True)
total = len(sessions) total = len(sessions)
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)") print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid> --dangerously-skip-permissions)")
print(f" {'-'*16} {'-'*20} {'-'*36}") print(f" {'-'*16} {'-'*20} {'-'*36}")
for mtime, name, sid in sessions[:20]: for mtime, name, sid in sessions[:20]:
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M") ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
@@ -596,9 +681,9 @@ def _run_loop() -> int:
session_name = state.get("session_name") session_name = state.get("session_name")
uuid = _find_session_uuid(session_name) if session_name else None uuid = _find_session_uuid(session_name) if session_name else None
if uuid: if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}" resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
elif session_name: elif session_name:
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list" resume_cmd = f"claude --resume <uuid> --dangerously-skip-permissions # run: scripts/agent_loop.py list"
else: else:
resume_cmd = "" resume_cmd = ""
git_info = _git_summary() git_info = _git_summary()
@@ -627,7 +712,7 @@ def _run_loop() -> int:
session_name = f"plan-issue-{pending_issue}" session_name = f"plan-issue-{pending_issue}"
uuid = _find_session_uuid(session_name) uuid = _find_session_uuid(session_name)
if uuid: if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}" resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
_comment_issue( _comment_issue(
pending_issue, pending_issue,
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```", f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
@@ -816,6 +901,66 @@ def _run_loop() -> int:
print(f"Merged PR #{pr_number}.") print(f"Merged PR #{pr_number}.")
return 0 return 0
# ── 2c. Catch-up: close issues whose PRs were already merged ─────────────
# Handles the case where State/Question was set (e.g., no CI run appeared
# because the changed paths didn't match ci.yml's path filter) but the PR
# was merged manually afterward. The next loop tick closes the issue.
for pr in _merged_issue_prs():
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
if not m:
continue
issue_num = int(m.group(1))
try:
issue_data = _tea_get(f"/repos/{REPO}/issues/{issue_num}")
except RuntimeError:
continue
if issue_data.get("state") != "open":
continue
pr_number = pr["number"]
print(f"Catch-up (merged PR): PR #{pr_number} for issue #{issue_num} was merged — closing.")
try:
_close_issue(issue_num)
except RuntimeError as e:
print(f"Catch-up (merged PR): could not close issue #{issue_num}: {e}")
continue
return 0
# ── 2d. Catch-up: merge Renovate PRs with passing CI ─────────────────────
# The merge-renovate CI job only fires on pull_request events. If a Renovate
# PR had CI run before that job was added (or the automerge label was absent),
# it stays open forever. Detect and merge those here.
for pr in _open_renovate_prs():
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
pr_run = _latest_ci_run_for_pr(pr_number)
if pr_run and pr_run.get("status") == "running":
print(f"Catch-up (Renovate): CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"Catch-up (Renovate): CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
continue
if pr_run and pr_run.get("status") == "success":
print(f"Catch-up (Renovate): CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up (Renovate): merge of PR #{pr_number} failed: {e} — skipping.")
continue
branch = pr.get("head", {}).get("ref", "")
if _find_pr_for_branch(branch):
print(f"Catch-up (Renovate): PR #{pr_number} still open after merge — skipping.")
continue
print(f"Catch-up (Renovate): merged PR #{pr_number}.")
return 0
if pr_run is None:
print(f"Catch-up (Renovate): no CI run for PR #{pr_number} ({pr_url}) — skipping (needs manual review).")
# ── 3. Global CI check (main branch only) ──────────────────────────────── # ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run() run = _latest_main_ci_run()
@@ -831,9 +976,8 @@ def _run_loop() -> int:
# spawning another agent, check whether any CI run is currently in # spawning another agent, check whether any CI run is currently in
# progress (the branch run) and wait if so. # progress (the branch run) and wait if so.
if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start: if ci_run_id_at_start is not None and run["id"] == ci_run_id_at_start:
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
in_flight = [ in_flight = [
r for r in (check or {}).get("workflow_runs", []) r for r in _fgj_run_list(limit=5)
if r.get("status") == "running" if r.get("status") == "running"
] ]
if in_flight: if in_flight:
+7
View File
@@ -11,6 +11,7 @@ const _minCoveragePercent = 80;
// Pure-abstract interfaces: no executable code, Dart VM never instruments them. // Pure-abstract interfaces: no executable code, Dart VM never instruments them.
const _noCode = { const _noCode = {
'lib/core/db_schema_version.dart',
'lib/core/repositories/account_repository.dart', 'lib/core/repositories/account_repository.dart',
'lib/core/repositories/draft_repository.dart', 'lib/core/repositories/draft_repository.dart',
'lib/core/repositories/email_repository.dart', 'lib/core/repositories/email_repository.dart',
@@ -19,7 +20,9 @@ const _noCode = {
'lib/core/repositories/sync_log_repository.dart', 'lib/core/repositories/sync_log_repository.dart',
'lib/core/repositories/undo_repository.dart', 'lib/core/repositories/undo_repository.dart',
'lib/core/repositories/search_history_repository.dart', 'lib/core/repositories/search_history_repository.dart',
'lib/core/repositories/user_preferences_repository.dart',
'lib/core/models/undo_action.dart', 'lib/core/models/undo_action.dart',
'lib/core/models/user_preferences.dart',
'lib/core/storage/secure_storage.dart', 'lib/core/storage/secure_storage.dart',
}; };
@@ -57,6 +60,8 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart', 'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart', 'lib/ui/widgets/undo_shell.dart',
'lib/ui/screens/about_screen.dart', 'lib/ui/screens/about_screen.dart',
'lib/ui/screens/email_action_helpers.dart',
'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_tile.dart', 'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart', 'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart', 'lib/core/sync/background_sync.dart',
@@ -70,6 +75,8 @@ const _excluded = {
'lib/data/repositories/sync_log_repository_impl.dart', 'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart', 'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart', 'lib/data/repositories/search_history_repository_impl.dart',
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart', 'lib/core/services/update_service.dart',
}; };
+77 -1
View File
@@ -202,6 +202,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \
@@ -229,6 +230,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \
@@ -243,6 +245,7 @@ class TestMain(unittest.TestCase):
"""main() exits cleanly with 0 when there are no ready issues.""" """main() exits cleanly with 0 when there are no ready issues."""
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._set_labels") as mock_labels, \ patch("agent_loop._set_labels") as mock_labels, \
@@ -263,6 +266,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
patch("agent_loop._set_labels"), \ patch("agent_loop._set_labels"), \
@@ -442,6 +446,7 @@ class TestPendingCi(unittest.TestCase):
"type": "ci-fix", "type": "ci-fix",
}), \ }), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \ patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \ patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
@@ -459,6 +464,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
@@ -471,6 +477,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
@@ -482,6 +489,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \ patch("agent_loop._latest_main_ci_run", return_value=run), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
@@ -493,6 +501,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[issue]), \ patch("agent_loop._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \ patch("agent_loop._set_labels"), \
@@ -714,7 +723,7 @@ class TestRunLoopResumeCommand(unittest.TestCase):
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
output = buf.getvalue() output = buf.getvalue()
self.assertIn(f"claude --resume {fake_uuid}", output) self.assertIn(f"claude --resume {fake_uuid} --dangerously-skip-permissions", output)
def test_resume_shows_list_hint_when_uuid_not_found(self): def test_resume_shows_list_hint_when_uuid_not_found(self):
buf = io.StringIO() buf = io.StringIO()
@@ -757,6 +766,7 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
ci_run = {"id": 999, "status": "success"} ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \ patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \ patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \ patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \ patch("agent_loop._merge_pr") as mock_merge, \
@@ -785,6 +795,71 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
mock_merge.assert_called_once_with(50) mock_merge.assert_called_once_with(50)
class TestMergedPrCatchup(unittest.TestCase):
"""Catch-up closes issues whose PRs were already merged outside the normal flow."""
def _make_merged_pr(self, pr_number=283, branch="issue-282-fix"):
return {"number": pr_number, "merged": True, "head": {"ref": branch}}
def test_closes_issue_when_pr_was_merged(self):
"""When a merged issue-N-fix PR exists and the issue still has labels, close it."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(282)
def test_skips_when_issue_has_no_labels(self):
"""When _get_issue_labels returns [] (likely already closed), skip the issue."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
def test_output_mentions_merged_pr_and_issue(self):
"""The catch-up log line names the PR number and issue number."""
pr = self._make_merged_pr(pr_number=283, branch="issue-282-fix")
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue"), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("283", output)
self.assertIn("282", output)
def test_continues_on_close_error(self):
"""If _close_issue raises, the loop continues instead of crashing."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue", side_effect=RuntimeError("already closed")), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
class TestMergeFailsOpen(unittest.TestCase): class TestMergeFailsOpen(unittest.TestCase):
"""Tests for auto-resolution when a PR is still open after the merge command.""" """Tests for auto-resolution when a PR is still open after the merge command."""
@@ -928,6 +1003,7 @@ class TestHeartbeat(unittest.TestCase):
self.assertFalse(Path(self._tmp.name).exists()) self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]): patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop() agent_loop._run_loop()
@@ -149,6 +149,22 @@ class _FakeMailboxes implements MailboxRepository {
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
@@ -288,6 +304,8 @@ class _FakeLogs implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
+17
View File
@@ -181,6 +181,8 @@ class FakeSyncLogRepository implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -222,6 +224,21 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<Mailbox?> findMailboxByRole(String id, String role) async => null; Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _AccountRepositoryWithMissingPlugin implements AccountRepository { class _AccountRepositoryWithMissingPlugin implements AccountRepository {
+195 -157
View File
@@ -3,16 +3,16 @@
// Do not manually edit this file. // Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes // ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i4; import 'dart:async' as _i5;
import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i6; import 'package:mockito/src/dummies.dart' as _i7;
import 'package:sharedinbox/core/models/account.dart' as _i5; import 'package:sharedinbox/core/models/account.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i2; import 'package:sharedinbox/core/models/email.dart' as _i3;
import 'package:sharedinbox/core/models/mailbox.dart' as _i8; import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3; import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9; import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7; import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
// ignore_for_file: type=lint // ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_redundant_argument_values
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
// ignore_for_file: subtype_of_sealed_class // ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member // ignore_for_file: invalid_use_of_internal_member
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody { class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
_FakeEmailBody_0( _FakeMailbox_0(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -39,9 +39,8 @@ class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
); );
} }
class _FakeSyncEmailsResult_1 extends _i1.SmartFake class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
implements _i2.SyncEmailsResult { _FakeEmailBody_1(
_FakeSyncEmailsResult_1(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -50,9 +49,20 @@ class _FakeSyncEmailsResult_1 extends _i1.SmartFake
); );
} }
class _FakeReliabilityResult_2 extends _i1.SmartFake class _FakeSyncEmailsResult_2 extends _i1.SmartFake
implements _i2.ReliabilityResult { implements _i3.SyncEmailsResult {
_FakeReliabilityResult_2( _FakeSyncEmailsResult_2(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeReliabilityResult_3 extends _i1.SmartFake
implements _i3.ReliabilityResult {
_FakeReliabilityResult_3(
Object parent, Object parent,
Invocation parentInvocation, Invocation parentInvocation,
) : super( ) : super(
@@ -64,32 +74,32 @@ class _FakeReliabilityResult_2 extends _i1.SmartFake
/// A class which mocks [AccountRepository]. /// A class which mocks [AccountRepository].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository { class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
MockAccountRepository() { MockAccountRepository() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod( _i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeAccounts, #observeAccounts,
[], [],
), ),
returnValue: _i4.Stream<List<_i5.Account>>.empty(), returnValue: _i5.Stream<List<_i6.Account>>.empty(),
) as _i4.Stream<List<_i5.Account>>); ) as _i5.Stream<List<_i6.Account>>);
@override @override
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod( _i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getAccount, #getAccount,
[id], [id],
), ),
returnValue: _i4.Future<_i5.Account?>.value(), returnValue: _i5.Future<_i6.Account?>.value(),
) as _i4.Future<_i5.Account?>); ) as _i5.Future<_i6.Account?>);
@override @override
_i4.Future<void> addAccount( _i5.Future<void> addAccount(
_i5.Account? account, _i6.Account? account,
String? password, String? password,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -100,13 +110,13 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
password, password,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> updateAccount( _i5.Future<void> updateAccount(
_i5.Account? account, { _i6.Account? account, {
String? password, String? password,
}) => }) =>
(super.noSuchMethod( (super.noSuchMethod(
@@ -115,65 +125,65 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
[account], [account],
{#password: password}, {#password: password},
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod( _i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#removeAccount, #removeAccount,
[id], [id],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod( _i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getPassword, #getPassword,
[accountId], [accountId],
), ),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>( returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#getPassword, #getPassword,
[accountId], [accountId],
), ),
)), )),
) as _i4.Future<String>); ) as _i5.Future<String>);
} }
/// A class which mocks [MailboxRepository]. /// A class which mocks [MailboxRepository].
/// ///
/// See the documentation for Mockito's code generation for more information. /// See the documentation for Mockito's code generation for more information.
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository { class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
MockMailboxRepository() { MockMailboxRepository() {
_i1.throwOnMissingStub(this); _i1.throwOnMissingStub(this);
} }
@override @override
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) => _i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeMailboxes, #observeMailboxes,
[accountId], [accountId],
), ),
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(), returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
) as _i4.Stream<List<_i8.Mailbox>>); ) as _i5.Stream<List<_i2.Mailbox>>);
@override @override
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod( _i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#syncMailboxes, #syncMailboxes,
[accountId], [accountId],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Future<_i8.Mailbox?> findMailboxByRole( _i5.Future<_i2.Mailbox?> findMailboxByRole(
String? accountId, String? accountId,
String? role, String? role,
) => ) =>
@@ -185,18 +195,46 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
role, role,
], ],
), ),
returnValue: _i4.Future<_i8.Mailbox?>.value(), returnValue: _i5.Future<_i2.Mailbox?>.value(),
) as _i4.Future<_i8.Mailbox?>); ) as _i5.Future<_i2.Mailbox?>);
@override @override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod( _i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#clearForResync, #clearForResync,
[accountId], [accountId],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override
_i5.Future<_i2.Mailbox> createMailboxWithRole(
String? accountId,
String? name,
String? role,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
} }
/// A class which mocks [EmailRepository]. /// A class which mocks [EmailRepository].
@@ -208,13 +246,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
} }
@override @override
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod( _i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
Invocation.getter(#onChangesQueued), Invocation.getter(#onChangesQueued),
returnValue: _i4.Stream<String>.empty(), returnValue: _i5.Stream<String>.empty(),
) as _i4.Stream<String>); ) as _i5.Stream<String>);
@override @override
_i4.Stream<List<_i2.Email>> observeEmails( _i5.Stream<List<_i3.Email>> observeEmails(
String? accountId, String? accountId,
String? mailboxPath, { String? mailboxPath, {
int? limit = 50, int? limit = 50,
@@ -228,11 +266,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
{#limit: limit}, {#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.Email>>.empty(), returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>); ) as _i5.Stream<List<_i3.Email>>);
@override @override
_i4.Stream<List<_i2.EmailThread>> observeThreads( _i5.Stream<List<_i3.EmailThread>> observeThreads(
String? accountId, String? accountId,
String? mailboxPath, { String? mailboxPath, {
int? limit = 50, int? limit = 50,
@@ -246,11 +284,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
{#limit: limit}, {#limit: limit},
), ),
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(), returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>); ) as _i5.Stream<List<_i3.EmailThread>>);
@override @override
_i4.Stream<List<_i2.Email>> observeEmailsInThread( _i5.Stream<List<_i3.Email>> observeEmailsInThread(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
String? threadId, String? threadId,
@@ -264,36 +302,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
threadId, threadId,
], ],
), ),
returnValue: _i4.Stream<List<_i2.Email>>.empty(), returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>); ) as _i5.Stream<List<_i3.Email>>);
@override @override
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod( _i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#getEmail, #getEmail,
[emailId], [emailId],
), ),
returnValue: _i4.Future<_i2.Email?>.value(), returnValue: _i5.Future<_i3.Email?>.value(),
) as _i4.Future<_i2.Email?>); ) as _i5.Future<_i3.Email?>);
@override @override
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) => _i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#getEmailBody, #getEmailBody,
[emailId], [emailId],
), ),
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0( returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
this, this,
Invocation.method( Invocation.method(
#getEmailBody, #getEmailBody,
[emailId], [emailId],
), ),
)), )),
) as _i4.Future<_i2.EmailBody>); ) as _i5.Future<_i3.EmailBody>);
@override @override
_i4.Future<_i2.SyncEmailsResult> syncEmails( _i5.Future<_i3.SyncEmailsResult> syncEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -306,7 +344,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
returnValue: returnValue:
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1( _i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
this, this,
Invocation.method( Invocation.method(
#syncEmails, #syncEmails,
@@ -316,10 +354,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i4.Future<_i2.SyncEmailsResult>); ) as _i5.Future<_i3.SyncEmailsResult>);
@override @override
_i4.Future<void> setFlag( _i5.Future<void> setFlag(
String? emailId, { String? emailId, {
bool? seen, bool? seen,
bool? flagged, bool? flagged,
@@ -333,12 +371,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
#flagged: flagged, #flagged: flagged,
}, },
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> markAllAsRead( _i5.Future<void> markAllAsRead(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -350,12 +388,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
mailboxPath, mailboxPath,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> moveEmail( _i5.Future<void> moveEmail(
String? emailId, String? emailId,
String? destMailboxPath, String? destMailboxPath,
) => ) =>
@@ -367,23 +405,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
destMailboxPath, destMailboxPath,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod( _i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#deleteEmail, #deleteEmail,
[emailId], [emailId],
), ),
returnValue: _i4.Future<String?>.value(), returnValue: _i5.Future<String?>.value(),
) as _i4.Future<String?>); ) as _i5.Future<String?>);
@override @override
_i4.Future<void> sendEmail( _i5.Future<void> sendEmail(
String? accountId, String? accountId,
_i2.EmailDraft? draft, _i3.EmailDraft? draft,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -393,14 +431,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
draft, draft,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<String> downloadAttachment( _i5.Future<String> downloadAttachment(
String? emailId, String? emailId,
_i2.EmailAttachment? attachment, _i3.EmailAttachment? attachment,
) => ) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
@@ -410,7 +448,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
attachment, attachment,
], ],
), ),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>( returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#downloadAttachment, #downloadAttachment,
@@ -420,25 +458,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i4.Future<String>); ) as _i5.Future<String>);
@override @override
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod( _i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#fetchRawRfc822, #fetchRawRfc822,
[emailId], [emailId],
), ),
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>( returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
this, this,
Invocation.method( Invocation.method(
#fetchRawRfc822, #fetchRawRfc822,
[emailId], [emailId],
), ),
)), )),
) as _i4.Future<String>); ) as _i5.Future<String>);
@override @override
_i4.Future<List<_i2.Email>> searchEmails( _i5.Future<List<_i3.Email>> searchEmails(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
String? query, String? query,
@@ -452,11 +490,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query, query,
], ],
), ),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]), returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i4.Future<List<_i2.Email>>); ) as _i5.Future<List<_i3.Email>>);
@override @override
_i4.Future<List<_i2.Email>> searchEmailsGlobal( _i5.Future<List<_i3.Email>> searchEmailsGlobal(
String? accountId, String? accountId,
String? query, String? query,
) => ) =>
@@ -468,11 +506,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query, query,
], ],
), ),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]), returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i4.Future<List<_i2.Email>>); ) as _i5.Future<List<_i3.Email>>);
@override @override
_i4.Future<List<_i2.Email>> getEmailsByAddress( _i5.Future<List<_i3.Email>> getEmailsByAddress(
String? accountId, String? accountId,
String? address, String? address,
) => ) =>
@@ -484,11 +522,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
address, address,
], ],
), ),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]), returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i4.Future<List<_i2.Email>>); ) as _i5.Future<List<_i3.Email>>);
@override @override
_i4.Future<List<_i2.EmailAddress>> searchAddresses( _i5.Future<List<_i3.EmailAddress>> searchAddresses(
String? accountId, String? accountId,
String? query, { String? query, {
int? limit = 10, int? limit = 10,
@@ -503,11 +541,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
{#limit: limit}, {#limit: limit},
), ),
returnValue: returnValue:
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]), _i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
) as _i4.Future<List<_i2.EmailAddress>>); ) as _i5.Future<List<_i3.EmailAddress>>);
@override @override
_i4.Future<int> flushPendingChanges( _i5.Future<int> flushPendingChanges(
String? accountId, String? accountId,
String? password, String? password,
) => ) =>
@@ -519,42 +557,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password, password,
], ],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations( _i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
String? accountId) => String? accountId) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#observeFailedMutations, #observeFailedMutations,
[accountId], [accountId],
), ),
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(), returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
) as _i4.Stream<List<_i2.FailedMutation>>); ) as _i5.Stream<List<_i3.FailedMutation>>);
@override @override
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod( _i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#discardMutation, #discardMutation,
[id], [id],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod( _i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#retryMutation, #retryMutation,
[id], [id],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<bool> cancelPendingChange( _i5.Future<bool> cancelPendingChange(
String? emailId, String? emailId,
String? changeType, String? changeType,
) => ) =>
@@ -566,11 +604,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
changeType, changeType,
], ],
), ),
returnValue: _i4.Future<bool>.value(false), returnValue: _i5.Future<bool>.value(false),
) as _i4.Future<bool>); ) as _i5.Future<bool>);
@override @override
_i4.Future<void> snoozeEmail( _i5.Future<void> snoozeEmail(
String? emailId, String? emailId,
DateTime? until, DateTime? until,
) => ) =>
@@ -582,32 +620,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
until, until,
], ],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod( _i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#wakeUpEmails, #wakeUpEmails,
[accountId], [accountId],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) => _i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
(super.noSuchMethod( (super.noSuchMethod(
Invocation.method( Invocation.method(
#restoreEmails, #restoreEmails,
[emails], [emails],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
@override @override
_i4.Future<_i2.Email?> findEmailByMessageId( _i5.Future<_i3.Email?> findEmailByMessageId(
String? accountId, String? accountId,
String? messageId, String? messageId,
) => ) =>
@@ -619,20 +657,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
messageId, messageId,
], ],
), ),
returnValue: _i4.Future<_i2.Email?>.value(), returnValue: _i5.Future<_i3.Email?>.value(),
) as _i4.Future<_i2.Email?>); ) as _i5.Future<_i3.Email?>);
@override @override
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod( _i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#applySieveRules, #applySieveRules,
[accountId], [accountId],
), ),
returnValue: _i4.Future<int>.value(0), returnValue: _i5.Future<int>.value(0),
) as _i4.Future<int>); ) as _i5.Future<int>);
@override @override
_i4.Stream<void> watchJmapPush( _i5.Stream<void> watchJmapPush(
String? accountId, String? accountId,
String? password, String? password,
) => ) =>
@@ -644,11 +682,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password, password,
], ],
), ),
returnValue: _i4.Stream<void>.empty(), returnValue: _i5.Stream<void>.empty(),
) as _i4.Stream<void>); ) as _i5.Stream<void>);
@override @override
_i4.Future<_i2.ReliabilityResult> verifySyncReliability( _i5.Future<_i3.ReliabilityResult> verifySyncReliability(
String? accountId, String? accountId,
String? mailboxPath, String? mailboxPath,
) => ) =>
@@ -661,7 +699,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
returnValue: returnValue:
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2( _i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
this, this,
Invocation.method( Invocation.method(
#verifySyncReliability, #verifySyncReliability,
@@ -671,15 +709,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
], ],
), ),
)), )),
) as _i4.Future<_i2.ReliabilityResult>); ) as _i5.Future<_i3.ReliabilityResult>);
@override @override
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod( _i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method( Invocation.method(
#clearForResync, #clearForResync,
[accountId], [accountId],
), ),
returnValue: _i4.Future<void>.value(), returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i4.Future<void>); ) as _i5.Future<void>);
} }
+173
View File
@@ -14,6 +14,7 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage; import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart'; import 'db_test_helper.dart';
import 'fake_imap.dart' show SnoozeSpyImapClient;
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account( const _account = Account(
@@ -432,5 +433,177 @@ void main() {
expect(result, isNotNull); expect(result, isNotNull);
expect(result!.role, 'inbox'); expect(result!.role, 'inbox');
}); });
group('createMailboxWithRole', () {
test('IMAP: creates mailbox on server and persists with role', () async {
final spy = SnoozeSpyImapClient();
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => spy,
);
await accounts.addAccount(_account, 'pw');
final result = await mailboxes.createMailboxWithRole(
'acc-1',
'Archive',
'archive',
);
expect(spy.createdMailbox, 'Archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'Archive');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test('JMAP: creates mailbox on server and persists with role', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-mailbox': {'id': 'mbx-archive'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'mbx-archive');
final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test(
'JMAP: throws when server returns no created ID',
() async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': null,
'notCreated': {
'new-mailbox': {'type': 'serverFail'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()),
);
},
);
});
group('syncMailboxes IMAP preserves manually-set role', () {
test('existing role is kept when server returns no special-use flag',
() async {
final spy = SnoozeSpyImapClient();
// Make listMailboxes return a plain folder without \Archive.
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
// Override listMailboxes to return one plain folder.
final fakeClient = _PlainArchiveImapClient();
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => fakeClient,
);
await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder).
await db.into(db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
role: const Value('archive'),
),
);
await mailboxes.syncMailboxes('acc-1');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(
found,
isNotNull,
reason: 'Manually-set role should be preserved after sync',
);
expect(found!.path, 'Archive');
// Suppress unused warning on spy.
expect(spy, isNotNull);
});
});
}); });
} }
/// Fake IMAP client that lists one mailbox named 'Archive' without any
/// special-use flags, and logs out cleanly.
class _PlainArchiveImapClient extends SnoozeSpyImapClient {
@override
Future<List<imap.Mailbox>> listMailboxes({
String path = '""',
bool recursive = false,
List<String>? mailboxPatterns,
List<String>? selectionOptions,
List<imap.ReturnOption>? returnOptions,
}) async =>
[
imap.Mailbox(
encodedName: 'Archive',
encodedPath: 'Archive',
pathSeparator: '/',
flags: [], // No \Archive special-use flag
),
];
@override
Future<imap.Mailbox> statusMailbox(
imap.Mailbox mailbox,
List<imap.StatusFlags> flags,
) async =>
mailbox;
@override
Future<dynamic> logout() async {}
}
+45 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 32); expect(db.schemaVersion, 36);
await db.close(); await db.close();
}); });
@@ -194,6 +194,21 @@ void main() {
// v32: local_sieve_applied table. // v32: local_sieve_applied table.
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get(); await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
@@ -381,11 +396,26 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes'); await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test('fresh install creates all tables at schemaVersion 32', () async { test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -412,6 +442,7 @@ void main() {
'local_sieve_scripts', // v29 'local_sieve_scripts', // v29
'share_keys', // v31 'share_keys', // v31
'local_sieve_applied', // v32 'local_sieve_applied', // v32
'user_preferences', // v34
]), ]),
); );
@@ -426,6 +457,18 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes'); await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms')); expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
}); });
}); });
@@ -62,6 +62,21 @@ class _FakeMailboxes implements MailboxRepository {
null; null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _FakeEmails implements EmailRepository { class _FakeEmails implements EmailRepository {
+17
View File
@@ -54,6 +54,21 @@ class _FakeMailboxes implements MailboxRepository {
null; null;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
} }
class _CountingEmails implements EmailRepository { class _CountingEmails implements EmailRepository {
@@ -170,6 +185,8 @@ class _FakeSyncLog implements SyncLogRepository {
required String accountId, required String accountId,
required bool success, required bool success,
String? errorMessage, String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol, required String protocol,
required int emailsFetched, required int emailsFetched,
required int emailsSkipped, required int emailsSkipped,
@@ -126,4 +126,34 @@ void main() {
expect(rows.first.result, 'error'); expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused'); expect(rows.first.errorMessage, 'Connection refused');
}); });
test('stores and retrieves stackTrace and isPermanent on error entries',
() async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 3, 1, 9);
final end = DateTime(2024, 3, 1, 9, 0, 1);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
await repo.log(
accountId: 'acc1',
success: false,
errorMessage: 'MissingPluginException',
stackTrace: fakeTrace,
isPermanent: true,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
mailboxesSynced: 0,
pendingFlushed: 0,
bytesTransferred: 0,
startedAt: start,
finishedAt: end,
);
final entries = await repo.observeSyncLogs('acc1').first;
final entry = entries.firstWhere((e) => e.startedAt == start);
expect(entry.stackTrace, fakeTrace);
expect(entry.isPermanent, true);
expect(entry.errorMessage, 'MissingPluginException');
});
} }
+6
View File
@@ -80,6 +80,9 @@ void main() {
expect(find.textContaining('Dark Mode'), findsWidgets); expect(find.textContaining('Dark Mode'), findsWidgets);
expect(find.textContaining('IMAP Accounts'), findsWidgets); expect(find.textContaining('IMAP Accounts'), findsWidgets);
expect(find.textContaining('JMAP Accounts'), findsWidgets); expect(find.textContaining('JMAP Accounts'), findsWidgets);
expect(find.textContaining('Locale'), findsWidgets);
expect(find.textContaining('Text Scale'), findsWidgets);
expect(find.textContaining('DB Schema Version'), findsWidgets);
// Buttons are in the body, not in the AppBar actions // Buttons are in the body, not in the AppBar actions
expect(find.byIcon(Icons.copy), findsOneWidget); expect(find.byIcon(Icons.copy), findsOneWidget);
expect(find.byIcon(Icons.bug_report), findsOneWidget); expect(find.byIcon(Icons.bug_report), findsOneWidget);
@@ -167,6 +170,9 @@ void main() {
expect(clipboardText, contains('Dark Mode')); expect(clipboardText, contains('Dark Mode'));
expect(clipboardText, contains('IMAP Accounts')); expect(clipboardText, contains('IMAP Accounts'));
expect(clipboardText, contains('JMAP Accounts')); expect(clipboardText, contains('JMAP Accounts'));
expect(clipboardText, contains('Locale'));
expect(clipboardText, contains('Text Scale'));
expect(clipboardText, contains('DB Schema Version'));
expect( expect(
clipboardText, clipboardText,
contains('[sharedinbox.de](https://sharedinbox.de)'), contains('[sharedinbox.de](https://sharedinbox.de)'),
+70
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
import 'helpers.dart'; import 'helpers.dart';
@@ -206,5 +207,74 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
expect(find.text('sharedinbox.de'), findsOneWidget); expect(find.text('sharedinbox.de'), findsOneWidget);
}); });
testWidgets('shows Healthy when sync health is healthy', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Healthy'), findsOneWidget);
});
testWidgets(
'shows discrepancy details when sync health has discrepancies',
(tester) async {
const summary =
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: false,
discrepancySummary: summary,
),
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('missing locally: 3'), findsOneWidget);
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
},
);
testWidgets(
'sync health row is positioned below the account name row',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos));
},
);
}); });
} }
+250
View File
@@ -179,6 +179,218 @@ void main() {
expect(find.text('report.pdf'), findsOneWidget); expect(find.text('report.pdf'), findsOneWidget);
}); });
testWidgets('Reply All button is not present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply all',
),
findsNothing,
);
});
testWidgets('Reply on single-recipient email navigates directly to compose',
(tester) async {
// testEmail has from=[bob], to=[alice]. After removing alice (own),
// only bob remains → no dialog, navigate straight to compose.
final email = testEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: [
..._overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
],
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply',
),
);
await tester.pumpAndSettle();
// No dialog shown — straight navigation to compose.
expect(find.text('Reply All'), findsNothing);
});
testWidgets('Reply on multi-recipient email shows Reply All dialog',
(tester) async {
// Email with an extra Cc recipient so the dialog is triggered.
final email = Email(
id: 'acc-1:42',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 42,
subject: 'Hello world',
receivedAt: DateTime(2024, 6),
sentAt: DateTime(2024, 6),
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [EmailAddress(name: 'Carol', email: 'carol@example.com')],
isSeen: false,
isFlagged: false,
hasAttachment: false,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Reply',
),
);
await tester.pumpAndSettle();
// Dialog must appear with title 'Reply All'.
expect(find.text('Reply All'), findsOneWidget);
// Both non-own addresses should be listed in the dialog.
expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1));
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
});
testWidgets('Mark as spam is in popup menu, not a standalone button',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget);
});
testWidgets('Mark as spam shows dialog when no junk folder',
(tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// Open the popup menu first, then tap Mark as spam.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as spam'));
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
});
testWidgets('Archive button is present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Archive',
),
findsOneWidget,
);
});
testWidgets('Archive shows dialog when no archive folder', (tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Archive',
),
);
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
});
testWidgets('Mark as unread is in popup menu, not a standalone button',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// No standalone icon button for mark as unread.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as unread',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as unread'), findsOneWidget);
});
testWidgets('Show Raw Email dialog shows size of email', (tester) async { testWidgets('Show Raw Email dialog shows size of email', (tester) async {
// 'A' * 2048 → fmtSize(2048) == '2.0 KB' // 'A' * 2048 → fmtSize(2048) == '2.0 KB'
final rawContent = 'A' * 2048; final rawContent = 'A' * 2048;
@@ -271,6 +483,44 @@ void main() {
expect(find.text('Share'), findsOneWidget); expect(find.text('Share'), findsOneWidget);
}); });
testWidgets(
'long-press on unsubscribe chip shows URL tooltip',
(tester) async {
final email = testEmail(
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
);
await tester.pumpWidget(
buildApp(
initialLocation:
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
email: email,
),
),
);
await tester.pumpAndSettle();
expect(find.text('Unsubscribe'), findsOneWidget);
expect(
find.byWidgetPredicate(
(w) =>
w is Tooltip && w.message == 'https://example.com/unsubscribe',
),
findsOneWidget,
);
await tester.longPress(find.text('Unsubscribe'));
await tester.pumpAndSettle();
expect(
find.text('https://example.com/unsubscribe'),
findsOneWidget,
);
},
);
testWidgets('Show Mail Structure opens dialog with MIME parts', ( testWidgets('Show Mail Structure opens dialog with MIME parts', (
tester, tester,
) async { ) async {
+147 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart'; import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart'; import 'package:sharedinbox/ui/screens/email_list_screen.dart';
@@ -315,7 +316,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('INBOX'), findsOneWidget); expect(find.text('INBOX'), findsOneWidget);
expect(find.byType(BottomAppBar), findsNothing); expect(find.byIcon(Icons.close), findsNothing);
}); });
testWidgets('tapping clear icon in search bar clears results', ( testWidgets('tapping clear icon in search bar clears results', (
@@ -631,5 +632,150 @@ void main() {
expect(find.text('This is the preview text'), findsOneWidget); expect(find.text('This is the preview text'), findsOneWidget);
}); });
group('archive with missing folder', () {
testWidgets('shows dialog when archive folder is not found', (
tester,
) async {
final email = testEmail(subject: 'To archive');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// No archive folder in the repo.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
// Enter selection mode and tap archive.
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
expect(find.text('Choose existing folder'), findsOneWidget);
expect(find.text('Create "Archive"'), findsOneWidget);
});
testWidgets('tapping Create creates the folder and moves emails', (
tester,
) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Create Archive".
await tester.tap(find.text('Create "Archive"'));
await tester.pumpAndSettle();
expect(movedTo, contains('Archive'));
});
testWidgets(
'tapping Choose existing opens folder picker and moves emails',
(tester) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
const archiveFolder = Mailbox(
id: 'acc-1:OldArchive',
accountId: 'acc-1',
path: 'OldArchive',
name: 'OldArchive',
unreadCount: 0,
totalCount: 0,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// Repo has a folder but it has no 'archive' role.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([archiveFolder]),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Choose existing folder".
await tester.tap(find.text('Choose existing folder'));
await tester.pumpAndSettle();
// Bottom sheet with folder list appears.
expect(find.text('OldArchive'), findsOneWidget);
await tester.tap(find.text('OldArchive'));
await tester.pumpAndSettle();
expect(movedTo, contains('OldArchive'));
},
);
});
}); });
} }
/// Email repository spy that records [moveEmail] calls.
class _SpyEmailRepository extends FakeEmailRepository {
_SpyEmailRepository({
super.emails,
required void Function(String emailId, String path) onMove,
}) : _onMove = onMove;
final void Function(String emailId, String path) _onMove;
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {
_onMove(emailId, destMailboxPath);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+79 -6
View File
@@ -14,6 +14,7 @@ import 'package:sharedinbox/core/models/discovery_result.dart';
import 'package:sharedinbox/core/models/draft.dart'; import 'package:sharedinbox/core/models/draft.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/account_repository.dart'; import 'package:sharedinbox/core/repositories/account_repository.dart';
import 'package:sharedinbox/core/repositories/draft_repository.dart'; import 'package:sharedinbox/core/repositories/draft_repository.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart';
@@ -21,10 +22,12 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
import 'package:sharedinbox/core/repositories/search_history_repository.dart'; import 'package:sharedinbox/core/repositories/search_history_repository.dart';
import 'package:sharedinbox/core/repositories/share_key_repository.dart'; import 'package:sharedinbox/core/repositories/share_key_repository.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/core/services/account_discovery_service.dart'; import 'package:sharedinbox/core/services/account_discovery_service.dart';
import 'package:sharedinbox/core/services/connection_test_service.dart'; import 'package:sharedinbox/core/services/connection_test_service.dart';
import 'package:sharedinbox/core/services/managesieve_probe_service.dart'; import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
import 'package:sharedinbox/core/services/share_encryption_service.dart'; import 'package:sharedinbox/core/services/share_encryption_service.dart';
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/account_list_screen.dart'; import 'package:sharedinbox/ui/screens/account_list_screen.dart';
import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
@@ -38,6 +41,7 @@ import 'package:sharedinbox/ui/screens/email_list_screen.dart';
import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart'; import 'package:sharedinbox/ui/screens/mailbox_list_screen.dart';
import 'package:sharedinbox/ui/screens/search_screen.dart'; import 'package:sharedinbox/ui/screens/search_screen.dart';
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fake repositories // Fake repositories
@@ -164,8 +168,28 @@ class FakeMailboxRepository implements MailboxRepository {
@override @override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async => Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
_mailboxes.where((m) => m.role == role).firstOrNull; _mailboxes.where((m) => m.role == role).firstOrNull;
@override @override
Future<void> clearForResync(String accountId) async {} Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
} }
class FakeEmailRepository implements EmailRepository { class FakeEmailRepository implements EmailRepository {
@@ -390,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
Widget buildApp({ Widget buildApp({
required String initialLocation, required String initialLocation,
required List<Override> overrides, required List<Override> overrides,
UserPreferencesRepository? userPreferences,
}) { }) {
final testRouter = GoRouter( final testRouter = GoRouter(
initialLocation: initialLocation, initialLocation: initialLocation,
@@ -410,6 +435,10 @@ Widget buildApp({
path: 'send', path: 'send',
builder: (ctx, state) => const AccountSendScreen(), builder: (ctx, state) => const AccountSendScreen(),
), ),
GoRoute(
path: 'preferences',
builder: (ctx, state) => const UserPreferencesScreen(),
),
GoRoute( GoRoute(
path: ':accountId/edit', path: ':accountId/edit',
builder: (ctx, state) => EditAccountScreen( builder: (ctx, state) => EditAccountScreen(
@@ -485,16 +514,18 @@ Widget buildApp({
return ProviderScope( return ProviderScope(
// Defaults come first so tests can override them via [overrides]. // Defaults come first so tests can override them via [overrides].
// //
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift // syncLogRepositoryProvider is backed by a Drift StreamQuery. When the
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed, // provider is disposed, Drift schedules a Timer.run() for cache
// Drift schedules a Timer.run() for cache debouncing. Flutter's test // debouncing. Flutter's test framework then fails the test with "A Timer
// framework then fails the test with "A Timer is still pending". Replacing // is still pending". Replacing it with a synchronous stream avoids this.
// these with simple synchronous streams avoids the pending-timer assertion. // syncHealthProvider has the same issue and is overridden in baseOverrides.
overrides: [ overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue( syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(), const NoOpSyncLogRepository(),
), ),
userPreferencesRepositoryProvider.overrideWithValue(
userPreferences ?? FakeUserPreferencesRepository(),
),
...overrides, ...overrides,
manageSieveProbeServiceProvider.overrideWith( manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(), (ref) => _NoOpManageSieveProbeService(),
@@ -521,6 +552,7 @@ List<Override> baseOverrides({
Exception? connectionError, Exception? connectionError,
ShareKeyRepository? shareKeyRepository, ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true, bool hasStoredPassword = true,
SyncHealthRow? syncHealth,
}) => }) =>
[ [
accountRepositoryProvider.overrideWithValue( accountRepositoryProvider.overrideWithValue(
@@ -539,6 +571,9 @@ List<Override> baseOverrides({
shareKeyRepositoryProvider.overrideWithValue( shareKeyRepositoryProvider.overrideWithValue(
shareKeyRepository ?? FakeShareKeyRepository(), shareKeyRepository ?? FakeShareKeyRepository(),
), ),
// syncHealthProvider is backed by a Drift StreamQuery; override with a
// plain stream to avoid "A Timer is still pending" in tests.
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
]; ];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -568,6 +603,7 @@ Email testEmail({
bool isSeen = false, bool isSeen = false,
bool isFlagged = false, bool isFlagged = false,
bool hasAttachment = false, bool hasAttachment = false,
String? listUnsubscribeHeader,
}) => }) =>
Email( Email(
id: id, id: id,
@@ -583,8 +619,45 @@ Email testEmail({
isSeen: isSeen, isSeen: isSeen,
isFlagged: isFlagged, isFlagged: isFlagged,
hasAttachment: hasAttachment, hasAttachment: hasAttachment,
listUnsubscribeHeader: listUnsubscribeHeader,
); );
class FakeUserPreferencesRepository implements UserPreferencesRepository {
FakeUserPreferencesRepository({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
MenuPosition menuPosition;
MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction;
@override
Stream<UserPreferences> observePreferences() => Stream.value(
UserPreferences(
menuPosition: menuPosition,
mailViewButtonPosition: mailViewButtonPosition,
afterMailViewAction: afterMailViewAction,
),
);
@override
Future<void> updateMenuPosition(MenuPosition position) async {
menuPosition = position;
}
@override
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
mailViewButtonPosition = position;
}
@override
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action;
}
}
class FakeSearchHistoryRepository implements SearchHistoryRepository { class FakeSearchHistoryRepository implements SearchHistoryRepository {
final List<String> _history = []; final List<String> _history = [];
@@ -41,6 +41,20 @@ void main() {
expect(html, contains('https: http: data: blob:')); expect(html, contains('https: http: data: blob:'));
_expectLightMode(html); _expectLightMode(html);
}); });
test('prevents horizontal overflow so wide HTML emails are not cut off',
() {
final html =
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
// Body clips overflow so fixed-width email tables don't escape the viewport.
expect(html, contains('overflow-x: hidden'));
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
expect(html, contains('table { width: 100%'));
// All elements are capped at viewport width via max-width.
expect(html, contains('max-width: 100%'));
// Pre-formatted text wraps instead of stretching the page.
expect(html, contains('white-space: pre-wrap'));
});
}); });
// On Linux (the test host) the widget falls back to plain text extracted via // On Linux (the test host) the widget falls back to plain text extracted via
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'helpers.dart'; import 'helpers.dart';
@@ -142,6 +143,60 @@ void main() {
expect(find.byIcon(Icons.expand_more), findsOneWidget); expect(find.byIcon(Icons.expand_more), findsOneWidget);
}); });
testWidgets('shows bottom app bar with back button by default', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
});
testWidgets('hides bottom app bar when button position is top', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
userPreferences: FakeUserPreferencesRepository(
mailViewButtonPosition: MenuPosition.top,
),
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsNothing);
});
testWidgets('flagged email shows star icon', (tester) async { testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true); final email = _threadEmail(isFlagged: true);
await tester.pumpWidget( await tester.pumpWidget(
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'helpers.dart';
void main() {
group('UserPreferencesScreen', () {
testWidgets('shows both menu position options', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(find.text('Menu bar position'), findsOneWidget);
expect(find.text('Bottom (default)'), findsNWidgets(2));
expect(find.text('Top'), findsNWidgets(2));
});
testWidgets('shows single mail view button position section', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(
find.text('Single mail view button position'),
findsOneWidget,
);
});
testWidgets('menu position bottom option is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final menuGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
expect(menuGroup.groupValue, MenuPosition.bottom);
});
testWidgets('mail view button position bottom is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final mailViewGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
expect(mailViewGroup.groupValue, MenuPosition.bottom);
});
testWidgets('tapping Top in menu position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').first);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.menuPosition, MenuPosition.top);
});
testWidgets(
'tapping Top in mail view button position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').last);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top);
});
testWidgets('shows after mail action section', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
// Scroll down to reveal the new section below the fold.
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(find.text('After mail action'), findsOneWidget);
expect(find.text('Next message (default)'), findsOneWidget);
expect(find.text('Return to mailbox'), findsOneWidget);
});
testWidgets('after mail action next message is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
final group =
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
expect(group.groupValue, AfterMailViewAction.nextMessage);
});
testWidgets('tapping Return to mailbox updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
await tester.tap(find.text('Return to mailbox'));
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
});
});
}
+2 -1
View File
@@ -25,7 +25,8 @@ The app processes the following data **exclusively on your device**:
device's secure storage and never transmitted to us. device's secure storage and never transmitted to us.
- **Email messages and attachments** — fetched directly from your email provider's IMAP server and - **Email messages and attachments** — fetched directly from your email provider's IMAP server and
displayed in the app. We never receive, store, or process your emails. displayed in the app. We never receive, store, or process your emails.
- **App settings and configuration** — stored locally on your device. - **App settings and configuration** — stored locally on your device. The app will never upload
this data to sharedinbox.de or any third-party service.
### Network connections ### Network connections