Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 ba1f324831 fix(agent-loop): replace tea with fgj for CI run queries
tea api returns HTML 504 pages with exit 0, causing JSONDecodeError.
fgj actions run list is more reliable and consistent with the rest of
the script. Adapted PR-event matching to use prettyref="#N" since fgj
does not expose event_payload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:24:17 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 529fb56cf8 fix: add explicit note that app settings are never uploaded (#280)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:22:53 +02:00
37 changed files with 254 additions and 1509 deletions
-48
View File
@@ -109,51 +109,3 @@ jobs:
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
merge-renovate:
name: Auto-merge Renovate PR
needs: [check]
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Merge if automerge label is set
env:
FORGEJO_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
python3 - << 'PYEOF'
import os, json, urllib.request, urllib.error, sys
token = os.environ["FORGEJO_TOKEN"]
url_base = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
pr_number = os.environ["PR_NUMBER"]
api = f"{url_base}/api/v1/repos/{repo}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
req = urllib.request.Request(f"{api}/issues/{pr_number}/labels", headers=headers)
with urllib.request.urlopen(req) as r:
labels = [l["name"] for l in json.loads(r.read())]
if "automerge" not in labels:
print(f"PR #{pr_number}: no 'automerge' label — major update, skipping")
sys.exit(0)
body = json.dumps({"Do": "merge"}).encode()
req = urllib.request.Request(
f"{api}/pulls/{pr_number}/merge",
data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req) as r:
print(f"PR #{pr_number} merged successfully")
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already been merged" in err or "has been merged" in err:
print(f"PR #{pr_number} already merged — OK")
else:
print(f"Merge failed: {err}")
sys.exit(1)
PYEOF
+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 folders'));
await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sent'));
await tester.pumpAndSettle();
@@ -331,7 +331,7 @@ void main() {
expect(find.text(subject), findsOneWidget);
// ── Check Inbox ────────────────────────────────────────────────────────
await tester.tap(find.byTooltip('Open folders'));
await tester.tap(find.byTooltip('Open navigation menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('INBOX'));
await tester.pumpAndSettle();
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 34;
const int dbSchemaVersion = 33;
-6
View File
@@ -1,6 +0,0 @@
enum MenuPosition { bottom, top }
class UserPreferences {
const UserPreferences({this.menuPosition = MenuPosition.bottom});
final MenuPosition menuPosition;
}
@@ -11,13 +11,4 @@ abstract class MailboxRepository {
/// Deletes all locally-cached mailbox rows for [accountId].
Future<void> clearForResync(String accountId);
/// Creates a new mailbox named [name] for [accountId] and tags it with
/// [role] in the local database. For JMAP accounts the role is also sent
/// to the server. Returns the newly created [Mailbox].
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
);
}
@@ -1,6 +0,0 @@
import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position);
}
-15
View File
@@ -307,17 +307,6 @@ class LocalSieveApplied extends Table {
Set<Column> get primaryKey => {accountId, messageId};
}
/// App-wide user preferences, stored as a singleton row (id always 1).
@DataClassName('UserPreferencesRow')
class UserPreferences extends Table {
IntColumn get id => integer()();
// 'bottom' (default) | 'top'
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
@override
Set<Column> get primaryKey => {id};
}
// ── Database ──────────────────────────────────────────────────────────────────
@DriftDatabase(
@@ -338,7 +327,6 @@ class UserPreferences extends Table {
LocalSieveScripts,
LocalSieveApplied,
ShareKeys,
UserPreferences,
],
)
class AppDatabase extends _$AppDatabase {
@@ -590,9 +578,6 @@ class AppDatabase extends _$AppDatabase {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
if (from < 34) {
await m.createTable(userPreferences);
}
},
);
}
@@ -79,14 +79,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
);
try {
final mailboxes = await client.listMailboxes(recursive: true);
// Pre-load existing DB roles so we can preserve manually-set roles for
// folders the server doesn't tag with a special-use attribute.
final existingRows = await (_db.select(_db.mailboxes)
..where((t) => t.accountId.equals(account.id)))
.get();
final existingRoles = {for (final r in existingRows) r.id: r.role};
for (final mb in mailboxes) {
final path = mb.path;
final id = '${account.id}:$path';
@@ -104,12 +96,6 @@ class MailboxRepositoryImpl implements MailboxRepository {
log('STATUS skipped for $path: $e');
}
// Use the server-assigned role when available; fall back to the
// existing DB role so that manually-created folders (e.g. a user
// who just created their Archive folder) keep their role across syncs
// when the IMAP server does not expose a special-use attribute.
final role = _imapRole(mb) ?? existingRoles[id];
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
@@ -118,7 +104,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
name: mb.name,
unreadCount: Value(unread),
totalCount: Value(total),
role: Value(role),
role: Value(_imapRole(mb)),
),
);
}
@@ -324,104 +310,4 @@ class MailboxRepositoryImpl implements MailboxRepository {
..where((t) => t.accountId.equals(accountId)))
.go();
}
@override
Future<model.Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final account = (await _accounts.getAccount(accountId))!;
final password = await _accounts.getPassword(accountId);
switch (account.type) {
case account_model.AccountType.imap:
return _createMailboxWithRoleImap(account, password, name, role);
case account_model.AccountType.jmap:
return _createMailboxWithRoleJmap(account, password, name, role);
}
}
Future<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String role,
) async {
final client = await _imapConnect(
account,
_effectiveUsername(account),
password,
);
try {
await client.createMailbox(name);
} finally {
await client.logout();
}
final id = '${account.id}:$name';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: id,
accountId: account.id,
path: name,
name: name,
role: Value(role),
),
);
final row = await (_db.select(_db.mailboxes)..where((t) => t.id.equals(id)))
.getSingle();
return _toModel(row);
}
Future<model.Mailbox> _createMailboxWithRoleJmap(
account_model.Account account,
String password,
String name,
String role,
) async {
final jmapUrl = account.jmapUrl;
if (jmapUrl == null || jmapUrl.isEmpty) {
throw Exception('JMAP account ${account.id} has no jmapUrl');
}
final jmap = await JmapClient.connect(
httpClient: _httpClient,
jmapUrl: Uri.parse(jmapUrl),
username: _effectiveUsername(account),
password: password,
);
final responses = await jmap.call([
[
'Mailbox/set',
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
],
]);
final result = _responseArgs(responses, 0, 'Mailbox/set');
final created = result['created'] as Map<String, dynamic>?;
final newId =
(created?['new-mailbox'] as Map<String, dynamic>?)?['id'] as String?;
if (newId == null) {
throw Exception(
'Failed to create mailbox "$name": server returned no ID',
);
}
final dbId = '${account.id}:$newId';
await _db.into(_db.mailboxes).insertOnConflictUpdate(
MailboxesCompanion.insert(
id: dbId,
accountId: account.id,
path: newId,
name: name,
role: Value(role),
),
);
final row = await (_db.select(_db.mailboxes)
..where((t) => t.id.equals(dbId)))
.getSingle();
return _toModel(row);
}
}
@@ -1,38 +0,0 @@
import 'package:drift/drift.dart';
import 'package:sharedinbox/core/models/user_preferences.dart' as pref;
import 'package:sharedinbox/core/repositories/user_preferences_repository.dart';
import 'package:sharedinbox/data/db/database.dart';
class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
UserPreferencesRepositoryImpl(this._db);
final AppDatabase _db;
static const _rowId = 1;
@override
Stream<pref.UserPreferences> observePreferences() {
return (_db.select(_db.userPreferences)..where((t) => t.id.equals(_rowId)))
.watchSingleOrNull()
.map(_rowToModel);
}
@override
Future<void> updateMenuPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
menuPosition: Value(position.name),
),
);
}
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,
),
);
}
}
+1 -15
View File
@@ -5,7 +5,6 @@ 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';
@@ -14,7 +13,6 @@ 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';
@@ -23,8 +21,7 @@ import 'package:sharedinbox/core/services/undo_service.dart';
import 'package:sharedinbox/core/storage/secure_storage.dart';
import 'package:sharedinbox/core/sync/account_sync_manager.dart';
import 'package:sharedinbox/core/sync/reliability_runner.dart';
import 'package:sharedinbox/data/db/database.dart'
hide Email, EmailBody, UserPreferences;
import 'package:sharedinbox/data/db/database.dart' hide Email, EmailBody;
import 'package:sharedinbox/data/db/local_sieve_repository.dart';
import 'package:sharedinbox/data/imap/imap_client_factory.dart';
import 'package:sharedinbox/data/jmap/sieve_repository.dart';
@@ -36,7 +33,6 @@ 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.
@@ -231,13 +227,3 @@ final accountConnectionStatusProvider =
.read(connectionTestServiceProvider)
.testConnection(account, password);
});
final userPreferencesRepositoryProvider =
Provider<UserPreferencesRepository>((ref) {
return UserPreferencesRepositoryImpl(ref.watch(dbProvider));
});
final userPreferencesProvider =
StreamProvider.autoDispose<UserPreferences>((ref) {
return ref.watch(userPreferencesRepositoryProvider).observePreferences();
});
-5
View File
@@ -20,7 +20,6 @@ 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(
@@ -57,10 +56,6 @@ 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(
+2 -40
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -67,14 +66,6 @@ class AccountListScreen extends ConsumerWidget {
unawaited(context.push('/accounts/about'));
},
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Preferences'),
onTap: () {
Navigator.pop(context); // Close drawer
unawaited(context.push('/accounts/preferences'));
},
),
],
),
),
@@ -133,6 +124,7 @@ class _AccountTile extends ConsumerWidget {
if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Sync health: '),
Icon(
@@ -141,13 +133,7 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange,
),
const SizedBox(width: 4),
Flexible(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
Text(' ($date)', style: const TextStyle(fontSize: 10)),
],
);
@@ -307,30 +293,6 @@ class _AccountTile extends ConsumerWidget {
}
}
String _formatDiscrepancies(String? summary) {
if (summary == null) return 'Discrepancies found';
try {
final decoded = jsonDecode(summary) as Map<String, dynamic>;
var missingLocally = 0;
var missingOnServer = 0;
var flagMismatches = 0;
for (final v in decoded.values) {
final m = v as Map<String, dynamic>;
missingLocally += (m['missingLocally'] as int? ?? 0);
missingOnServer += (m['missingOnServer'] as int? ?? 0);
flagMismatches += (m['flagMismatches'] as int? ?? 0);
}
final parts = <String>[];
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
if (parts.isEmpty) return 'Discrepancies found';
return 'Discrepancies found (${parts.join(', ')})';
} catch (_) {
return 'Discrepancies found';
}
}
class _OnboardingView extends StatelessWidget {
const _OnboardingView();
-79
View File
@@ -1,79 +0,0 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
enum _MissingFolderChoice { chooseExisting, createNew }
/// Resolves a mailbox by role, prompting the user to choose or create one when
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
Future<Mailbox?> resolveMailboxByRole(
BuildContext context,
MailboxRepository mailboxRepo,
String accountId,
String currentMailboxPath,
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
if (!context.mounted) return null;
if (mailbox != null) return mailbox;
final choice = await showDialog<_MissingFolderChoice>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(dialogTitle),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
child: const Text('Choose existing folder'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
child: Text('Create "$createFolderName"'),
),
],
),
);
if (!context.mounted || choice == null) return null;
switch (choice) {
case _MissingFolderChoice.chooseExisting:
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
if (!context.mounted) return null;
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
shrinkWrap: true,
children: [
const ListTile(
title: Text(
'Move to…',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
for (final m
in mailboxes.where((m) => m.path != currentMailboxPath))
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(m.name),
onTap: () => Navigator.pop(ctx, m.path),
),
],
),
);
if (chosen == null || !context.mounted) return null;
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
case _MissingFolderChoice.createNew:
mailbox = await mailboxRepo.createMailboxWithRole(
accountId,
createFolderName,
role,
);
if (!context.mounted) return null;
}
return mailbox;
}
+50 -96
View File
@@ -16,7 +16,6 @@ import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/format_utils.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -86,12 +85,42 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
},
),
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
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(_archive(context, header));
unawaited(_markAsSpam(context, header));
},
),
IconButton(
@@ -119,43 +148,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (context.mounted) context.pop();
},
),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
color: _isFlagged ? Colors.amber : null,
),
tooltip: _isFlagged ? 'Unflag' : 'Flag',
onPressed: () async {
final next = !_isFlagged;
await repo.setFlag(widget.emailId, flagged: next);
if (mounted) setState(() => _isFlagged = next);
},
),
PopupMenuButton<String>(
itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuItem(
value: 'headers',
child: Text('Show Mail Headers'),
@@ -169,11 +163,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
child: Text('Show Raw Email'),
),
],
onSelected: (value) async {
if (value == 'mark_unread') {
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop();
} else if (value == 'headers' && body != null) {
onSelected: (value) {
if (value == 'headers' && body != null) {
_showHeaders(context, body);
} else if (value == 'structure' && body != null) {
_showStructure(context, body);
@@ -402,55 +393,21 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<void> _archive(BuildContext context, Email header) async {
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
if (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) context.pop();
}
Future<void> _markAsSpam(BuildContext context, Email header) async {
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
header.accountId,
header.mailboxPath,
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
final mailboxRepo = ref.read(mailboxRepositoryProvider);
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk');
if (mailbox == null || !context.mounted) return;
if (junk == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No Junk folder found')),
);
return;
}
await ref
.read(emailRepositoryProvider)
.moveEmail(widget.emailId, mailbox.path);
.moveEmail(widget.emailId, junk.path);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
@@ -460,7 +417,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: mailbox.path,
destinationMailboxPath: junk.path,
),
),
);
@@ -938,13 +895,10 @@ class _UnsubscribeChip extends StatelessWidget {
Widget build(BuildContext context) {
final uri = _parseUnsubscribeUri(header);
if (uri == null) return const SizedBox.shrink();
return Tooltip(
message: uri.toString(),
child: ActionChip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
),
return ActionChip(
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
label: const Text('Unsubscribe'),
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
);
}
}
+24 -57
View File
@@ -8,10 +8,8 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
@@ -149,21 +147,16 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
Widget build(BuildContext context) {
final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold(
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
appBar: _buildAppBar(repo, accountAsync),
drawer: _selecting
? null
: FolderDrawer(
accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: _selecting
? _selectionBottomBar()
: (menuAtBottom ? _folderNavBottomBar() : null),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: Column(
children: [
_buildSyncErrorBanner(),
@@ -179,14 +172,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
PreferredSizeWidget _buildAppBar(
EmailRepository emailRepo,
AsyncValue<Account?> accountAsync, {
required bool menuAtBottom,
}) {
AsyncValue<Account?> accountAsync,
) {
final selectionCount =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar(
automaticallyImplyLeading: !menuAtBottom,
leading: _selecting
? IconButton(
icon: const Icon(Icons.close),
@@ -309,22 +300,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
Widget _folderNavBottomBar() {
return BottomAppBar(
child: Row(
children: [
Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: 'Open folders',
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
],
),
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
@@ -445,26 +420,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
Future<void> _batchMoveToRole(
String role, {
required String dialogTitle,
required String createFolderName,
}) async {
Future<void> _batchMoveToRole(String role, String notFoundMessage) async {
final ids = _selectedEmailIds;
_clearSelection();
final mailbox = await resolveMailboxByRole(
context,
ref.read(mailboxRepositoryProvider),
widget.accountId,
widget.mailboxPath,
role,
dialogTitle: dialogTitle,
createFolderName: createFolderName,
);
if (!mounted || mailbox == null) return;
final mailbox = await ref
.read(mailboxRepositoryProvider)
.findMailboxByRole(widget.accountId, role);
if (!mounted) return;
if (mailbox == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
duration: const Duration(seconds: 5),
content: Text(notFoundMessage),
),
);
return;
}
final repo = ref.read(emailRepositoryProvider);
// Fetch full email data before moving so we can restore them if user clicks Undo.
@@ -490,11 +463,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
}
Future<void> _batchArchive() => _batchMoveToRole(
'archive',
dialogTitle: 'No archive folder found',
createFolderName: 'Archive',
);
Future<void> _batchArchive() =>
_batchMoveToRole('archive', 'No archive folder found');
Future<void> _refreshSearchAndPopIfEmpty() async {
if (!mounted || !_searching) return;
@@ -573,11 +543,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
}
Future<void> _batchMarkSpam() => _batchMoveToRole(
'junk',
dialogTitle: 'No spam folder found',
createFolderName: 'Junk',
);
Future<void> _batchMarkSpam() =>
_batchMoveToRole('junk', 'No spam folder found');
Future<void> _batchMove() async {
final ids = _selectedEmailIds;
-18
View File
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
@@ -18,12 +17,8 @@ class MailboxListScreen extends ConsumerWidget {
final mailboxRepo = ref.watch(mailboxRepositoryProvider);
final emailRepo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(accountId));
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !menuAtBottom,
title: const Text('Folders'),
actions: [
IconButton(
@@ -47,19 +42,6 @@ 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 ───────────────────────────────────────
@@ -1,67 +0,0 @@
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,
),
],
),
),
],
),
),
);
}
}
+2 -5
View File
@@ -31,13 +31,10 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
<meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="$csp">
<style>
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
img { max-width: 100%; height: auto; }
a { color: #1976D2; }
* { box-sizing: border-box; max-width: 100%; }
table { width: 100%; border-collapse: collapse; }
td, th { overflow-wrap: break-word; word-break: break-word; }
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
* { box-sizing: border-box; }
</style>
</head>
<body>
+1 -7
View File
@@ -6,11 +6,5 @@
"labels": ["dependencies"],
"github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"]
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest", "lockFileMaintenance"],
"addLabels": ["automerge"]
}
]
}
}
-5
View File
@@ -20,9 +20,7 @@ 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',
};
@@ -60,7 +58,6 @@ 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',
@@ -75,8 +72,6 @@ const _excluded = {
'lib/data/repositories/sync_log_repository_impl.dart',
'lib/data/repositories/undo_repository_impl.dart',
'lib/data/repositories/search_history_repository_impl.dart',
'lib/data/repositories/user_preferences_repository_impl.dart',
'lib/ui/screens/user_preferences_screen.dart',
'lib/core/services/update_service.dart',
};
@@ -149,22 +149,6 @@ class _FakeMailboxes implements MailboxRepository {
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-15
View File
@@ -224,21 +224,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
Future<Mailbox?> findMailboxByRole(String id, String role) async => null;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
+157 -195
View File
@@ -3,16 +3,16 @@
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i5;
import 'dart:async' as _i4;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i7;
import 'package:sharedinbox/core/models/account.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i3;
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i4;
import 'package:mockito/src/dummies.dart' as _i6;
import 'package:sharedinbox/core/models/account.dart' as _i5;
import 'package:sharedinbox/core/models/email.dart' as _i2;
import 'package:sharedinbox/core/models/mailbox.dart' as _i8;
import 'package:sharedinbox/core/repositories/account_repository.dart' as _i3;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i9;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i7;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
@@ -29,8 +29,8 @@ import 'package:sharedinbox/core/repositories/mailbox_repository.dart' as _i8;
// ignore_for_file: subtype_of_sealed_class
// ignore_for_file: invalid_use_of_internal_member
class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
_FakeMailbox_0(
class _FakeEmailBody_0 extends _i1.SmartFake implements _i2.EmailBody {
_FakeEmailBody_0(
Object parent,
Invocation parentInvocation,
) : super(
@@ -39,8 +39,9 @@ class _FakeMailbox_0 extends _i1.SmartFake implements _i2.Mailbox {
);
}
class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
_FakeEmailBody_1(
class _FakeSyncEmailsResult_1 extends _i1.SmartFake
implements _i2.SyncEmailsResult {
_FakeSyncEmailsResult_1(
Object parent,
Invocation parentInvocation,
) : super(
@@ -49,20 +50,9 @@ class _FakeEmailBody_1 extends _i1.SmartFake implements _i3.EmailBody {
);
}
class _FakeSyncEmailsResult_2 extends _i1.SmartFake
implements _i3.SyncEmailsResult {
_FakeSyncEmailsResult_2(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
class _FakeReliabilityResult_3 extends _i1.SmartFake
implements _i3.ReliabilityResult {
_FakeReliabilityResult_3(
class _FakeReliabilityResult_2 extends _i1.SmartFake
implements _i2.ReliabilityResult {
_FakeReliabilityResult_2(
Object parent,
Invocation parentInvocation,
) : super(
@@ -74,32 +64,32 @@ class _FakeReliabilityResult_3 extends _i1.SmartFake
/// A class which mocks [AccountRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
class MockAccountRepository extends _i1.Mock implements _i3.AccountRepository {
MockAccountRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i5.Stream<List<_i6.Account>> observeAccounts() => (super.noSuchMethod(
_i4.Stream<List<_i5.Account>> observeAccounts() => (super.noSuchMethod(
Invocation.method(
#observeAccounts,
[],
),
returnValue: _i5.Stream<List<_i6.Account>>.empty(),
) as _i5.Stream<List<_i6.Account>>);
returnValue: _i4.Stream<List<_i5.Account>>.empty(),
) as _i4.Stream<List<_i5.Account>>);
@override
_i5.Future<_i6.Account?> getAccount(String? id) => (super.noSuchMethod(
_i4.Future<_i5.Account?> getAccount(String? id) => (super.noSuchMethod(
Invocation.method(
#getAccount,
[id],
),
returnValue: _i5.Future<_i6.Account?>.value(),
) as _i5.Future<_i6.Account?>);
returnValue: _i4.Future<_i5.Account?>.value(),
) as _i4.Future<_i5.Account?>);
@override
_i5.Future<void> addAccount(
_i6.Account? account,
_i4.Future<void> addAccount(
_i5.Account? account,
String? password,
) =>
(super.noSuchMethod(
@@ -110,13 +100,13 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
password,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> updateAccount(
_i6.Account? account, {
_i4.Future<void> updateAccount(
_i5.Account? account, {
String? password,
}) =>
(super.noSuchMethod(
@@ -125,65 +115,65 @@ class MockAccountRepository extends _i1.Mock implements _i4.AccountRepository {
[account],
{#password: password},
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> removeAccount(String? id) => (super.noSuchMethod(
_i4.Future<void> removeAccount(String? id) => (super.noSuchMethod(
Invocation.method(
#removeAccount,
[id],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
_i4.Future<String> getPassword(String? accountId) => (super.noSuchMethod(
Invocation.method(
#getPassword,
[accountId],
),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this,
Invocation.method(
#getPassword,
[accountId],
),
)),
) as _i5.Future<String>);
) as _i4.Future<String>);
}
/// A class which mocks [MailboxRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
class MockMailboxRepository extends _i1.Mock implements _i7.MailboxRepository {
MockMailboxRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i5.Stream<List<_i2.Mailbox>> observeMailboxes(String? accountId) =>
_i4.Stream<List<_i8.Mailbox>> observeMailboxes(String? accountId) =>
(super.noSuchMethod(
Invocation.method(
#observeMailboxes,
[accountId],
),
returnValue: _i5.Stream<List<_i2.Mailbox>>.empty(),
) as _i5.Stream<List<_i2.Mailbox>>);
returnValue: _i4.Stream<List<_i8.Mailbox>>.empty(),
) as _i4.Stream<List<_i8.Mailbox>>);
@override
_i5.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
_i4.Future<int> syncMailboxes(String? accountId) => (super.noSuchMethod(
Invocation.method(
#syncMailboxes,
[accountId],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Future<_i2.Mailbox?> findMailboxByRole(
_i4.Future<_i8.Mailbox?> findMailboxByRole(
String? accountId,
String? role,
) =>
@@ -195,46 +185,18 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
role,
],
),
returnValue: _i5.Future<_i2.Mailbox?>.value(),
) as _i5.Future<_i2.Mailbox?>);
returnValue: _i4.Future<_i8.Mailbox?>.value(),
) as _i4.Future<_i8.Mailbox?>);
@override
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
_i5.Future<_i2.Mailbox> createMailboxWithRole(
String? accountId,
String? name,
String? role,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailboxWithRole,
[
accountId,
name,
role,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
}
/// A class which mocks [EmailRepository].
@@ -246,13 +208,13 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
}
@override
_i5.Stream<String> get onChangesQueued => (super.noSuchMethod(
_i4.Stream<String> get onChangesQueued => (super.noSuchMethod(
Invocation.getter(#onChangesQueued),
returnValue: _i5.Stream<String>.empty(),
) as _i5.Stream<String>);
returnValue: _i4.Stream<String>.empty(),
) as _i4.Stream<String>);
@override
_i5.Stream<List<_i3.Email>> observeEmails(
_i4.Stream<List<_i2.Email>> observeEmails(
String? accountId,
String? mailboxPath, {
int? limit = 50,
@@ -266,11 +228,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
{#limit: limit},
),
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>);
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@override
_i5.Stream<List<_i3.EmailThread>> observeThreads(
_i4.Stream<List<_i2.EmailThread>> observeThreads(
String? accountId,
String? mailboxPath, {
int? limit = 50,
@@ -284,11 +246,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
{#limit: limit},
),
returnValue: _i5.Stream<List<_i3.EmailThread>>.empty(),
) as _i5.Stream<List<_i3.EmailThread>>);
returnValue: _i4.Stream<List<_i2.EmailThread>>.empty(),
) as _i4.Stream<List<_i2.EmailThread>>);
@override
_i5.Stream<List<_i3.Email>> observeEmailsInThread(
_i4.Stream<List<_i2.Email>> observeEmailsInThread(
String? accountId,
String? mailboxPath,
String? threadId,
@@ -302,36 +264,36 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
threadId,
],
),
returnValue: _i5.Stream<List<_i3.Email>>.empty(),
) as _i5.Stream<List<_i3.Email>>);
returnValue: _i4.Stream<List<_i2.Email>>.empty(),
) as _i4.Stream<List<_i2.Email>>);
@override
_i5.Future<_i3.Email?> getEmail(String? emailId) => (super.noSuchMethod(
_i4.Future<_i2.Email?> getEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#getEmail,
[emailId],
),
returnValue: _i5.Future<_i3.Email?>.value(),
) as _i5.Future<_i3.Email?>);
returnValue: _i4.Future<_i2.Email?>.value(),
) as _i4.Future<_i2.Email?>);
@override
_i5.Future<_i3.EmailBody> getEmailBody(String? emailId) =>
_i4.Future<_i2.EmailBody> getEmailBody(String? emailId) =>
(super.noSuchMethod(
Invocation.method(
#getEmailBody,
[emailId],
),
returnValue: _i5.Future<_i3.EmailBody>.value(_FakeEmailBody_1(
returnValue: _i4.Future<_i2.EmailBody>.value(_FakeEmailBody_0(
this,
Invocation.method(
#getEmailBody,
[emailId],
),
)),
) as _i5.Future<_i3.EmailBody>);
) as _i4.Future<_i2.EmailBody>);
@override
_i5.Future<_i3.SyncEmailsResult> syncEmails(
_i4.Future<_i2.SyncEmailsResult> syncEmails(
String? accountId,
String? mailboxPath,
) =>
@@ -344,7 +306,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
returnValue:
_i5.Future<_i3.SyncEmailsResult>.value(_FakeSyncEmailsResult_2(
_i4.Future<_i2.SyncEmailsResult>.value(_FakeSyncEmailsResult_1(
this,
Invocation.method(
#syncEmails,
@@ -354,10 +316,10 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i5.Future<_i3.SyncEmailsResult>);
) as _i4.Future<_i2.SyncEmailsResult>);
@override
_i5.Future<void> setFlag(
_i4.Future<void> setFlag(
String? emailId, {
bool? seen,
bool? flagged,
@@ -371,12 +333,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
#flagged: flagged,
},
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> markAllAsRead(
_i4.Future<void> markAllAsRead(
String? accountId,
String? mailboxPath,
) =>
@@ -388,12 +350,12 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
mailboxPath,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> moveEmail(
_i4.Future<void> moveEmail(
String? emailId,
String? destMailboxPath,
) =>
@@ -405,23 +367,23 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
destMailboxPath,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
_i4.Future<String?> deleteEmail(String? emailId) => (super.noSuchMethod(
Invocation.method(
#deleteEmail,
[emailId],
),
returnValue: _i5.Future<String?>.value(),
) as _i5.Future<String?>);
returnValue: _i4.Future<String?>.value(),
) as _i4.Future<String?>);
@override
_i5.Future<void> sendEmail(
_i4.Future<void> sendEmail(
String? accountId,
_i3.EmailDraft? draft,
_i2.EmailDraft? draft,
) =>
(super.noSuchMethod(
Invocation.method(
@@ -431,14 +393,14 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
draft,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<String> downloadAttachment(
_i4.Future<String> downloadAttachment(
String? emailId,
_i3.EmailAttachment? attachment,
_i2.EmailAttachment? attachment,
) =>
(super.noSuchMethod(
Invocation.method(
@@ -448,7 +410,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
attachment,
],
),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this,
Invocation.method(
#downloadAttachment,
@@ -458,25 +420,25 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i5.Future<String>);
) as _i4.Future<String>);
@override
_i5.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
_i4.Future<String> fetchRawRfc822(String? emailId) => (super.noSuchMethod(
Invocation.method(
#fetchRawRfc822,
[emailId],
),
returnValue: _i5.Future<String>.value(_i7.dummyValue<String>(
returnValue: _i4.Future<String>.value(_i6.dummyValue<String>(
this,
Invocation.method(
#fetchRawRfc822,
[emailId],
),
)),
) as _i5.Future<String>);
) as _i4.Future<String>);
@override
_i5.Future<List<_i3.Email>> searchEmails(
_i4.Future<List<_i2.Email>> searchEmails(
String? accountId,
String? mailboxPath,
String? query,
@@ -490,11 +452,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i5.Future<List<_i3.Email>> searchEmailsGlobal(
_i4.Future<List<_i2.Email>> searchEmailsGlobal(
String? accountId,
String? query,
) =>
@@ -506,11 +468,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
query,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i5.Future<List<_i3.Email>> getEmailsByAddress(
_i4.Future<List<_i2.Email>> getEmailsByAddress(
String? accountId,
String? address,
) =>
@@ -522,11 +484,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
address,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i5.Future<List<_i3.EmailAddress>> searchAddresses(
_i4.Future<List<_i2.EmailAddress>> searchAddresses(
String? accountId,
String? query, {
int? limit = 10,
@@ -541,11 +503,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
{#limit: limit},
),
returnValue:
_i5.Future<List<_i3.EmailAddress>>.value(<_i3.EmailAddress>[]),
) as _i5.Future<List<_i3.EmailAddress>>);
_i4.Future<List<_i2.EmailAddress>>.value(<_i2.EmailAddress>[]),
) as _i4.Future<List<_i2.EmailAddress>>);
@override
_i5.Future<int> flushPendingChanges(
_i4.Future<int> flushPendingChanges(
String? accountId,
String? password,
) =>
@@ -557,42 +519,42 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password,
],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Stream<List<_i3.FailedMutation>> observeFailedMutations(
_i4.Stream<List<_i2.FailedMutation>> observeFailedMutations(
String? accountId) =>
(super.noSuchMethod(
Invocation.method(
#observeFailedMutations,
[accountId],
),
returnValue: _i5.Stream<List<_i3.FailedMutation>>.empty(),
) as _i5.Stream<List<_i3.FailedMutation>>);
returnValue: _i4.Stream<List<_i2.FailedMutation>>.empty(),
) as _i4.Stream<List<_i2.FailedMutation>>);
@override
_i5.Future<void> discardMutation(int? id) => (super.noSuchMethod(
_i4.Future<void> discardMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#discardMutation,
[id],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<void> retryMutation(int? id) => (super.noSuchMethod(
_i4.Future<void> retryMutation(int? id) => (super.noSuchMethod(
Invocation.method(
#retryMutation,
[id],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<bool> cancelPendingChange(
_i4.Future<bool> cancelPendingChange(
String? emailId,
String? changeType,
) =>
@@ -604,11 +566,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
changeType,
],
),
returnValue: _i5.Future<bool>.value(false),
) as _i5.Future<bool>);
returnValue: _i4.Future<bool>.value(false),
) as _i4.Future<bool>);
@override
_i5.Future<void> snoozeEmail(
_i4.Future<void> snoozeEmail(
String? emailId,
DateTime? until,
) =>
@@ -620,32 +582,32 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
until,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
_i4.Future<int> wakeUpEmails(String? accountId) => (super.noSuchMethod(
Invocation.method(
#wakeUpEmails,
[accountId],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Future<void> restoreEmails(List<_i3.Email>? emails) =>
_i4.Future<void> restoreEmails(List<_i2.Email>? emails) =>
(super.noSuchMethod(
Invocation.method(
#restoreEmails,
[emails],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
@override
_i5.Future<_i3.Email?> findEmailByMessageId(
_i4.Future<_i2.Email?> findEmailByMessageId(
String? accountId,
String? messageId,
) =>
@@ -657,20 +619,20 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
messageId,
],
),
returnValue: _i5.Future<_i3.Email?>.value(),
) as _i5.Future<_i3.Email?>);
returnValue: _i4.Future<_i2.Email?>.value(),
) as _i4.Future<_i2.Email?>);
@override
_i5.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
_i4.Future<int> applySieveRules(String? accountId) => (super.noSuchMethod(
Invocation.method(
#applySieveRules,
[accountId],
),
returnValue: _i5.Future<int>.value(0),
) as _i5.Future<int>);
returnValue: _i4.Future<int>.value(0),
) as _i4.Future<int>);
@override
_i5.Stream<void> watchJmapPush(
_i4.Stream<void> watchJmapPush(
String? accountId,
String? password,
) =>
@@ -682,11 +644,11 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
password,
],
),
returnValue: _i5.Stream<void>.empty(),
) as _i5.Stream<void>);
returnValue: _i4.Stream<void>.empty(),
) as _i4.Stream<void>);
@override
_i5.Future<_i3.ReliabilityResult> verifySyncReliability(
_i4.Future<_i2.ReliabilityResult> verifySyncReliability(
String? accountId,
String? mailboxPath,
) =>
@@ -699,7 +661,7 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
returnValue:
_i5.Future<_i3.ReliabilityResult>.value(_FakeReliabilityResult_3(
_i4.Future<_i2.ReliabilityResult>.value(_FakeReliabilityResult_2(
this,
Invocation.method(
#verifySyncReliability,
@@ -709,15 +671,15 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
],
),
)),
) as _i5.Future<_i3.ReliabilityResult>);
) as _i4.Future<_i2.ReliabilityResult>);
@override
_i5.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
_i4.Future<void> clearForResync(String? accountId) => (super.noSuchMethod(
Invocation.method(
#clearForResync,
[accountId],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
returnValue: _i4.Future<void>.value(),
returnValueForMissingStub: _i4.Future<void>.value(),
) as _i4.Future<void>);
}
-173
View File
@@ -14,7 +14,6 @@ import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
import 'account_repository_impl_test.dart' show MapSecureStorage;
import 'db_test_helper.dart';
import 'fake_imap.dart' show SnoozeSpyImapClient;
// ── Helpers ───────────────────────────────────────────────────────────────────
const _account = Account(
@@ -433,177 +432,5 @@ void main() {
expect(result, isNotNull);
expect(result!.role, 'inbox');
});
group('createMailboxWithRole', () {
test('IMAP: creates mailbox on server and persists with role', () async {
final spy = SnoozeSpyImapClient();
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => spy,
);
await accounts.addAccount(_account, 'pw');
final result = await mailboxes.createMailboxWithRole(
'acc-1',
'Archive',
'archive',
);
expect(spy.createdMailbox, 'Archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'Archive');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test('JMAP: creates mailbox on server and persists with role', () async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': {
'new-mailbox': {'id': 'mbx-archive'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
final result = await r.mailboxes
.createMailboxWithRole('jmap-1', 'Archive', 'archive');
expect(result.name, 'Archive');
expect(result.role, 'archive');
expect(result.path, 'mbx-archive');
final found = await r.mailboxes.findMailboxByRole('jmap-1', 'archive');
expect(found, isNotNull);
expect(found!.name, 'Archive');
});
test(
'JMAP: throws when server returns no created ID',
() async {
final r = _makeRepos(
httpClient: _mockJmap(
apiResponses: [
{
'sessionState': 'sess1',
'methodResponses': [
[
'Mailbox/set',
{
'accountId': 'acct1',
'created': null,
'notCreated': {
'new-mailbox': {'type': 'serverFail'},
},
},
'0',
],
],
},
],
),
);
await r.accounts.addAccount(_jmapAccount, 'pw');
await expectLater(
r.mailboxes.createMailboxWithRole('jmap-1', 'Archive', 'archive'),
throwsA(isA<Exception>()),
);
},
);
});
group('syncMailboxes IMAP preserves manually-set role', () {
test('existing role is kept when server returns no special-use flag',
() async {
final spy = SnoozeSpyImapClient();
// Make listMailboxes return a plain folder without \Archive.
final db = openTestDatabase();
final accounts = AccountRepositoryImpl(db, MapSecureStorage());
// Override listMailboxes to return one plain folder.
final fakeClient = _PlainArchiveImapClient();
final mailboxes = MailboxRepositoryImpl(
db,
accounts,
imapConnect: (_, __, ___) async => fakeClient,
);
await accounts.addAccount(_account, 'pw');
// Pre-seed the DB with role='archive' (as if user created the folder).
await db.into(db.mailboxes).insert(
MailboxesCompanion.insert(
id: 'acc-1:Archive',
accountId: 'acc-1',
path: 'Archive',
name: 'Archive',
role: const Value('archive'),
),
);
await mailboxes.syncMailboxes('acc-1');
final found = await mailboxes.findMailboxByRole('acc-1', 'archive');
expect(
found,
isNotNull,
reason: 'Manually-set role should be preserved after sync',
);
expect(found!.path, 'Archive');
// Suppress unused warning on spy.
expect(spy, isNotNull);
});
});
});
}
/// Fake IMAP client that lists one mailbox named 'Archive' without any
/// special-use flags, and logs out cleanly.
class _PlainArchiveImapClient extends SnoozeSpyImapClient {
@override
Future<List<imap.Mailbox>> listMailboxes({
String path = '""',
bool recursive = false,
List<String>? mailboxPatterns,
List<String>? selectionOptions,
List<imap.ReturnOption>? returnOptions,
}) async =>
[
imap.Mailbox(
encodedName: 'Archive',
encodedPath: 'Archive',
pathSeparator: '/',
flags: [], // No \Archive special-use flag
),
];
@override
Future<imap.Mailbox> statusMailbox(
imap.Mailbox mailbox,
List<imap.StatusFlags> flags,
) async =>
mailbox;
@override
Future<dynamic> logout() async {}
}
+2 -9
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 34);
expect(db.schemaVersion, 33);
await db.close();
});
@@ -199,9 +199,6 @@ void main() {
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
@@ -394,14 +391,11 @@ void main() {
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
// v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get();
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 34', () async {
test('fresh install creates all tables at schemaVersion 33', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -428,7 +422,6 @@ void main() {
'local_sieve_scripts', // v29
'share_keys', // v31
'local_sieve_applied', // v32
'user_preferences', // v34
]),
);
@@ -62,21 +62,6 @@ class _FakeMailboxes implements MailboxRepository {
null;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-15
View File
@@ -54,21 +54,6 @@ class _FakeMailboxes implements MailboxRepository {
null;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async =>
Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
}
class _CountingEmails implements EmailRepository {
-46
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
import 'helpers.dart';
@@ -207,50 +206,5 @@ 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);
},
);
});
}
+4 -110
View File
@@ -290,10 +290,11 @@ void main() {
);
});
testWidgets('Mark as spam shows dialog when no junk folder',
testWidgets(
'Mark as spam moves email to junk and shows snackbar when no junk folder',
(tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
// returns null → snackbar shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -311,76 +312,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);
});
testWidgets('Archive button is present in app bar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Archive',
),
findsOneWidget,
);
});
testWidgets('Archive shows dialog when no archive folder', (tester) async {
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
// returns null → dialog shown.
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Archive',
),
);
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
});
testWidgets('Mark as unread is in popup menu, not a standalone button',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
overrides: _overrides(
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
),
),
);
await tester.pumpAndSettle();
// No standalone icon button for mark as unread.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as unread',
),
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as unread'), findsOneWidget);
expect(find.text('No Junk folder found'), findsOneWidget);
});
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
@@ -475,44 +407,6 @@ 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 -147
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_detail_screen.dart';
import 'package:sharedinbox/ui/screens/email_list_screen.dart';
@@ -316,7 +315,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('INBOX'), findsOneWidget);
expect(find.byIcon(Icons.close), findsNothing);
expect(find.byType(BottomAppBar), findsNothing);
});
testWidgets('tapping clear icon in search bar clears results', (
@@ -632,150 +631,5 @@ void main() {
expect(find.text('This is the preview text'), findsOneWidget);
});
group('archive with missing folder', () {
testWidgets('shows dialog when archive folder is not found', (
tester,
) async {
final email = testEmail(subject: 'To archive');
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// No archive folder in the repo.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
// Enter selection mode and tap archive.
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
expect(find.text('No archive folder found'), findsOneWidget);
expect(find.text('Choose existing folder'), findsOneWidget);
expect(find.text('Create "Archive"'), findsOneWidget);
});
testWidgets('tapping Create creates the folder and moves emails', (
tester,
) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Create Archive".
await tester.tap(find.text('Create "Archive"'));
await tester.pumpAndSettle();
expect(movedTo, contains('Archive'));
});
testWidgets(
'tapping Choose existing opens folder picker and moves emails',
(tester) async {
final email = testEmail(subject: 'To archive');
final movedTo = <String>[];
final fakeEmailRepo = _SpyEmailRepository(
emails: [email],
onMove: (id, path) => movedTo.add(path),
);
const archiveFolder = Mailbox(
id: 'acc-1:OldArchive',
accountId: 'acc-1',
path: 'OldArchive',
name: 'OldArchive',
unreadCount: 0,
totalCount: 0,
);
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
// Repo has a folder but it has no 'archive' role.
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([archiveFolder]),
),
emailRepositoryProvider.overrideWithValue(fakeEmailRepo),
],
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('To archive'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.archive));
await tester.pumpAndSettle();
// Tap "Choose existing folder".
await tester.tap(find.text('Choose existing folder'));
await tester.pumpAndSettle();
// Bottom sheet with folder list appears.
expect(find.text('OldArchive'), findsOneWidget);
await tester.tap(find.text('OldArchive'));
await tester.pumpAndSettle();
expect(movedTo, contains('OldArchive'));
},
);
});
});
}
/// Email repository spy that records [moveEmail] calls.
class _SpyEmailRepository extends FakeEmailRepository {
_SpyEmailRepository({
super.emails,
required void Function(String emailId, String path) onMove,
}) : _onMove = onMove;
final void Function(String emailId, String path) _onMove;
@override
Future<void> moveEmail(String emailId, String destMailboxPath) async {
_onMove(emailId, destMailboxPath);
}
}
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

+6 -59
View File
@@ -14,7 +14,6 @@ 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';
@@ -22,12 +21,10 @@ 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';
@@ -41,7 +38,6 @@ 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
@@ -168,28 +164,8 @@ class FakeMailboxRepository implements MailboxRepository {
@override
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
_mailboxes.where((m) => m.role == role).firstOrNull;
@override
Future<void> clearForResync(String accountId) async {}
@override
Future<Mailbox> createMailboxWithRole(
String accountId,
String name,
String role,
) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
role: role,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
}
class FakeEmailRepository implements EmailRepository {
@@ -434,10 +410,6 @@ 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(
@@ -513,18 +485,16 @@ Widget buildApp({
return ProviderScope(
// Defaults come first so tests can override them via [overrides].
//
// 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.
// 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.
overrides: [
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
syncLogRepositoryProvider.overrideWithValue(
const NoOpSyncLogRepository(),
),
userPreferencesRepositoryProvider.overrideWithValue(
FakeUserPreferencesRepository(),
),
...overrides,
manageSieveProbeServiceProvider.overrideWith(
(ref) => _NoOpManageSieveProbeService(),
@@ -551,7 +521,6 @@ List<Override> baseOverrides({
Exception? connectionError,
ShareKeyRepository? shareKeyRepository,
bool hasStoredPassword = true,
SyncHealthRow? syncHealth,
}) =>
[
accountRepositoryProvider.overrideWithValue(
@@ -570,9 +539,6 @@ 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)),
];
// ---------------------------------------------------------------------------
@@ -602,7 +568,6 @@ Email testEmail({
bool isSeen = false,
bool isFlagged = false,
bool hasAttachment = false,
String? listUnsubscribeHeader,
}) =>
Email(
id: id,
@@ -618,26 +583,8 @@ Email testEmail({
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: hasAttachment,
listUnsubscribeHeader: listUnsubscribeHeader,
);
class FakeUserPreferencesRepository implements UserPreferencesRepository {
FakeUserPreferencesRepository({
this.menuPosition = MenuPosition.bottom,
});
MenuPosition menuPosition;
@override
Stream<UserPreferences> observePreferences() =>
Stream.value(UserPreferences(menuPosition: menuPosition));
@override
Future<void> updateMenuPosition(MenuPosition position) async {
menuPosition = position;
}
}
class FakeSearchHistoryRepository implements SearchHistoryRepository {
final List<String> _history = [];
@@ -41,20 +41,6 @@ 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
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/user_preferences_screen.dart';
import 'helpers.dart';
void main() {
group('UserPreferencesScreen', () {
testWidgets('shows both menu position options', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
expect(find.text('Menu bar position'), findsOneWidget);
expect(find.text('Bottom (default)'), findsOneWidget);
expect(find.text('Top'), findsOneWidget);
});
testWidgets('bottom option is selected by default', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroup = find.byType(RadioGroup<MenuPosition>);
final widget = tester.widget<RadioGroup<MenuPosition>>(radioGroup);
expect(widget.groupValue, MenuPosition.bottom);
});
testWidgets('tapping Top option updates the repo', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top'));
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.menuPosition, MenuPosition.top);
});
});
}