Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
100ca9d8a1 | ||
|
|
3d47af177a | ||
|
|
f6a37eaa16 | ||
|
|
156b040b92 | ||
|
|
e6c1288afe |
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -124,7 +125,6 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
if (h == null) return const Text('Sync health: Not verified yet');
|
if (h == null) return const Text('Sync health: Not verified yet');
|
||||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
const Text('Sync health: '),
|
const Text('Sync health: '),
|
||||||
Icon(
|
Icon(
|
||||||
@@ -133,7 +133,13 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
h.isHealthy
|
||||||
|
? 'Healthy'
|
||||||
|
: _formatDiscrepancies(h.discrepancySummary),
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -293,6 +299,30 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatDiscrepancies(String? summary) {
|
||||||
|
if (summary == null) return 'Discrepancies found';
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(summary) as Map<String, dynamic>;
|
||||||
|
var missingLocally = 0;
|
||||||
|
var missingOnServer = 0;
|
||||||
|
var flagMismatches = 0;
|
||||||
|
for (final v in decoded.values) {
|
||||||
|
final m = v as Map<String, dynamic>;
|
||||||
|
missingLocally += (m['missingLocally'] as int? ?? 0);
|
||||||
|
missingOnServer += (m['missingOnServer'] as int? ?? 0);
|
||||||
|
flagMismatches += (m['flagMismatches'] as int? ?? 0);
|
||||||
|
}
|
||||||
|
final parts = <String>[];
|
||||||
|
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
|
||||||
|
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
|
||||||
|
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
|
||||||
|
if (parts.isEmpty) return 'Discrepancies found';
|
||||||
|
return 'Discrepancies found (${parts.join(', ')})';
|
||||||
|
} catch (_) {
|
||||||
|
return 'Discrepancies found';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _OnboardingView extends StatelessWidget {
|
class _OnboardingView extends StatelessWidget {
|
||||||
const _OnboardingView();
|
const _OnboardingView();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
|
||||||
|
enum _MissingFolderChoice { chooseExisting, createNew }
|
||||||
|
|
||||||
|
/// Resolves a mailbox by role, prompting the user to choose or create one when
|
||||||
|
/// the role is not found. Returns the target [Mailbox], or null if cancelled.
|
||||||
|
Future<Mailbox?> resolveMailboxByRole(
|
||||||
|
BuildContext context,
|
||||||
|
MailboxRepository mailboxRepo,
|
||||||
|
String accountId,
|
||||||
|
String currentMailboxPath,
|
||||||
|
String role, {
|
||||||
|
required String dialogTitle,
|
||||||
|
required String createFolderName,
|
||||||
|
}) async {
|
||||||
|
Mailbox? mailbox = await mailboxRepo.findMailboxByRole(accountId, role);
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
if (mailbox != null) return mailbox;
|
||||||
|
|
||||||
|
final choice = await showDialog<_MissingFolderChoice>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(dialogTitle),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(ctx, _MissingFolderChoice.chooseExisting),
|
||||||
|
child: const Text('Choose existing folder'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, _MissingFolderChoice.createNew),
|
||||||
|
child: Text('Create "$createFolderName"'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!context.mounted || choice == null) return null;
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case _MissingFolderChoice.chooseExisting:
|
||||||
|
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
final chosen = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
const ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Move to…',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final m
|
||||||
|
in mailboxes.where((m) => m.path != currentMailboxPath))
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.folder_outlined),
|
||||||
|
title: Text(m.name),
|
||||||
|
onTap: () => Navigator.pop(ctx, m.path),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (chosen == null || !context.mounted) return null;
|
||||||
|
mailbox = mailboxes.firstWhere((m) => m.path == chosen);
|
||||||
|
case _MissingFolderChoice.createNew:
|
||||||
|
mailbox = await mailboxRepo.createMailboxWithRole(
|
||||||
|
accountId,
|
||||||
|
createFolderName,
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mailbox;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import 'package:sharedinbox/core/models/undo_action.dart';
|
|||||||
import 'package:sharedinbox/core/utils/format_utils.dart';
|
import 'package:sharedinbox/core/utils/format_utils.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -85,42 +86,12 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
icon: const Icon(Icons.archive),
|
||||||
tooltip: 'Mark as unread',
|
tooltip: 'Archive',
|
||||||
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
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_markAsSpam(context, header));
|
unawaited(_archive(context, header));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -148,8 +119,43 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
if (context.mounted) context.pop();
|
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>(
|
PopupMenuButton<String>(
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'mark_unread',
|
||||||
|
child: Text('Mark as unread'),
|
||||||
|
),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'headers',
|
value: 'headers',
|
||||||
child: Text('Show Mail Headers'),
|
child: Text('Show Mail Headers'),
|
||||||
@@ -163,8 +169,11 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
child: Text('Show Raw Email'),
|
child: Text('Show Raw Email'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onSelected: (value) {
|
onSelected: (value) async {
|
||||||
if (value == 'headers' && body != null) {
|
if (value == 'mark_unread') {
|
||||||
|
await repo.setFlag(widget.emailId, seen: false);
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
} else if (value == 'headers' && body != null) {
|
||||||
_showHeaders(context, body);
|
_showHeaders(context, body);
|
||||||
} else if (value == 'structure' && body != null) {
|
} else if (value == 'structure' && body != null) {
|
||||||
_showStructure(context, body);
|
_showStructure(context, body);
|
||||||
@@ -393,21 +402,22 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
Future<void> _archive(BuildContext context, Email header) async {
|
||||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
final mailbox = await resolveMailboxByRole(
|
||||||
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk');
|
context,
|
||||||
|
ref.read(mailboxRepositoryProvider),
|
||||||
if (junk == null) {
|
header.accountId,
|
||||||
if (!context.mounted) return;
|
header.mailboxPath,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
'archive',
|
||||||
const SnackBar(content: Text('No Junk folder found')),
|
dialogTitle: 'No archive folder found',
|
||||||
|
createFolderName: 'Archive',
|
||||||
);
|
);
|
||||||
return;
|
|
||||||
}
|
if (mailbox == null || !context.mounted) return;
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(emailRepositoryProvider)
|
.read(emailRepositoryProvider)
|
||||||
.moveEmail(widget.emailId, junk.path);
|
.moveEmail(widget.emailId, mailbox.path);
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
ref.read(undoServiceProvider.notifier).pushAction(
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
@@ -417,7 +427,40 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
type: UndoType.move,
|
type: UndoType.move,
|
||||||
emailIds: [widget.emailId],
|
emailIds: [widget.emailId],
|
||||||
sourceMailboxPath: header.mailboxPath,
|
sourceMailboxPath: header.mailboxPath,
|
||||||
destinationMailboxPath: junk.path,
|
destinationMailboxPath: mailbox.path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||||
|
final mailbox = await resolveMailboxByRole(
|
||||||
|
context,
|
||||||
|
ref.read(mailboxRepositoryProvider),
|
||||||
|
header.accountId,
|
||||||
|
header.mailboxPath,
|
||||||
|
'junk',
|
||||||
|
dialogTitle: 'No spam folder found',
|
||||||
|
createFolderName: 'Junk',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mailbox == null || !context.mounted) return;
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.moveEmail(widget.emailId, mailbox.path);
|
||||||
|
|
||||||
|
unawaited(
|
||||||
|
ref.read(undoServiceProvider.notifier).pushAction(
|
||||||
|
UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: header.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: [widget.emailId],
|
||||||
|
sourceMailboxPath: header.mailboxPath,
|
||||||
|
destinationMailboxPath: mailbox.path,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -895,10 +938,13 @@ class _UnsubscribeChip extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final uri = _parseUnsubscribeUri(header);
|
final uri = _parseUnsubscribeUri(header);
|
||||||
if (uri == null) return const SizedBox.shrink();
|
if (uri == null) return const SizedBox.shrink();
|
||||||
return ActionChip(
|
return Tooltip(
|
||||||
|
message: uri.toString(),
|
||||||
|
child: ActionChip(
|
||||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||||
label: const Text('Unsubscribe'),
|
label: const Text('Unsubscribe'),
|
||||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import 'package:intl/intl.dart';
|
|||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_tile.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
@@ -25,8 +25,6 @@ int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
|||||||
String _fmtDate(DateTime dt) =>
|
String _fmtDate(DateTime dt) =>
|
||||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
||||||
|
|
||||||
enum _MissingFolderChoice { chooseExisting, createNew }
|
|
||||||
|
|
||||||
class EmailListScreen extends ConsumerStatefulWidget {
|
class EmailListScreen extends ConsumerStatefulWidget {
|
||||||
const EmailListScreen({
|
const EmailListScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -431,70 +429,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
final ids = _selectedEmailIds;
|
final ids = _selectedEmailIds;
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
|
|
||||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
final mailbox = await resolveMailboxByRole(
|
||||||
Mailbox? mailbox =
|
context,
|
||||||
await mailboxRepo.findMailboxByRole(widget.accountId, role);
|
ref.read(mailboxRepositoryProvider),
|
||||||
|
|
||||||
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,
|
widget.accountId,
|
||||||
createFolderName,
|
widget.mailboxPath,
|
||||||
role,
|
role,
|
||||||
|
dialogTitle: dialogTitle,
|
||||||
|
createFolderName: createFolderName,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
|
||||||
}
|
if (!mounted || mailbox == null) return;
|
||||||
}
|
|
||||||
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
|||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||||
<style>
|
<style>
|
||||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||||
img { max-width: 100%; height: auto; }
|
img { max-width: 100%; height: auto; }
|
||||||
a { color: #1976D2; }
|
a { color: #1976D2; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; max-width: 100%; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
td, th { overflow-wrap: break-word; word-break: break-word; }
|
||||||
|
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const _excluded = {
|
|||||||
'lib/ui/widgets/try_connection_button.dart',
|
'lib/ui/widgets/try_connection_button.dart',
|
||||||
'lib/ui/widgets/undo_shell.dart',
|
'lib/ui/widgets/undo_shell.dart',
|
||||||
'lib/ui/screens/about_screen.dart',
|
'lib/ui/screens/about_screen.dart',
|
||||||
|
'lib/ui/screens/email_action_helpers.dart',
|
||||||
'lib/ui/utils/about_markdown.dart',
|
'lib/ui/utils/about_markdown.dart',
|
||||||
'lib/ui/widgets/email_tile.dart',
|
'lib/ui/widgets/email_tile.dart',
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
'lib/core/sync/account_sync_manager.dart',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
|
||||||
|
|
||||||
import 'helpers.dart';
|
import 'helpers.dart';
|
||||||
|
|
||||||
@@ -206,5 +207,50 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
expect(find.text('sharedinbox.de'), findsOneWidget);
|
expect(find.text('sharedinbox.de'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows Healthy when sync health is healthy', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts',
|
||||||
|
overrides: baseOverrides(
|
||||||
|
accounts: [kTestAccount],
|
||||||
|
syncHealth: SyncHealthRow(
|
||||||
|
accountId: kTestAccount.id,
|
||||||
|
lastVerifiedAt: DateTime(2024, 6),
|
||||||
|
isHealthy: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Healthy'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'shows discrepancy details when sync health has discrepancies',
|
||||||
|
(tester) async {
|
||||||
|
const summary =
|
||||||
|
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts',
|
||||||
|
overrides: baseOverrides(
|
||||||
|
accounts: [kTestAccount],
|
||||||
|
syncHealth: SyncHealthRow(
|
||||||
|
accountId: kTestAccount.id,
|
||||||
|
lastVerifiedAt: DateTime(2024, 6),
|
||||||
|
isHealthy: false,
|
||||||
|
discrepancySummary: summary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
||||||
|
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,11 +290,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets('Mark as spam shows dialog when no junk folder',
|
||||||
'Mark as spam moves email to junk and shows snackbar when no junk folder',
|
|
||||||
(tester) async {
|
(tester) async {
|
||||||
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
||||||
// returns null → snackbar shown.
|
// returns null → dialog shown.
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
buildApp(
|
buildApp(
|
||||||
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
@@ -312,7 +311,76 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('No Junk folder found'), findsOneWidget);
|
expect(find.text('No spam folder found'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Archive button is present in app bar', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(
|
||||||
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Archive',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Archive shows dialog when no archive folder', (tester) async {
|
||||||
|
// FakeMailboxRepository has no mailboxes by default → findMailboxByRole
|
||||||
|
// returns null → dialog shown.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(
|
||||||
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Archive',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No archive folder found'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Mark as unread is in popup menu, not a standalone button',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(
|
||||||
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// No standalone icon button for mark as unread.
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Mark as unread',
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
|
||||||
|
// It appears in the popup menu.
|
||||||
|
await tester.tap(find.byType(PopupMenuButton<String>));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Mark as unread'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
||||||
@@ -407,6 +475,44 @@ void main() {
|
|||||||
expect(find.text('Share'), findsOneWidget);
|
expect(find.text('Share'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'long-press on unsubscribe chip shows URL tooltip',
|
||||||
|
(tester) async {
|
||||||
|
final email = testEmail(
|
||||||
|
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation:
|
||||||
|
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(
|
||||||
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
email: email,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Unsubscribe'), findsOneWidget);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) =>
|
||||||
|
w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.longPress(find.text('Unsubscribe'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('https://example.com/unsubscribe'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('Show Mail Structure opens dialog with MIME parts', (
|
testWidgets('Show Mail Structure opens dialog with MIME parts', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
|||||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||||
|
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||||
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||||
@@ -505,13 +506,12 @@ Widget buildApp({
|
|||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
// Defaults come first so tests can override them via [overrides].
|
// Defaults come first so tests can override them via [overrides].
|
||||||
//
|
//
|
||||||
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
|
// syncLogRepositoryProvider is backed by a Drift StreamQuery. When the
|
||||||
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
|
// provider is disposed, Drift schedules a Timer.run() for cache
|
||||||
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
|
// debouncing. Flutter's test framework then fails the test with "A Timer
|
||||||
// framework then fails the test with "A Timer is still pending". Replacing
|
// is still pending". Replacing it with a synchronous stream avoids this.
|
||||||
// these with simple synchronous streams avoids the pending-timer assertion.
|
// syncHealthProvider has the same issue and is overridden in baseOverrides.
|
||||||
overrides: [
|
overrides: [
|
||||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
|
|
||||||
syncLogRepositoryProvider.overrideWithValue(
|
syncLogRepositoryProvider.overrideWithValue(
|
||||||
const NoOpSyncLogRepository(),
|
const NoOpSyncLogRepository(),
|
||||||
),
|
),
|
||||||
@@ -541,6 +541,7 @@ List<Override> baseOverrides({
|
|||||||
Exception? connectionError,
|
Exception? connectionError,
|
||||||
ShareKeyRepository? shareKeyRepository,
|
ShareKeyRepository? shareKeyRepository,
|
||||||
bool hasStoredPassword = true,
|
bool hasStoredPassword = true,
|
||||||
|
SyncHealthRow? syncHealth,
|
||||||
}) =>
|
}) =>
|
||||||
[
|
[
|
||||||
accountRepositoryProvider.overrideWithValue(
|
accountRepositoryProvider.overrideWithValue(
|
||||||
@@ -559,6 +560,9 @@ List<Override> baseOverrides({
|
|||||||
shareKeyRepositoryProvider.overrideWithValue(
|
shareKeyRepositoryProvider.overrideWithValue(
|
||||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||||
),
|
),
|
||||||
|
// syncHealthProvider is backed by a Drift StreamQuery; override with a
|
||||||
|
// plain stream to avoid "A Timer is still pending" in tests.
|
||||||
|
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -588,6 +592,7 @@ Email testEmail({
|
|||||||
bool isSeen = false,
|
bool isSeen = false,
|
||||||
bool isFlagged = false,
|
bool isFlagged = false,
|
||||||
bool hasAttachment = false,
|
bool hasAttachment = false,
|
||||||
|
String? listUnsubscribeHeader,
|
||||||
}) =>
|
}) =>
|
||||||
Email(
|
Email(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -603,6 +608,7 @@ Email testEmail({
|
|||||||
isSeen: isSeen,
|
isSeen: isSeen,
|
||||||
isFlagged: isFlagged,
|
isFlagged: isFlagged,
|
||||||
hasAttachment: hasAttachment,
|
hasAttachment: hasAttachment,
|
||||||
|
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||||
);
|
);
|
||||||
|
|
||||||
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ void main() {
|
|||||||
expect(html, contains('https: http: data: blob:'));
|
expect(html, contains('https: http: data: blob:'));
|
||||||
_expectLightMode(html);
|
_expectLightMode(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
||||||
|
() {
|
||||||
|
final html =
|
||||||
|
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
|
||||||
|
// Body clips overflow so fixed-width email tables don't escape the viewport.
|
||||||
|
expect(html, contains('overflow-x: hidden'));
|
||||||
|
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
|
||||||
|
expect(html, contains('table { width: 100%'));
|
||||||
|
// All elements are capped at viewport width via max-width.
|
||||||
|
expect(html, contains('max-width: 100%'));
|
||||||
|
// Pre-formatted text wraps instead of stretching the page.
|
||||||
|
expect(html, contains('white-space: pre-wrap'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// On Linux (the test host) the widget falls back to plain text extracted via
|
// On Linux (the test host) the widget falls back to plain text extracted via
|
||||||
|
|||||||
Reference in New Issue
Block a user