Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 e16dab6752 test: add agentloop code test comment to DEVELOPMENT.md
Closes #335

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 09:12:17 +02:00
Bot of Thomas Güttler d905cd653f fix: check Docker availability before falling back to local Dagger engine (#329) (#333) 2026-05-29 23:19:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e21cde0a3c fix: allow forgejo-actions as issue author in agent loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:52:56 +02:00
Bot of Thomas Güttler 50a6678ec2 feat: reimplement user preferences, archive, configurable navigation (#315) (#324) 2026-05-29 19:08:12 +02:00
Bot of Thomas Güttler 91083218d4 fix: diff from last deployed SHA to catch all changes since last deploy (#320) (#332) 2026-05-29 17:34:21 +02:00
Bot of Thomas Güttler adc4eb6f6d feat: remove publish-website from deploy.yml, schedule website.yml hourly (#325) (#330) 2026-05-29 12:53:18 +02:00
Bot of Thomas Güttler 05d00bdf09 fix: move overflow actions into popup menu so three-dot menu is always visible (#312) (#323) 2026-05-28 07:19:11 +02:00
Bot of Thomas Güttler c45775be92 fix: move sync health report to own row below each account (#311) (#322) 2026-05-28 06:53:11 +02:00
47fc534a8d fix: disable github-actions manager to suppress GitHub token warning (#285) (#306)
## Summary

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

## Test plan

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

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

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/306
2026-05-28 05:03:02 +02:00
Bot of Thomas Güttler a5928c1aa6 fix: add _tea_get and merged-PR catch-up to close issues on merge (#305) (#310) 2026-05-28 00:07:13 +02:00
Bot of Thomas Güttler 7f3cd43d6e feat: add --dangerously-skip-permissions to claude --resume output (#304) (#309) 2026-05-27 23:48:12 +02:00
Bot of Thomas Güttler f0f210e5ab feat: configurable next action after single mail view (#300) (#308) 2026-05-27 23:33:14 +02:00
Bot of Thomas Güttler 41550eb4b5 feat: configurable menu bar position for mailbox view (#298) (#303) 2026-05-27 22:07:12 +02:00
Bot of Thomas Güttler 633fc5d9da fix: show full discrepancy details in account list (#296) (#301) 2026-05-27 21:20:19 +02:00
Bot of Thomas Güttler 14f64cd2a5 feat: show URL tooltip on long-press of unsubscribe chip (#294) (#295) 2026-05-27 21:02:30 +02:00
Bot of Thomas Güttler 5ddfe68467 feat: catch up Renovate PRs with passing CI in agent loop (#289) (#293) 2026-05-27 20:09:13 +02:00
Bot of Thomas Güttler f42522e6d0 Merge pull request 'chore(deps): update gradle to v8.14.5' (#274) from renovate/gradle-8.x into main 2026-05-27 20:02:49 +02:00
guettlibotandBot of Thomas Güttler db78d590ca chore(deps): update opentelemetry-go monorepo to v0.19.0 (#279) 2026-05-27 20:00:52 +02:00
Bot of Thomas Güttler dbb29fb76a fix: rename workflow to Update Website and guard verify step (#282) (#283) 2026-05-27 20:00:39 +02:00
guettlibotandBot of Thomas Güttler 2d2d12cc24 chore(deps): update dependency flutter to v3.44.0 (#278) 2026-05-27 20:00:08 +02:00
guettlibotandBot of Thomas Güttler 3f0b3e5096 fix(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 (#275) 2026-05-27 19:59:21 +02:00
guettlibotandBot of Thomas Güttler 38fab3f5fc chore(deps): update gradle to v8.14.5 (#274) 2026-05-27 19:58:36 +02:00
Bot of Thomas Güttler e2b08e07b7 fix: prevent HTML email content from being cut off (#288) (#292) 2026-05-27 19:52:14 +02:00
Bot of Thomas Güttler c0dd13be5d feat: align single and multi-mail actions, add archive (#287) (#291) 2026-05-27 19:36:13 +02:00
guettlibot 96bd351512 chore(deps): update gradle to v8.14.5 2026-05-27 06:06:19 +00:00
44 changed files with 1564 additions and 333 deletions
+13 -48
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
fetch-depth: 0
- name: Detect Android and Linux changes
id: diff
@@ -48,7 +48,7 @@ jobs:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
if r.get("status") == "success"
]
print(runs[0].get("commit_sha") or "")
except Exception as e:
@@ -64,10 +64,17 @@ jobs:
exit 0
fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
# Diff from the last successfully deployed commit to catch all changes since
# that deploy, not just the most recent commit. Falls back to HEAD~1 when
# LAST_DEPLOYED_SHA is unknown or not in local history.
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
fi
echo "Changed files:"
echo "$CHANGED"
@@ -204,48 +211,6 @@ jobs:
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health:
name: Update Deploy Health Label
runs-on: ubuntu-latest
+6 -3
View File
@@ -1,6 +1,8 @@
name: Deploy Website
name: Update Website
on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push:
branches: [main]
paths:
@@ -11,7 +13,7 @@ on:
jobs:
deploy:
name: Build & Deploy Website
name: Build & Update Website
runs-on: ubuntu-latest
timeout-minutes: 60
@@ -34,7 +36,7 @@ jobs:
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Build & Deploy Website
- name: Build & Update Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
@@ -45,6 +47,7 @@ jobs:
run: task publish-website
- name: Verify Website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.41.6"
"flutter": "3.44.0"
}
+2
View File
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
<!-- agentloop code test passed -->
+1 -1
View File
@@ -294,7 +294,7 @@ tasks:
for attempt in 1 2 3; do
run_dagger "$@" && return 0
RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then
if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
+1 -1
View File
@@ -67,7 +67,7 @@ flutter {
dependencies {
// Required for flutter_local_notifications and other plugins that need Java 8+ APIs on API < 26.
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// integration_test is a dev dependency; the Flutter plugin loader adds it as
// debugImplementation only, but GeneratedPluginRegistrant.java (in src/main)
// references its class in all variants. Make it available for release compilation
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
+4 -4
View File
@@ -44,10 +44,10 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
)
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0
replace go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp => go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.16.0
replace go.opentelemetry.io/otel/log => go.opentelemetry.io/otel/log v0.19.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.16.0
replace go.opentelemetry.io/otel/sdk/log => go.opentelemetry.io/otel/sdk/log v0.19.0
+12
View File
@@ -4,6 +4,18 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `user_preferences` table) — in main
- PR #307 and issue #299 closed.
- Issue #315 closed.
## Tasks (2026-05-26)
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
+2 -2
View File
@@ -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
View File
@@ -1 +1 @@
const int dbSchemaVersion = 33;
const int dbSchemaVersion = 36;
+14
View File
@@ -0,0 +1,14 @@
enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences {
const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
}
@@ -0,0 +1,8 @@
import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
}
+33
View File
@@ -307,6 +307,23 @@ 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'))();
// Added in schema v35: 'bottom' (default) | 'top'
TextColumn get mailViewButtonPosition =>
text().withDefault(const Constant('bottom'))();
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
@override
Set<Column> get primaryKey => {id};
}
// ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase(
@@ -327,6 +344,7 @@ class LocalSieveApplied extends Table {
LocalSieveScripts,
LocalSieveApplied,
ShareKeys,
UserPreferences,
],
)
class AppDatabase extends _$AppDatabase {
@@ -578,6 +596,21 @@ class AppDatabase extends _$AppDatabase {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
if (from < 34) {
await m.createTable(userPreferences);
}
if (from >= 34 && from < 35) {
await m.addColumn(
userPreferences,
userPreferences.mailViewButtonPosition,
);
}
if (from >= 34 && from < 36) {
await m.addColumn(
userPreferences,
userPreferences.afterMailViewAction,
);
}
},
);
}
@@ -0,0 +1,68 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
UserPreferencesRepositoryImpl(this._db);
final AppDatabase _db;
static const _rowId = 1;
@override
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
}
@override
Future<void> updateMenuPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
menuPosition: Value(position.name),
),
);
}
@override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
mailViewButtonPosition: Value(position.name),
),
);
}
@override
Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action,
) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
afterMailViewAction: Value(action.name),
),
);
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences();
return pref.UserPreferences(
menuPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.menuPosition,
orElse: () => pref.MenuPosition.bottom,
),
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.mailViewButtonPosition,
orElse: () => pref.MenuPosition.bottom,
),
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
);
}
}
+15 -1
View File
@@ -5,6 +5,7 @@ import 'package:http/http.dart' as http;
import 'package:sharedinbox/core/models/account.dart' as model;
import 'package:sharedinbox/core/models/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();
});
+5
View File
@@ -20,6 +20,7 @@ import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart';
import 'package:sharedinbox/ui/screens/sync_log_screen.dart';
import 'package:sharedinbox/ui/screens/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(
+112 -71
View File
@@ -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'));
},
),
],
),
),
@@ -111,20 +120,80 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile(
leading: const Icon(Icons.account_circle),
title: Text(account.displayName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4),
health.when(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.account_circle),
title: Text(account.displayName),
subtitle: Text('${account.email}\n$typeLabel'),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) =>
const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
data: (h) {
if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Sync health: '),
Icon(
@@ -133,7 +202,13 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange,
),
const SizedBox(width: 4),
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
Expanded(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)),
],
);
@@ -141,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'),
),
],
),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
],
);
}
@@ -293,6 +310,30 @@ class _AccountTile extends ConsumerWidget {
}
}
String _formatDiscrepancies(String? summary) {
if (summary == null) return 'Discrepancies found';
try {
final decoded = jsonDecode(summary) as Map<String, dynamic>;
var missingLocally = 0;
var missingOnServer = 0;
var flagMismatches = 0;
for (final v in decoded.values) {
final m = v as Map<String, dynamic>;
missingLocally += (m['missingLocally'] as int? ?? 0);
missingOnServer += (m['missingOnServer'] as int? ?? 0);
flagMismatches += (m['flagMismatches'] as int? ?? 0);
}
final parts = <String>[];
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
if (parts.isEmpty) return 'Discrepancies found';
return 'Discrepancies found (${parts.join(', ')})';
} catch (_) {
return 'Discrepancies found';
}
}
class _OnboardingView extends StatelessWidget {
const _OnboardingView();
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m
in mailboxes.where((m) => m.path != currentMailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
+154 -64
View File
@@ -13,9 +13,11 @@ import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -76,57 +78,19 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
},
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
icon: const Icon(Icons.archive),
tooltip: 'Archive',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.mark_email_unread_outlined),
tooltip: 'Mark as unread',
onPressed: () async {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
unawaited(_archive(context, header));
},
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: 'Delete',
onPressed: () async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) {
@@ -145,11 +109,44 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
if (context.mounted) context.pop();
if (context.mounted) _navigateTo(context, header, nextEmailId);
},
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
PopupMenuButton<String>(
itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'forward',
child: Text('Forward'),
),
const PopupMenuItem(
value: 'move',
child: Text('Move to folder'),
),
const PopupMenuItem(
value: 'snooze',
child: Text('Snooze'),
),
const PopupMenuItem(
value: 'spam',
child: Text('Mark as spam'),
),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'headers',
child: Text('Show Mail Headers'),
@@ -163,8 +160,20 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text('Show Raw Email'),
),
],
onSelected: (value) {
if (value == 'headers' && body != null) {
onSelected: (value) async {
if (value == 'forward' && header != null) {
unawaited(_forward(context, header, body));
} else if (value == 'move' && header != null) {
unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header));
} else if (value == 'spam' && header != null) {
unawaited(_markAsSpam(context, header));
} else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId);
} else if (value == 'headers' && body != null) {
_showHeaders(context, body);
} else if (value == 'structure' && body != null) {
_showStructure(context, body);
@@ -243,6 +252,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex =
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename));
try {
@@ -393,21 +435,25 @@ 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 nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
if (junk == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No Junk folder found')),
);
return;
}
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, junk.path);
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
@@ -417,12 +463,48 @@ 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();
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _markAsSpam(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
if (mailbox == null || !context.mounted) return;
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
UndoAction(
id: DateTime.now().toIso8601String(),
accountId: header.accountId,
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
),
),
);
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _forward(
@@ -447,6 +529,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
}
Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -495,10 +579,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
);
if (context.mounted) context.pop();
if (context.mounted) _navigateTo(context, header, nextEmailId);
}
Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>(
context: context,
builder: (ctx) => const SnoozePicker(),
@@ -526,7 +613,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
),
);
context.pop();
_navigateTo(context, header, nextEmailId);
}
}
@@ -895,10 +982,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),
),
);
}
}
+39 -70
View File
@@ -7,10 +7,11 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.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';
@@ -25,8 +26,6 @@ int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
enum _MissingFolderChoice { chooseExisting, createNew }
class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({
super.key,
@@ -150,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(),
@@ -175,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),
@@ -303,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(
@@ -431,70 +453,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
final ids = _selectedEmailIds;
_clearSelection();
final mailboxRepo = ref.read(mailboxRepositoryProvider);
Mailbox? mailbox =
await mailboxRepo.findMailboxByRole(widget.accountId, role);
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
widget.accountId,
widget.mailboxPath,
role,
dialogTitle: dialogTitle,
createFolderName: createFolderName,
);
if (!mounted) return;
if (mailbox == null) {
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 (!mounted || choice == null) return;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes =
await mailboxRepo.observeMailboxes(widget.accountId).first;
if (!mounted) return;
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 != widget.mailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !mounted) return;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
widget.accountId,
createFolderName,
role,
);
if (!mounted) return;
}
}
if (!mounted || mailbox == null) return;
final repo = ref.read(emailRepositoryProvider);
+18
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/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 ───────────────────────────────────────
+23 -1
View File
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold(
appBar: AppBar(title: const Text('Thread')),
appBar: AppBar(
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) {
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
),
);
}
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
}
class _EmailMessageCard extends ConsumerStatefulWidget {
+145
View File
@@ -0,0 +1,145 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
class UserPreferencesScreen extends ConsumerWidget {
const UserPreferencesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefsAsync = ref.watch(userPreferencesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Preferences')),
body: prefsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Error loading preferences')),
data: (prefs) => ListView(
children: [
ListTile(
title: Text(
'Menu bar position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the folder navigation menu is shown in the mailbox view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.menuPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMenuPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Open folder navigation from a button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Open folder navigation from the hamburger icon in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'Single mail view button position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the back button is shown in the single mail view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.mailViewButtonPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMailViewButtonPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Show the back button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Show the back button in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'After mail action',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'What to show after deleting, archiving, or otherwise handling a message.',
),
),
RadioGroup<AfterMailViewAction>(
groupValue: prefs.afterMailViewAction,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateAfterMailViewAction(value),
);
},
child: const Column(
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text(
'Show the next message in the mailbox.',
),
value: AfterMailViewAction.nextMessage,
),
RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'),
subtitle: Text(
'Return to the message list.',
),
value: AfterMailViewAction.showMailbox,
),
],
),
),
],
),
),
);
}
}
+5 -2
View File
@@ -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>
+8 -8
View File
@@ -659,10 +659,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: "direct main"
description:
@@ -1088,26 +1088,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.17"
timezone:
dependency: transitive
description:
+1 -1
View File
@@ -5,7 +5,7 @@
],
"labels": ["dependencies"],
"github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
"enabled": false
},
"packageRules": [
{
+168 -35
View File
@@ -11,15 +11,18 @@ Flow
a. pending_issue type=="plan" post resume comment, set State/Planned, exit 0
b. pending_issue + open PR check PR branch CI, merge/fix/wait as needed
c. Catch-up: orphaned issue-N-fix PRs with passing CI merge them
d. Main CI running save pending-ci state, exit 0
e. Main CI failed start fix-CI agent (pushes fix to main), exit 0
f. Main CI ok + pending_issue close the issue, exit 0 (dead code path
d. Catch-up: close issues for PRs already merged (e.g., merged manually after
State/Question was set because CI path filter didn't trigger) → exit 0
e. Catch-up: Renovate PRs with passing CI merge them
f. Main CI running save pending-ci state, exit 0
g. Main CI failed start fix-CI agent (pushes fix to main), exit 0
h. Main CI ok + pending_issue close the issue, exit 0 (dead code path
section 2b always returns first)
g. Main CI ok (or no run yet) find oldest ToPlan issue, start plan agent,
i. Main CI ok (or no run yet) find oldest ToPlan issue, start plan agent,
save state, exit 0
h. No ToPlan issues find oldest Ready issue, start issue agent,
j. No ToPlan issues find oldest Ready issue, start issue agent,
save state, exit 0
i. No Ready issues print "nothing to do", exit 0
k. No Ready issues print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
Plan agents must NOT write any code or create PRs; they only post a plan comment.
@@ -32,7 +35,7 @@ Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
To resume the Claude conversation, look up the session UUID first:
scripts/agent_loop.py list # shows NAME and UUID columns
claude --resume <uuid> # use the UUID, NOT the session name
claude --resume <uuid> --dangerously-skip-permissions # use the UUID, NOT the session name
"""
import argparse
@@ -43,6 +46,8 @@ import shlex
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
@@ -74,7 +79,7 @@ LABEL_TO_PLAN = "State/ToPlan"
LABEL_PLANNED = "State/Planned"
# Only pick up issues filed by these accounts.
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"}
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2", "forgejo-actions"}
# ── helpers ───────────────────────────────────────────────────────────────────
@@ -120,6 +125,30 @@ def _fgj_run_list(limit: int = 20) -> list[dict]:
return data if isinstance(data, list) else []
def _tea_get(path: str) -> dict:
"""Make an authenticated GET request to the Codeberg API and return parsed JSON.
Tries FORGEJO_TOKEN env var first, then ``fgj auth token`` for the token.
"""
token = os.environ.get("FORGEJO_TOKEN", "")
if not token:
r = subprocess.run(
["fgj", "--hostname", "codeberg.org", "auth", "token"],
capture_output=True, text=True,
)
if r.returncode == 0:
token = r.stdout.strip()
url = f"https://codeberg.org/api/v1{path}"
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"GET {path}: HTTP {e.code} {e.reason}") from e
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
"""Add/remove labels on an issue via fgj."""
cmd = ["issue", "edit", str(issue), "--repo", REPO]
@@ -186,7 +215,8 @@ 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".
"""
for run in _fgj_run_list(limit=20):
data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
for run in data.get("workflow_runs", []):
if (run.get("event") == "push"
and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"):
@@ -197,19 +227,22 @@ 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.
For push events fgj reports the branch in ``prettyref``; for pull_request
events ``prettyref`` is ``#N``, so we resolve the PR number first.
For pull_request events the branch is embedded in the JSON ``event_payload``
field; for push events it appears directly in ``prettyref``.
"""
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:
data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
for run in data.get("workflow_runs", []):
if run.get("event") == "pull_request":
if pr_ref and run.get("prettyref") == pr_ref:
return run
elif run.get("event") == "push":
if run.get("prettyref") == branch:
return run
payload_str = run.get("event_payload", "")
if payload_str:
try:
payload = json.loads(payload_str)
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
return run
except (json.JSONDecodeError, AttributeError):
pass
elif run.get("event") == "push" and run.get("prettyref") == branch:
return run
return None
@@ -251,6 +284,53 @@ def _open_issue_prs() -> list[dict]:
return issue_prs
def _open_renovate_prs() -> list[dict]:
"""Return all open PRs from Renovate (renovate/* branches), oldest-first."""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "open", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
prs = json.loads(result.stdout)
renovate_prs = [
pr for pr in prs
if (pr.get("head", {}).get("ref") or "").startswith("renovate/")
]
renovate_prs.sort(key=lambda p: p["number"])
return renovate_prs
def _merged_issue_prs() -> list[dict]:
"""Return recently merged PRs with issue-{N}-fix branches, oldest-first.
Used for catch-up: if the loop set State/Question (e.g., no CI run detected)
but the PR was later merged manually, we still want to close the issue.
"""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "closed", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
try:
prs = json.loads(result.stdout)
except json.JSONDecodeError:
return []
merged = []
for pr in prs:
if not pr.get("merged"):
continue
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
merged.append(pr)
merged.sort(key=lambda p: p["number"])
return merged
def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number."""
pr_ref = f"#{pr_number}"
@@ -289,17 +369,10 @@ 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
"""
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
try:
pr_data = _tea_get(f"/repos/{REPO}/pulls/{pr_number}")
except RuntimeError:
pr_data = {}
mergeable = pr_data.get("mergeable")
if mergeable is False:
@@ -524,7 +597,7 @@ def cmd_list() -> int:
sessions.sort(reverse=True)
total = len(sessions)
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)")
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid> --dangerously-skip-permissions)")
print(f" {'-'*16} {'-'*20} {'-'*36}")
for mtime, name, sid in sessions[:20]:
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
@@ -608,9 +681,9 @@ def _run_loop() -> int:
session_name = state.get("session_name")
uuid = _find_session_uuid(session_name) if session_name else None
if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
elif session_name:
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list"
resume_cmd = f"claude --resume <uuid> --dangerously-skip-permissions # run: scripts/agent_loop.py list"
else:
resume_cmd = ""
git_info = _git_summary()
@@ -639,7 +712,7 @@ def _run_loop() -> int:
session_name = f"plan-issue-{pending_issue}"
uuid = _find_session_uuid(session_name)
if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}"
resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
_comment_issue(
pending_issue,
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
@@ -828,6 +901,66 @@ def _run_loop() -> int:
print(f"Merged PR #{pr_number}.")
return 0
# ── 2c. Catch-up: close issues whose PRs were already merged ─────────────
# Handles the case where State/Question was set (e.g., no CI run appeared
# because the changed paths didn't match ci.yml's path filter) but the PR
# was merged manually afterward. The next loop tick closes the issue.
for pr in _merged_issue_prs():
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
if not m:
continue
issue_num = int(m.group(1))
try:
issue_data = _tea_get(f"/repos/{REPO}/issues/{issue_num}")
except RuntimeError:
continue
if issue_data.get("state") != "open":
continue
pr_number = pr["number"]
print(f"Catch-up (merged PR): PR #{pr_number} for issue #{issue_num} was merged — closing.")
try:
_close_issue(issue_num)
except RuntimeError as e:
print(f"Catch-up (merged PR): could not close issue #{issue_num}: {e}")
continue
return 0
# ── 2d. Catch-up: merge Renovate PRs with passing CI ─────────────────────
# The merge-renovate CI job only fires on pull_request events. If a Renovate
# PR had CI run before that job was added (or the automerge label was absent),
# it stays open forever. Detect and merge those here.
for pr in _open_renovate_prs():
pr_number = pr["number"]
pr_url = f"{REPO_URL}/pulls/{pr_number}"
pr_run = _latest_ci_run_for_pr(pr_number)
if pr_run and pr_run.get("status") == "running":
print(f"Catch-up (Renovate): CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} still running. Waiting.")
return 0
if pr_run and pr_run.get("status") in ("failure", "error"):
print(f"Catch-up (Renovate): CI {_ci_run_url(pr_run['id'])} on PR #{pr_number} failed — skipping.")
continue
if pr_run and pr_run.get("status") == "success":
print(f"Catch-up (Renovate): CI passed on PR #{pr_number} ({pr_url}) — merging.")
try:
_merge_pr(pr_number)
except RuntimeError as e:
print(f"Catch-up (Renovate): merge of PR #{pr_number} failed: {e} — skipping.")
continue
branch = pr.get("head", {}).get("ref", "")
if _find_pr_for_branch(branch):
print(f"Catch-up (Renovate): PR #{pr_number} still open after merge — skipping.")
continue
print(f"Catch-up (Renovate): merged PR #{pr_number}.")
return 0
if pr_run is None:
print(f"Catch-up (Renovate): no CI run for PR #{pr_number} ({pr_url}) — skipping (needs manual review).")
# ── 3. Global CI check (main branch only) ────────────────────────────────
run = _latest_main_ci_run()
+5
View File
@@ -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',
};
+6
View File
@@ -24,6 +24,12 @@ for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
if ! docker info >/dev/null 2>&1; then
echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running."
echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible"
echo "or that Docker is running locally (check: sudo systemctl start docker)."
exit 1
fi
echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0
fi
+77 -1
View File
@@ -202,6 +202,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
@@ -229,6 +230,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \
@@ -243,6 +245,7 @@ class TestMain(unittest.TestCase):
"""main() exits cleanly with 0 when there are no ready issues."""
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._set_labels") as mock_labels, \
@@ -263,6 +266,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
patch("agent_loop._set_labels"), \
@@ -442,6 +446,7 @@ class TestPendingCi(unittest.TestCase):
"type": "ci-fix",
}), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \
@@ -459,6 +464,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
@@ -471,6 +477,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
@@ -482,6 +489,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
@@ -493,6 +501,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \
@@ -714,7 +723,7 @@ class TestRunLoopResumeCommand(unittest.TestCase):
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn(f"claude --resume {fake_uuid}", output)
self.assertIn(f"claude --resume {fake_uuid} --dangerously-skip-permissions", output)
def test_resume_shows_list_hint_when_uuid_not_found(self):
buf = io.StringIO()
@@ -757,6 +766,7 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \
@@ -785,6 +795,71 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
mock_merge.assert_called_once_with(50)
class TestMergedPrCatchup(unittest.TestCase):
"""Catch-up closes issues whose PRs were already merged outside the normal flow."""
def _make_merged_pr(self, pr_number=283, branch="issue-282-fix"):
return {"number": pr_number, "merged": True, "head": {"ref": branch}}
def test_closes_issue_when_pr_was_merged(self):
"""When a merged issue-N-fix PR exists and the issue still has labels, close it."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(282)
def test_skips_when_issue_has_no_labels(self):
"""When _get_issue_labels returns [] (likely already closed), skip the issue."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
def test_output_mentions_merged_pr_and_issue(self):
"""The catch-up log line names the PR number and issue number."""
pr = self._make_merged_pr(pr_number=283, branch="issue-282-fix")
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue"), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("283", output)
self.assertIn("282", output)
def test_continues_on_close_error(self):
"""If _close_issue raises, the loop continues instead of crashing."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue", side_effect=RuntimeError("already closed")), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
class TestMergeFailsOpen(unittest.TestCase):
"""Tests for auto-resolution when a PR is still open after the merge command."""
@@ -928,6 +1003,7 @@ class TestHeartbeat(unittest.TestCase):
self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop()
+30 -2
View File
@@ -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, 36);
await db.close();
});
@@ -199,6 +199,16 @@ 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();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
@@ -391,11 +401,21 @@ 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();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 33', () async {
test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -422,6 +442,7 @@ void main() {
'local_sieve_scripts', // v29
'share_keys', // v31
'local_sieve_applied', // v32
'user_preferences', // v34
]),
);
@@ -441,6 +462,13 @@ void main() {
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close();
});
});
+70
View File
@@ -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,74 @@ 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);
},
);
testWidgets(
'sync health row is positioned below the account name row',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos));
},
);
});
}
+122 -8
View File
@@ -271,7 +271,58 @@ void main() {
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
});
testWidgets('Mark as spam button is present in app bar', (tester) async {
testWidgets('Mark as spam is in popup menu, not a standalone button',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget);
});
testWidgets('Mark as spam shows dialog when no junk folder',
(tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// Open the popup menu first, then tap Mark as spam.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as spam'));
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
});
testWidgets('Archive button is present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -284,17 +335,15 @@ void main() {
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
(w) => w is Tooltip && w.message == 'Archive',
),
findsOneWidget,
);
});
testWidgets(
'Mark as spam moves email to junk and shows snackbar when no junk folder',
(tester) async {
testWidgets('Archive shows dialog when no archive 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',
@@ -307,12 +356,39 @@ void main() {
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
(w) => w is Tooltip && w.message == 'Archive',
),
);
await tester.pumpAndSettle();
expect(find.text('No Junk folder found'), findsOneWidget);
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 +483,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 {
+1 -1
View File
@@ -316,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', (
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+59 -6
View File
@@ -14,6 +14,7 @@ import 'package:sharedinbox/core/models/discovery_result.dart';
import 'package:sharedinbox/core/models/draft.dart';
import 'package:sharedinbox/core/models/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
@@ -410,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
Widget buildApp({
required String initialLocation,
required List<Override> overrides,
UserPreferencesRepository? userPreferences,
}) {
final testRouter = GoRouter(
initialLocation: initialLocation,
@@ -430,6 +435,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(
@@ -505,16 +514,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(
userPreferences ?? FakeUserPreferencesRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(),
@@ -541,6 +552,7 @@ List<Override> baseOverrides({
Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true,
SyncHealthRow? syncHealth,
}) =>
[
accountRepositoryProvider.overrideWithValue(
@@ -559,6 +571,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)),
];
// ---------------------------------------------------------------------------
@@ -588,6 +603,7 @@ Email testEmail({
bool isSeen = false,
bool isFlagged = false,
bool hasAttachment = false,
String? listUnsubscribeHeader,
}) =>
Email(
id: id,
@@ -603,8 +619,45 @@ Email testEmail({
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: hasAttachment,
listUnsubscribeHeader: listUnsubscribeHeader,
);
class FakeUserPreferencesRepository implements UserPreferencesRepository {
FakeUserPreferencesRepository({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
MenuPosition menuPosition;
MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction;
@override
Stream<UserPreferences> observePreferences() => Stream.value(
UserPreferences(
menuPosition: menuPosition,
mailViewButtonPosition: mailViewButtonPosition,
afterMailViewAction: afterMailViewAction,
),
);
@override
Future<void> updateMenuPosition(MenuPosition position) async {
menuPosition = position;
}
@override
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
mailViewButtonPosition = position;
}
@override
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action;
}
}
class FakeSearchHistoryRepository implements SearchHistoryRepository {
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
@@ -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/user_preferences.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
@@ -142,6 +143,60 @@ void main() {
expect(find.byIcon(Icons.expand_more), findsOneWidget);
});
testWidgets('shows bottom app bar with back button by default', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
});
testWidgets('hides bottom app bar when button position is top', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
userPreferences: FakeUserPreferencesRepository(
mailViewButtonPosition: MenuPosition.top,
),
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsNothing);
});
testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true);
await tester.pumpWidget(
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'helpers.dart';
void main() {
group('UserPreferencesScreen', () {
testWidgets('shows both menu position options', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(find.text('Menu bar position'), findsOneWidget);
expect(find.text('Bottom (default)'), findsNWidgets(2));
expect(find.text('Top'), findsNWidgets(2));
});
testWidgets('shows single mail view button position section', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(
find.text('Single mail view button position'),
findsOneWidget,
);
});
testWidgets('menu position bottom option is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final menuGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
expect(menuGroup.groupValue, MenuPosition.bottom);
});
testWidgets('mail view button position bottom is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final mailViewGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
expect(mailViewGroup.groupValue, MenuPosition.bottom);
});
testWidgets('tapping Top in menu position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').first);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.menuPosition, MenuPosition.top);
});
testWidgets(
'tapping Top in mail view button position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').last);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top);
});
testWidgets('shows after mail action section', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
// Scroll down to reveal the new section below the fold.
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(find.text('After mail action'), findsOneWidget);
expect(find.text('Next message (default)'), findsOneWidget);
expect(find.text('Return to mailbox'), findsOneWidget);
});
testWidgets('after mail action next message is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
final group =
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
expect(group.groupValue, AfterMailViewAction.nextMessage);
});
testWidgets('tapping Return to mailbox updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
await tester.tap(find.text('Return to mailbox'));
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
});
});
}