Compare commits
@@ -109,3 +109,51 @@ jobs:
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
merge-renovate:
|
||||
name: Auto-merge Renovate PR
|
||||
needs: [check]
|
||||
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Merge if automerge label is set
|
||||
env:
|
||||
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
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
r for r in data.get("workflow_runs", [])
|
||||
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
|
||||
]
|
||||
print(runs[0]["head_sha"] if runs else "")
|
||||
print(runs[0].get("commit_sha") or "")
|
||||
except Exception as e:
|
||||
print(f"API check failed: {e}", file=sys.stderr)
|
||||
print("")
|
||||
@@ -83,44 +83,6 @@ jobs:
|
||||
&& echo "linux=true" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "linux=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test-android-firebase:
|
||||
name: Android Instrumented Tests (Firebase Test Lab)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
needs: [check-changes]
|
||||
if: needs.check-changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check runner tools
|
||||
run: |
|
||||
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
|
||||
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
|
||||
|
||||
- name: Setup Dagger Remote Engine (via stunnel)
|
||||
env:
|
||||
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
|
||||
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
|
||||
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
|
||||
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
|
||||
run: scripts/setup_dagger_remote.sh
|
||||
|
||||
- name: Run Android Tests on Firebase Test Lab
|
||||
if: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY != '' }}
|
||||
env:
|
||||
FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_TEST_LAB_SERVICE_ACCOUNT_KEY }}
|
||||
FIREBASE_PROJECT_ID: ${{ vars.FIREBASE_PROJECT_ID }}
|
||||
DAGGER_NO_NAG: "1"
|
||||
run: task test-android-firebase
|
||||
|
||||
- name: Cleanup TLS credentials
|
||||
if: always()
|
||||
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
|
||||
|
||||
deploy-playstore:
|
||||
name: Build & Deploy to Play Store
|
||||
runs-on: ubuntu-latest
|
||||
@@ -287,10 +249,9 @@ jobs:
|
||||
label-deploy-health:
|
||||
name: Update Deploy Health Label
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-android-firebase, deploy-playstore, deploy-apk, build-linux]
|
||||
needs: [deploy-playstore, deploy-apk, build-linux]
|
||||
if: |
|
||||
always() && vars.DEPLOY_HEALTH_ISSUE != '' && (
|
||||
needs.test-android-firebase.result == 'success' || needs.test-android-firebase.result == 'failure' ||
|
||||
needs.deploy-playstore.result == 'success' || needs.deploy-playstore.result == 'failure' ||
|
||||
needs.deploy-apk.result == 'success' || needs.deploy-apk.result == 'failure' ||
|
||||
needs.build-linux.result == 'success' || needs.build-linux.result == 'failure'
|
||||
@@ -303,7 +264,7 @@ jobs:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_URL: ${{ github.server_url }}
|
||||
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: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, json, urllib.request, urllib.error
|
||||
|
||||
@@ -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
|
||||
@@ -33,12 +33,12 @@ repos:
|
||||
- id: ci-no-direct-dagger
|
||||
name: check for direct dagger calls in workflows (use Task instead)
|
||||
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
|
||||
always_run: true
|
||||
- id: dagger-progress-plain
|
||||
name: ensure all dagger calls use --progress=plain
|
||||
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
|
||||
always_run: true
|
||||
|
||||
@@ -844,13 +844,24 @@ func (m *Ci) PublishAndroid(
|
||||
|
||||
// Renovate runs Renovate bot against the repository on Forgejo/Codeberg.
|
||||
func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string, error) {
|
||||
// Codeberg's GET /pulls?state=all&limit=100 times out with a 504, but limit=10
|
||||
// completes in ~9 s. Patch the compiled pr-cache.js to use 10 instead of the
|
||||
// hardcoded 20/100 values before launching renovate.
|
||||
const patchCmd = `for f in \
|
||||
/usr/local/renovate/dist/modules/platform/forgejo/pr-cache.js \
|
||||
/usr/local/renovate/dist/modules/platform/gitea/pr-cache.js; do \
|
||||
sed -i 's/limit: this\.items\.length ? 20 : 100/limit: this.items.length ? 10 : 10/' "$f" && echo "patched $f"; \
|
||||
done`
|
||||
return dag.Container().
|
||||
From("renovate/renovate:39").
|
||||
From("renovate/renovate:43").
|
||||
WithSecretVariable("RENOVATE_TOKEN", renovateToken).
|
||||
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
|
||||
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
|
||||
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
|
||||
WithEnvVariable("LOG_LEVEL", "info").
|
||||
WithUser("root").
|
||||
WithExec([]string{"/bin/sh", "-c", patchCmd}).
|
||||
WithUser("ubuntu").
|
||||
WithExec([]string{"renovate"}).
|
||||
Stdout(ctx)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,16 @@ This file contains tasks which got implemented.
|
||||
|
||||
Tasks get moved from next.md to done.md
|
||||
|
||||
## Tasks (2026-05-26)
|
||||
|
||||
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
|
||||
dependencies up to date. All required components are in main:
|
||||
- `renovate.json` — Renovate configuration covering pub, Dockerfile, and Forgejo Actions
|
||||
- `ci/main.go` — `Renovate()` Dagger function using Forgejo platform and Codeberg endpoint
|
||||
- `.forgejo/workflows/renovate.yml` — daily cron (06:00 UTC) workflow
|
||||
- `Taskfile.yml` — `renovate` task
|
||||
- Issue #257 closed.
|
||||
|
||||
## Tasks (2026-05-11)
|
||||
|
||||
- **Stabilize Email List UI during Selection (Issue #14)**: Prevented layout shifts when entering
|
||||
|
||||
@@ -317,7 +317,7 @@ void main() {
|
||||
|
||||
// ── Check Sent folder ──────────────────────────────────────────────────
|
||||
// Use the drawer to switch folders (no back button on Linux desktop).
|
||||
await tester.tap(find.byTooltip('Open navigation menu'));
|
||||
await tester.tap(find.byTooltip('Open folders'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sent'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -331,7 +331,7 @@ void main() {
|
||||
expect(find.text(subject), findsOneWidget);
|
||||
|
||||
// ── Check Inbox ────────────────────────────────────────────────────────
|
||||
await tester.tap(find.byTooltip('Open navigation menu'));
|
||||
await tester.tap(find.byTooltip('Open folders'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('INBOX'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -1 +1 @@
|
||||
const int dbSchemaVersion = 33;
|
||||
const int dbSchemaVersion = 34;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
enum MenuPosition { bottom, top }
|
||||
|
||||
class UserPreferences {
|
||||
const UserPreferences({this.menuPosition = MenuPosition.bottom});
|
||||
final MenuPosition menuPosition;
|
||||
}
|
||||
@@ -11,4 +11,13 @@ abstract class MailboxRepository {
|
||||
|
||||
/// Deletes all locally-cached mailbox rows for [accountId].
|
||||
Future<void> clearForResync(String accountId);
|
||||
|
||||
/// Creates a new mailbox named [name] for [accountId] and tags it with
|
||||
/// [role] in the local database. For JMAP accounts the role is also sent
|
||||
/// to the server. Returns the newly created [Mailbox].
|
||||
Future<Mailbox> createMailboxWithRole(
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
|
||||
abstract class UserPreferencesRepository {
|
||||
Stream<UserPreferences> observePreferences();
|
||||
Future<void> updateMenuPosition(MenuPosition position);
|
||||
}
|
||||
@@ -307,6 +307,17 @@ class LocalSieveApplied extends Table {
|
||||
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'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
// ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@DriftDatabase(
|
||||
@@ -327,6 +338,7 @@ class LocalSieveApplied extends Table {
|
||||
LocalSieveScripts,
|
||||
LocalSieveApplied,
|
||||
ShareKeys,
|
||||
UserPreferences,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
@@ -578,6 +590,9 @@ class AppDatabase extends _$AppDatabase {
|
||||
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
|
||||
await m.addColumn(syncLogs, syncLogs.isPermanent);
|
||||
}
|
||||
if (from < 34) {
|
||||
await m.createTable(userPreferences);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,14 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
);
|
||||
try {
|
||||
final mailboxes = await client.listMailboxes(recursive: true);
|
||||
|
||||
// Pre-load existing DB roles so we can preserve manually-set roles for
|
||||
// folders the server doesn't tag with a special-use attribute.
|
||||
final existingRows = await (_db.select(_db.mailboxes)
|
||||
..where((t) => t.accountId.equals(account.id)))
|
||||
.get();
|
||||
final existingRoles = {for (final r in existingRows) r.id: r.role};
|
||||
|
||||
for (final mb in mailboxes) {
|
||||
final path = mb.path;
|
||||
final id = '${account.id}:$path';
|
||||
@@ -96,6 +104,12 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
log('STATUS skipped for $path: $e');
|
||||
}
|
||||
|
||||
// Use the server-assigned role when available; fall back to the
|
||||
// existing DB role so that manually-created folders (e.g. a user
|
||||
// who just created their Archive folder) keep their role across syncs
|
||||
// when the IMAP server does not expose a special-use attribute.
|
||||
final role = _imapRole(mb) ?? existingRoles[id];
|
||||
|
||||
await _db.into(_db.mailboxes).insertOnConflictUpdate(
|
||||
MailboxesCompanion.insert(
|
||||
id: id,
|
||||
@@ -104,7 +118,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
name: mb.name,
|
||||
unreadCount: Value(unread),
|
||||
totalCount: Value(total),
|
||||
role: Value(_imapRole(mb)),
|
||||
role: Value(role),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -310,4 +324,104 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
..where((t) => t.accountId.equals(accountId)))
|
||||
.go();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<model.Mailbox> createMailboxWithRole(
|
||||
String accountId,
|
||||
String name,
|
||||
String role,
|
||||
) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
final password = await _accounts.getPassword(accountId);
|
||||
switch (account.type) {
|
||||
case account_model.AccountType.imap:
|
||||
return _createMailboxWithRoleImap(account, password, name, role);
|
||||
case account_model.AccountType.jmap:
|
||||
return _createMailboxWithRoleJmap(account, password, name, role);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/draft_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
@@ -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/sync_log_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/undo_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
|
||||
import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||
@@ -21,7 +23,8 @@ import 'package:sharedinbox/core/services/undo_service.dart';
|
||||
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
||||
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
|
||||
import 'package:sharedinbox/core/sync/reliability_runner.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
|
||||
import 'package:sharedinbox/data/db/database.dart'
|
||||
hide Email, EmailBody, UserPreferences;
|
||||
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
|
||||
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
|
||||
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
|
||||
@@ -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/sync_log_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/undo_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/repositories/user_preferences_repository_impl.dart';
|
||||
import 'package:sharedinbox/data/storage/flutter_secure_storage_impl.dart';
|
||||
|
||||
/// Swappable IMAP connection factory — override in tests to use plaintext.
|
||||
@@ -227,3 +231,13 @@ final accountConnectionStatusProvider =
|
||||
.read(connectionTestServiceProvider)
|
||||
.testConnection(account, password);
|
||||
});
|
||||
|
||||
final userPreferencesRepositoryProvider =
|
||||
Provider<UserPreferencesRepository>((ref) {
|
||||
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
|
||||
});
|
||||
|
||||
final userPreferencesProvider =
|
||||
StreamProvider.autoDispose<UserPreferences>((ref) {
|
||||
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
|
||||
});
|
||||
|
||||
@@ -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/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/undo_log_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
import 'package:sharedinbox/ui/widgets/undo_shell.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
@@ -56,6 +57,10 @@ final router = GoRouter(
|
||||
path: 'about',
|
||||
builder: (ctx, state) => const AboutScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'preferences',
|
||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -66,6 +67,14 @@ class AccountListScreen extends ConsumerWidget {
|
||||
unawaited(context.push('/accounts/about'));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: const Text('Preferences'),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close drawer
|
||||
unawaited(context.push('/accounts/preferences'));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -124,7 +133,6 @@ class _AccountTile extends ConsumerWidget {
|
||||
if (h == null) return const Text('Sync health: Not verified yet');
|
||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Sync health: '),
|
||||
Icon(
|
||||
@@ -133,7 +141,13 @@ class _AccountTile extends ConsumerWidget {
|
||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
||||
Flexible(
|
||||
child: Text(
|
||||
h.isHealthy
|
||||
? 'Healthy'
|
||||
: _formatDiscrepancies(h.discrepancySummary),
|
||||
),
|
||||
),
|
||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||
],
|
||||
);
|
||||
@@ -293,6 +307,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 {
|
||||
const _OnboardingView();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -85,42 +86,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
},
|
||||
),
|
||||
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(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: 'Mark as spam',
|
||||
icon: const Icon(Icons.archive),
|
||||
tooltip: 'Archive',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
unawaited(_archive(context, header));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
@@ -148,8 +119,43 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
if (context.mounted) context.pop();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.report_outlined),
|
||||
tooltip: 'Mark as spam',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_markAsSpam(context, header));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.drive_file_move_outline),
|
||||
tooltip: 'Move to folder',
|
||||
onPressed: header == null ? null : () => _moveTo(context, header),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.access_time),
|
||||
tooltip: 'Snooze',
|
||||
onPressed: header == null ? null : () => _snooze(context, header),
|
||||
),
|
||||
IconButton(
|
||||
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>(
|
||||
itemBuilder: (ctx) => [
|
||||
const PopupMenuItem(
|
||||
value: 'mark_unread',
|
||||
child: Text('Mark as unread'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'headers',
|
||||
child: Text('Show Mail Headers'),
|
||||
@@ -163,8 +169,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
child: Text('Show Raw Email'),
|
||||
),
|
||||
],
|
||||
onSelected: (value) {
|
||||
if (value == 'headers' && body != null) {
|
||||
onSelected: (value) async {
|
||||
if (value == 'mark_unread') {
|
||||
await repo.setFlag(widget.emailId, seen: false);
|
||||
if (context.mounted) context.pop();
|
||||
} else if (value == 'headers' && body != null) {
|
||||
_showHeaders(context, body);
|
||||
} else if (value == 'structure' && body != null) {
|
||||
_showStructure(context, body);
|
||||
@@ -393,21 +402,22 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk');
|
||||
Future<void> _archive(BuildContext context, Email header) async {
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
header.accountId,
|
||||
header.mailboxPath,
|
||||
'archive',
|
||||
dialogTitle: 'No archive folder found',
|
||||
createFolderName: 'Archive',
|
||||
);
|
||||
|
||||
if (junk == null) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No Junk folder found')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mailbox == null || !context.mounted) return;
|
||||
|
||||
await ref
|
||||
.read(emailRepositoryProvider)
|
||||
.moveEmail(widget.emailId, junk.path);
|
||||
.moveEmail(widget.emailId, mailbox.path);
|
||||
|
||||
unawaited(
|
||||
ref.read(undoServiceProvider.notifier).pushAction(
|
||||
@@ -417,7 +427,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
type: UndoType.move,
|
||||
emailIds: [widget.emailId],
|
||||
sourceMailboxPath: header.mailboxPath,
|
||||
destinationMailboxPath: junk.path,
|
||||
destinationMailboxPath: mailbox.path,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (context.mounted) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -895,10 +938,13 @@ class _UnsubscribeChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final uri = _parseUnsubscribeUri(header);
|
||||
if (uri == null) return const SizedBox.shrink();
|
||||
return ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
return Tooltip(
|
||||
message: uri.toString(),
|
||||
child: ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/account.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
@@ -147,16 +149,21 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final repo = ref.watch(emailRepositoryProvider);
|
||||
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(repo, accountAsync),
|
||||
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
||||
drawer: _selecting
|
||||
? null
|
||||
: FolderDrawer(
|
||||
accountId: widget.accountId,
|
||||
currentMailboxPath: widget.mailboxPath,
|
||||
),
|
||||
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||
bottomNavigationBar: _selecting
|
||||
? _selectionBottomBar()
|
||||
: (menuAtBottom ? _folderNavBottomBar() : null),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildSyncErrorBanner(),
|
||||
@@ -172,12 +179,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
PreferredSizeWidget _buildAppBar(
|
||||
EmailRepository emailRepo,
|
||||
AsyncValue<Account?> accountAsync,
|
||||
) {
|
||||
AsyncValue<Account?> accountAsync, {
|
||||
required bool menuAtBottom,
|
||||
}) {
|
||||
final selectionCount =
|
||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: !menuAtBottom,
|
||||
leading: _selecting
|
||||
? IconButton(
|
||||
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() {
|
||||
return BottomAppBar(
|
||||
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;
|
||||
_clearSelection();
|
||||
final mailbox = await ref
|
||||
.read(mailboxRepositoryProvider)
|
||||
.findMailboxByRole(widget.accountId, role);
|
||||
if (!mounted) return;
|
||||
if (mailbox == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Text(notFoundMessage),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final mailbox = await resolveMailboxByRole(
|
||||
context,
|
||||
ref.read(mailboxRepositoryProvider),
|
||||
widget.accountId,
|
||||
widget.mailboxPath,
|
||||
role,
|
||||
dialogTitle: dialogTitle,
|
||||
createFolderName: createFolderName,
|
||||
);
|
||||
|
||||
if (!mounted || mailbox == null) return;
|
||||
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
Future<void> _batchArchive() =>
|
||||
_batchMoveToRole('archive', 'No archive folder found');
|
||||
Future<void> _batchArchive() => _batchMoveToRole(
|
||||
'archive',
|
||||
dialogTitle: 'No archive folder found',
|
||||
createFolderName: 'Archive',
|
||||
);
|
||||
|
||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||
if (!mounted || !_searching) return;
|
||||
@@ -543,8 +573,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchMarkSpam() =>
|
||||
_batchMoveToRole('junk', 'No spam folder found');
|
||||
Future<void> _batchMarkSpam() => _batchMoveToRole(
|
||||
'junk',
|
||||
dialogTitle: 'No spam folder found',
|
||||
createFolderName: 'Junk',
|
||||
);
|
||||
|
||||
Future<void> _batchMove() async {
|
||||
final ids = _selectedEmailIds;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
@@ -17,8 +18,12 @@ class MailboxListScreen extends ConsumerWidget {
|
||||
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
|
||||
final emailRepo = ref.watch(emailRepositoryProvider);
|
||||
final accountAsync = ref.watch(accountByIdProvider(accountId));
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: !menuAtBottom,
|
||||
title: const Text('Folders'),
|
||||
actions: [
|
||||
IconButton(
|
||||
@@ -42,6 +47,19 @@ class MailboxListScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
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(
|
||||
children: [
|
||||
// ── Failed-mutation banner ───────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; 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; }
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -6,5 +6,11 @@
|
||||
"labels": ["dependencies"],
|
||||
"github-actions": {
|
||||
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
|
||||
}
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
|
||||
"addLabels": ["automerge"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
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"] = (
|
||||
f"{Path.home()}/.nix-profile/bin"
|
||||
f":{Path.home()}/go/bin"
|
||||
@@ -97,22 +97,27 @@ def _fgj(*args: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _tea_get(path: str) -> dict | list | None:
|
||||
"""Run a tea api GET and return parsed JSON. Only use for reads — tea PATCH/PUT
|
||||
silently fails (exits 0) when unauthenticated, so writes must go via fgj."""
|
||||
cmd = ["tea", "api", path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
def _fgj_run_list(limit: int = 20) -> list[dict]:
|
||||
"""Return workflow runs via fgj actions run list."""
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "actions", "run", "list",
|
||||
"--repo", REPO, "--json", "-L", str(limit)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
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()
|
||||
if not out:
|
||||
return None
|
||||
data = json.loads(out)
|
||||
if isinstance(data, dict) and "message" in data and "url" in data:
|
||||
raise RuntimeError(f"tea api {path} returned error: {data['message']}")
|
||||
return data
|
||||
return []
|
||||
try:
|
||||
data = json.loads(out)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(
|
||||
f"fgj actions run list returned non-JSON:\n{out[:500]}"
|
||||
) from exc
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
|
||||
@@ -181,9 +186,7 @@ def _latest_main_ci_run() -> dict | None:
|
||||
event=push and prettyref=main, so filtering by event alone is not enough.
|
||||
We also require workflow_id == "ci.yml".
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
for run in _fgj_run_list(limit=20):
|
||||
if (run.get("event") == "push"
|
||||
and run.get("prettyref") == "main"
|
||||
and run.get("workflow_id") == "ci.yml"):
|
||||
@@ -194,20 +197,16 @@ def _latest_main_ci_run() -> dict | None:
|
||||
def _latest_ci_run_for_branch(branch: str) -> dict | 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 push events the branch is in ``prettyref``; for pull_request
|
||||
events it lives inside ``event_payload["pull_request"]["head"]["ref"]``.
|
||||
For push events fgj reports the branch in ``prettyref``; for pull_request
|
||||
events ``prettyref`` is ``#N``, so we resolve the PR number first.
|
||||
"""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?limit=20")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
runs = _fgj_run_list(limit=20)
|
||||
pr_data = _find_pr_for_branch(branch)
|
||||
pr_ref = f"#{pr_data['number']}" if pr_data else None
|
||||
for run in runs:
|
||||
if run.get("event") == "pull_request":
|
||||
try:
|
||||
payload = json.loads(run.get("event_payload", "{}"))
|
||||
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
if pr_ref and run.get("prettyref") == pr_ref:
|
||||
return run
|
||||
elif run.get("event") == "push":
|
||||
if run.get("prettyref") == branch:
|
||||
return run
|
||||
@@ -254,24 +253,27 @@ def _open_issue_prs() -> list[dict]:
|
||||
|
||||
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."""
|
||||
data = _tea_get(f"repos/{REPO}/actions/runs?event=pull_request&limit=50")
|
||||
runs = (data or {}).get("workflow_runs", [])
|
||||
for run in runs:
|
||||
try:
|
||||
payload = json.loads(run.get("event_payload", "{}"))
|
||||
if payload.get("pull_request", {}).get("number") == pr_number:
|
||||
return run
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
pr_ref = f"#{pr_number}"
|
||||
for run in _fgj_run_list(limit=50):
|
||||
if run.get("event") == "pull_request" and run.get("prettyref") == pr_ref:
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _get_issue_labels(issue: int) -> list[str]:
|
||||
"""Return label names for an issue."""
|
||||
data = _tea_get(f"repos/{REPO}/issues/{issue}")
|
||||
if not data:
|
||||
result = subprocess.run(
|
||||
["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 [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:
|
||||
@@ -287,8 +289,18 @@ def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: in
|
||||
"merged" — PR closed after a retry
|
||||
"fallback" — all options exhausted; caller should set State/Question
|
||||
"""
|
||||
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
|
||||
mergeable = (pr_data or {}).get("mergeable")
|
||||
result = subprocess.run(
|
||||
["fgj", "--hostname", "codeberg.org", "pr", "view", str(pr_number),
|
||||
"--repo", REPO, "--json"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
pr_data: dict = {}
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
try:
|
||||
pr_data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
mergeable = pr_data.get("mergeable")
|
||||
|
||||
if mergeable is False:
|
||||
prompt = (
|
||||
@@ -831,9 +843,8 @@ def _run_loop() -> int:
|
||||
# spawning another agent, check whether any CI run is currently in
|
||||
# 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:
|
||||
check = _tea_get(f"repos/{REPO}/actions/runs?limit=5")
|
||||
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 in_flight:
|
||||
|
||||
@@ -20,7 +20,9 @@ const _noCode = {
|
||||
'lib/core/repositories/sync_log_repository.dart',
|
||||
'lib/core/repositories/undo_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/user_preferences.dart',
|
||||
'lib/core/storage/secure_storage.dart',
|
||||
};
|
||||
|
||||
@@ -58,6 +60,7 @@ const _excluded = {
|
||||
'lib/ui/widgets/try_connection_button.dart',
|
||||
'lib/ui/widgets/undo_shell.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/core/sync/account_sync_manager.dart',
|
||||
@@ -72,6 +75,8 @@ const _excluded = {
|
||||
'lib/data/repositories/sync_log_repository_impl.dart',
|
||||
'lib/data/repositories/undo_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',
|
||||
};
|
||||
|
||||
|
||||
@@ -149,6 +149,22 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
|
||||
@override
|
||||
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 {
|
||||
|
||||
@@ -224,6 +224,21 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
|
||||
@override
|
||||
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 {
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
// Do not manually edit this file.
|
||||
|
||||
// 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/src/dummies.dart' as _i6;
|
||||
import 'package:sharedinbox/core/models/account.dart' as _i5;
|
||||
import 'package:sharedinbox/core/models/email.dart' as _i2;
|
||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
|
||||
import 'package:mockito/src/dummies.dart' as _i7;
|
||||
import 'package:sharedinbox/core/models/account.dart' as _i6;
|
||||
import 'package:sharedinbox/core/models/email.dart' as _i3;
|
||||
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
|
||||
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/mailbox_repository.dart' as _i7;
|
||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// 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: invalid_use_of_internal_member
|
||||
|
||||
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
|
||||
_FakeEmailBody_0(
|
||||
class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
|
||||
_FakeMailbox_0(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
@@ -39,9 +39,8 @@ class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
|
||||
implements _i2.SyncEmailsResult {
|
||||
_FakeSyncEmailsResult_1(
|
||||
class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
|
||||
_FakeEmailBody_1(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
@@ -50,9 +49,20 @@ class _FakeSyncEmailsResult_1 extends _i1.SmartFake
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeReliabilityResult_2 extends _i1.SmartFake
|
||||
implements _i2.ReliabilityResult {
|
||||
_FakeReliabilityResult_2(
|
||||
class _FakeSyncEmailsResult_2 extends _i1.SmartFake
|
||||
implements _i3.SyncEmailsResult {
|
||||
_FakeSyncEmailsResult_2(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
parent,
|
||||
parentInvocation,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeReliabilityResult_3 extends _i1.SmartFake
|
||||
implements _i3.ReliabilityResult {
|
||||
_FakeReliabilityResult_3(
|
||||
Object parent,
|
||||
Invocation parentInvocation,
|
||||
) : super(
|
||||
@@ -64,32 +74,32 @@ class _FakeReliabilityResult_2 extends _i1.SmartFake
|
||||
/// A class which mocks [AccountRepository].
|
||||
///
|
||||
/// 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() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
|
||||
_i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeAccounts,
|
||||
[],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i5.Account>>.empty(),
|
||||
) as _i4.Stream<List<_i5.Account>>);
|
||||
returnValue: _i5.Stream<List<_i6.Account>>.empty(),
|
||||
) as _i5.Stream<List<_i6.Account>>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
|
||||
_i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getAccount,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<_i5.Account?>.value(),
|
||||
) as _i4.Future<_i5.Account?>);
|
||||
returnValue: _i5.Future<_i6.Account?>.value(),
|
||||
) as _i5.Future<_i6.Account?>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> addAccount(
|
||||
_i5.Account? account,
|
||||
_i5.Future<void> addAccount(
|
||||
_i6.Account? account,
|
||||
String? password,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
@@ -100,13 +110,13 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
|
||||
password,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> updateAccount(
|
||||
_i5.Account? account, {
|
||||
_i5.Future<void> updateAccount(
|
||||
_i6.Account? account, {
|
||||
String? password,
|
||||
}) =>
|
||||
(super.noSuchMethod(
|
||||
@@ -115,65 +125,65 @@ class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
|
||||
[account],
|
||||
{#password: password},
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
|
||||
_i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#removeAccount,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
|
||||
_i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getPassword,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(
|
||||
#getPassword,
|
||||
[accountId],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<String>);
|
||||
) as _i5.Future<String>);
|
||||
}
|
||||
|
||||
/// A class which mocks [MailboxRepository].
|
||||
///
|
||||
/// 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() {
|
||||
_i1.throwOnMissingStub(this);
|
||||
}
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
|
||||
_i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeMailboxes,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
|
||||
) as _i4.Stream<List<_i8.Mailbox>>);
|
||||
returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
|
||||
) as _i5.Stream<List<_i2.Mailbox>>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
|
||||
_i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#syncMailboxes,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
) as _i4.Future<int>);
|
||||
returnValue: _i5.Future<int>.value(0),
|
||||
) as _i5.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i8.Mailbox?> findMailboxByRole(
|
||||
_i5.Future<_i2.Mailbox?> findMailboxByRole(
|
||||
String? accountId,
|
||||
String? role,
|
||||
) =>
|
||||
@@ -185,18 +195,46 @@ class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
|
||||
role,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<_i8.Mailbox?>.value(),
|
||||
) as _i4.Future<_i8.Mailbox?>);
|
||||
returnValue: _i5.Future<_i2.Mailbox?>.value(),
|
||||
) as _i5.Future<_i2.Mailbox?>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#clearForResync,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) 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].
|
||||
@@ -208,13 +246,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
|
||||
_i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
|
||||
Invocation.getter(#onChangesQueued),
|
||||
returnValue: _i4.Stream<String>.empty(),
|
||||
) as _i4.Stream<String>);
|
||||
returnValue: _i5.Stream<String>.empty(),
|
||||
) as _i5.Stream<String>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmails(
|
||||
_i5.Stream<List<_i3.Email>> observeEmails(
|
||||
String? accountId,
|
||||
String? mailboxPath, {
|
||||
int? limit = 50,
|
||||
@@ -228,11 +266,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
],
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||
) as _i4.Stream<List<_i2.Email>>);
|
||||
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
|
||||
) as _i5.Stream<List<_i3.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.EmailThread>> observeThreads(
|
||||
_i5.Stream<List<_i3.EmailThread>> observeThreads(
|
||||
String? accountId,
|
||||
String? mailboxPath, {
|
||||
int? limit = 50,
|
||||
@@ -246,11 +284,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
],
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
|
||||
) as _i4.Stream<List<_i2.EmailThread>>);
|
||||
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
|
||||
) as _i5.Stream<List<_i3.EmailThread>>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
|
||||
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
String? threadId,
|
||||
@@ -264,36 +302,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
threadId,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
|
||||
) as _i4.Stream<List<_i2.Email>>);
|
||||
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
|
||||
) as _i5.Stream<List<_i3.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
|
||||
_i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getEmail,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<_i2.Email?>.value(),
|
||||
) as _i4.Future<_i2.Email?>);
|
||||
returnValue: _i5.Future<_i3.Email?>.value(),
|
||||
) as _i5.Future<_i3.Email?>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
|
||||
_i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#getEmailBody,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
|
||||
returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
|
||||
this,
|
||||
Invocation.method(
|
||||
#getEmailBody,
|
||||
[emailId],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<_i2.EmailBody>);
|
||||
) as _i5.Future<_i3.EmailBody>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.SyncEmailsResult> syncEmails(
|
||||
_i5.Future<_i3.SyncEmailsResult> syncEmails(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
@@ -306,7 +344,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
],
|
||||
),
|
||||
returnValue:
|
||||
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
|
||||
_i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
|
||||
this,
|
||||
Invocation.method(
|
||||
#syncEmails,
|
||||
@@ -316,10 +354,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<_i2.SyncEmailsResult>);
|
||||
) as _i5.Future<_i3.SyncEmailsResult>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> setFlag(
|
||||
_i5.Future<void> setFlag(
|
||||
String? emailId, {
|
||||
bool? seen,
|
||||
bool? flagged,
|
||||
@@ -333,12 +371,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
#flagged: flagged,
|
||||
},
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> markAllAsRead(
|
||||
_i5.Future<void> markAllAsRead(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
@@ -350,12 +388,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
mailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> moveEmail(
|
||||
_i5.Future<void> moveEmail(
|
||||
String? emailId,
|
||||
String? destMailboxPath,
|
||||
) =>
|
||||
@@ -367,23 +405,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
destMailboxPath,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||
_i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#deleteEmail,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<String?>.value(),
|
||||
) as _i4.Future<String?>);
|
||||
returnValue: _i5.Future<String?>.value(),
|
||||
) as _i5.Future<String?>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> sendEmail(
|
||||
_i5.Future<void> sendEmail(
|
||||
String? accountId,
|
||||
_i2.EmailDraft? draft,
|
||||
_i3.EmailDraft? draft,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -393,14 +431,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
draft,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<String> downloadAttachment(
|
||||
_i5.Future<String> downloadAttachment(
|
||||
String? emailId,
|
||||
_i2.EmailAttachment? attachment,
|
||||
_i3.EmailAttachment? attachment,
|
||||
) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
@@ -410,7 +448,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
attachment,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(
|
||||
#downloadAttachment,
|
||||
@@ -420,25 +458,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<String>);
|
||||
) as _i5.Future<String>);
|
||||
|
||||
@override
|
||||
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
|
||||
_i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#fetchRawRfc822,
|
||||
[emailId],
|
||||
),
|
||||
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
|
||||
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
|
||||
this,
|
||||
Invocation.method(
|
||||
#fetchRawRfc822,
|
||||
[emailId],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<String>);
|
||||
) as _i5.Future<String>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> searchEmails(
|
||||
_i5.Future<List<_i3.Email>> searchEmails(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
String? query,
|
||||
@@ -452,11 +490,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
query,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||
) as _i5.Future<List<_i3.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
|
||||
_i5.Future<List<_i3.Email>> searchEmailsGlobal(
|
||||
String? accountId,
|
||||
String? query,
|
||||
) =>
|
||||
@@ -468,11 +506,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
query,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||
) as _i5.Future<List<_i3.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.Email>> getEmailsByAddress(
|
||||
_i5.Future<List<_i3.Email>> getEmailsByAddress(
|
||||
String? accountId,
|
||||
String? address,
|
||||
) =>
|
||||
@@ -484,11 +522,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
address,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
|
||||
) as _i4.Future<List<_i2.Email>>);
|
||||
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
|
||||
) as _i5.Future<List<_i3.Email>>);
|
||||
|
||||
@override
|
||||
_i4.Future<List<_i2.EmailAddress>> searchAddresses(
|
||||
_i5.Future<List<_i3.EmailAddress>> searchAddresses(
|
||||
String? accountId,
|
||||
String? query, {
|
||||
int? limit = 10,
|
||||
@@ -503,11 +541,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
{#limit: limit},
|
||||
),
|
||||
returnValue:
|
||||
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]),
|
||||
) as _i4.Future<List<_i2.EmailAddress>>);
|
||||
_i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
|
||||
) as _i5.Future<List<_i3.EmailAddress>>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> flushPendingChanges(
|
||||
_i5.Future<int> flushPendingChanges(
|
||||
String? accountId,
|
||||
String? password,
|
||||
) =>
|
||||
@@ -519,42 +557,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
password,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
) as _i4.Future<int>);
|
||||
returnValue: _i5.Future<int>.value(0),
|
||||
) as _i5.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
|
||||
_i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
|
||||
String? accountId) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#observeFailedMutations,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
|
||||
) as _i4.Stream<List<_i2.FailedMutation>>);
|
||||
returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
|
||||
) as _i5.Stream<List<_i3.FailedMutation>>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
|
||||
_i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#discardMutation,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
|
||||
_i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#retryMutation,
|
||||
[id],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<bool> cancelPendingChange(
|
||||
_i5.Future<bool> cancelPendingChange(
|
||||
String? emailId,
|
||||
String? changeType,
|
||||
) =>
|
||||
@@ -566,11 +604,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
changeType,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<bool>.value(false),
|
||||
) as _i4.Future<bool>);
|
||||
returnValue: _i5.Future<bool>.value(false),
|
||||
) as _i5.Future<bool>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> snoozeEmail(
|
||||
_i5.Future<void> snoozeEmail(
|
||||
String? emailId,
|
||||
DateTime? until,
|
||||
) =>
|
||||
@@ -582,32 +620,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
until,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
||||
_i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#wakeUpEmails,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
) as _i4.Future<int>);
|
||||
returnValue: _i5.Future<int>.value(0),
|
||||
) as _i5.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
|
||||
_i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
|
||||
(super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#restoreEmails,
|
||||
[emails],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.Email?> findEmailByMessageId(
|
||||
_i5.Future<_i3.Email?> findEmailByMessageId(
|
||||
String? accountId,
|
||||
String? messageId,
|
||||
) =>
|
||||
@@ -619,20 +657,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
messageId,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Future<_i2.Email?>.value(),
|
||||
) as _i4.Future<_i2.Email?>);
|
||||
returnValue: _i5.Future<_i3.Email?>.value(),
|
||||
) as _i5.Future<_i3.Email?>);
|
||||
|
||||
@override
|
||||
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
|
||||
_i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#applySieveRules,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<int>.value(0),
|
||||
) as _i4.Future<int>);
|
||||
returnValue: _i5.Future<int>.value(0),
|
||||
) as _i5.Future<int>);
|
||||
|
||||
@override
|
||||
_i4.Stream<void> watchJmapPush(
|
||||
_i5.Stream<void> watchJmapPush(
|
||||
String? accountId,
|
||||
String? password,
|
||||
) =>
|
||||
@@ -644,11 +682,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
password,
|
||||
],
|
||||
),
|
||||
returnValue: _i4.Stream<void>.empty(),
|
||||
) as _i4.Stream<void>);
|
||||
returnValue: _i5.Stream<void>.empty(),
|
||||
) as _i5.Stream<void>);
|
||||
|
||||
@override
|
||||
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
|
||||
_i5.Future<_i3.ReliabilityResult> verifySyncReliability(
|
||||
String? accountId,
|
||||
String? mailboxPath,
|
||||
) =>
|
||||
@@ -661,7 +699,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
],
|
||||
),
|
||||
returnValue:
|
||||
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
|
||||
_i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
|
||||
this,
|
||||
Invocation.method(
|
||||
#verifySyncReliability,
|
||||
@@ -671,15 +709,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
|
||||
],
|
||||
),
|
||||
)),
|
||||
) as _i4.Future<_i2.ReliabilityResult>);
|
||||
) as _i5.Future<_i3.ReliabilityResult>);
|
||||
|
||||
@override
|
||||
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
|
||||
Invocation.method(
|
||||
#clearForResync,
|
||||
[accountId],
|
||||
),
|
||||
returnValue: _i4.Future<void>.value(),
|
||||
returnValueForMissingStub: _i4.Future<void>.value(),
|
||||
) as _i4.Future<void>);
|
||||
returnValue: _i5.Future<void>.value(),
|
||||
returnValueForMissingStub: _i5.Future<void>.value(),
|
||||
) as _i5.Future<void>);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
||||
|
||||
import 'account_repository_impl_test.dart' show MapSecureStorage;
|
||||
import 'db_test_helper.dart';
|
||||
import 'fake_imap.dart' show SnoozeSpyImapClient;
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const _account = Account(
|
||||
@@ -432,5 +433,177 @@ void main() {
|
||||
expect(result, isNotNull);
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ void main() {
|
||||
group('Migration', () {
|
||||
test('schemaVersion matches expected value', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
expect(db.schemaVersion, 33);
|
||||
expect(db.schemaVersion, 34);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
@@ -199,6 +199,9 @@ void main() {
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
@@ -391,11 +394,14 @@ void main() {
|
||||
expect(syncLogColumns, contains('error_stack_trace'));
|
||||
expect(syncLogColumns, contains('is_permanent'));
|
||||
|
||||
// v34: user_preferences table.
|
||||
await db.customSelect('SELECT count(*) FROM user_preferences').get();
|
||||
|
||||
await db.close();
|
||||
if (dbFile.existsSync()) dbFile.deleteSync();
|
||||
});
|
||||
|
||||
test('fresh install creates all tables at schemaVersion 33', () async {
|
||||
test('fresh install creates all tables at schemaVersion 34', () async {
|
||||
final db = AppDatabase(NativeDatabase.memory());
|
||||
await db.select(db.accounts).get();
|
||||
|
||||
@@ -422,6 +428,7 @@ void main() {
|
||||
'local_sieve_scripts', // v29
|
||||
'share_keys', // v31
|
||||
'local_sieve_applied', // v32
|
||||
'user_preferences', // v34
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -62,6 +62,21 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
null;
|
||||
@override
|
||||
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 {
|
||||
|
||||
@@ -54,6 +54,21 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
null;
|
||||
@override
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
|
||||
|
||||
import 'helpers.dart';
|
||||
|
||||
@@ -206,5 +207,50 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -290,11 +290,10 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'Mark as spam moves email to junk and shows snackbar when no junk folder',
|
||||
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||
(tester) async {
|
||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
||||
// returns null → snackbar shown.
|
||||
// returns null → dialog shown.
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
@@ -312,7 +311,76 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No Junk folder found'), findsOneWidget);
|
||||
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 {
|
||||
@@ -407,6 +475,44 @@ void main() {
|
||||
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', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
|
||||
@@ -315,7 +316,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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', (
|
||||
@@ -631,5 +632,150 @@ void main() {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@@ -14,6 +14,7 @@ import 'package:sharedinbox/core/models/discovery_result.dart';
|
||||
import 'package:sharedinbox/core/models/draft.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/account_repository.dart';
|
||||
import 'package:sharedinbox/core/repositories/draft_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/share_key_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/connection_test_service.dart';
|
||||
import 'package:sharedinbox/core/services/managesieve_probe_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/ui/screens/account_list_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/search_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/thread_detail_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake repositories
|
||||
@@ -164,8 +168,28 @@ class FakeMailboxRepository implements MailboxRepository {
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||
_mailboxes.where((m) => m.role == role).firstOrNull;
|
||||
|
||||
@override
|
||||
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 {
|
||||
@@ -410,6 +434,10 @@ Widget buildApp({
|
||||
path: 'send',
|
||||
builder: (ctx, state) => const AccountSendScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'preferences',
|
||||
builder: (ctx, state) => const UserPreferencesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':accountId/edit',
|
||||
builder: (ctx, state) => EditAccountScreen(
|
||||
@@ -485,16 +513,18 @@ Widget buildApp({
|
||||
return ProviderScope(
|
||||
// Defaults come first so tests can override them via [overrides].
|
||||
//
|
||||
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
|
||||
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
|
||||
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
|
||||
// framework then fails the test with "A Timer is still pending". Replacing
|
||||
// these with simple synchronous streams avoids the pending-timer assertion.
|
||||
// syncLogRepositoryProvider is backed by a Drift StreamQuery. When the
|
||||
// provider is disposed, Drift schedules a Timer.run() for cache
|
||||
// debouncing. Flutter's test framework then fails the test with "A Timer
|
||||
// is still pending". Replacing it with a synchronous stream avoids this.
|
||||
// syncHealthProvider has the same issue and is overridden in baseOverrides.
|
||||
overrides: [
|
||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
syncLogRepositoryProvider.overrideWithValue(
|
||||
const NoOpSyncLogRepository(),
|
||||
),
|
||||
userPreferencesRepositoryProvider.overrideWithValue(
|
||||
FakeUserPreferencesRepository(),
|
||||
),
|
||||
...overrides,
|
||||
manageSieveProbeServiceProvider.overrideWith(
|
||||
(ref) => _NoOpManageSieveProbeService(),
|
||||
@@ -521,6 +551,7 @@ List<Override> baseOverrides({
|
||||
Exception? connectionError,
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
bool hasStoredPassword = true,
|
||||
SyncHealthRow? syncHealth,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
@@ -539,6 +570,9 @@ List<Override> baseOverrides({
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
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 +602,7 @@ Email testEmail({
|
||||
bool isSeen = false,
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
String? listUnsubscribeHeader,
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
@@ -583,8 +618,26 @@ Email testEmail({
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||
);
|
||||
|
||||
class FakeUserPreferencesRepository implements UserPreferencesRepository {
|
||||
FakeUserPreferencesRepository({
|
||||
this.menuPosition = MenuPosition.bottom,
|
||||
});
|
||||
|
||||
MenuPosition menuPosition;
|
||||
|
||||
@override
|
||||
Stream<UserPreferences> observePreferences() =>
|
||||
Stream.value(UserPreferences(menuPosition: menuPosition));
|
||||
|
||||
@override
|
||||
Future<void> updateMenuPosition(MenuPosition position) async {
|
||||
menuPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
||||
final List<String> _history = [];
|
||||
|
||||
|
||||
@@ -41,6 +41,20 @@ void main() {
|
||||
expect(html, contains('https: http: data: blob:'));
|
||||
_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
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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)'), findsOneWidget);
|
||||
expect(find.text('Top'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('bottom option is selected by default', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final radioGroup = find.byType(RadioGroup<MenuPosition>);
|
||||
final widget = tester.widget<RadioGroup<MenuPosition>>(radioGroup);
|
||||
expect(widget.groupValue, MenuPosition.bottom);
|
||||
});
|
||||
|
||||
testWidgets('tapping Top option updates the repo', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts/preferences',
|
||||
overrides: baseOverrides(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Top'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final repo = ProviderScope.containerOf(
|
||||
tester.element(find.byType(UserPreferencesScreen)),
|
||||
).read(userPreferencesRepositoryProvider)
|
||||
as FakeUserPreferencesRepository;
|
||||
|
||||
expect(repo.menuPosition, MenuPosition.top);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,8 @@ The app processes the following data **exclusively on your device**:
|
||||
device's secure storage and never transmitted to us.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||