Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f5a58e5b | ||
|
|
b1ce093c33 |
@@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine;
|
|||||||
public final class GeneratedPluginRegistrant {
|
public final class GeneratedPluginRegistrant {
|
||||||
private static final String TAG = "GeneratedPluginRegistrant";
|
private static final String TAG = "GeneratedPluginRegistrant";
|
||||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
const int dbSchemaVersion = 32;
|
||||||
@@ -6,6 +6,7 @@ import 'package:drift/native.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 32;
|
int get schemaVersion => dbSchemaVersion;
|
||||||
|
|
||||||
Future<void> _createEmailFts() async {
|
Future<void> _createEmailFts() async {
|
||||||
await customStatement('''
|
await customStatement('''
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sharedinbox/core/db_schema_version.dart';
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -19,7 +21,9 @@ class AboutScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
||||||
|
late final Future<String?> _deviceModelFuture;
|
||||||
late final Stream<List<Account>> _accountsStream;
|
late final Stream<List<Account>> _accountsStream;
|
||||||
|
String? _deviceModel;
|
||||||
|
|
||||||
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
static const _gitHash = String.fromEnvironment('GIT_HASH');
|
||||||
|
|
||||||
@@ -27,14 +31,35 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
||||||
|
_deviceModelFuture = _fetchDeviceModel();
|
||||||
|
unawaited(
|
||||||
|
_deviceModelFuture.then((model) {
|
||||||
|
if (mounted) setState(() => _deviceModel = model);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _fetchDeviceModel() async {
|
||||||
|
try {
|
||||||
|
final info = DeviceInfoPlugin();
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final android = await info.androidInfo;
|
||||||
|
return '${android.manufacturer} / ${android.model}';
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final ios = await info.iosInfo;
|
||||||
|
return ios.utsname.machine;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildMarkdown(
|
String _buildMarkdown(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
PackageInfo? pkg,
|
PackageInfo? pkg,
|
||||||
int imapCount,
|
int imapCount,
|
||||||
int jmapCount,
|
int jmapCount, {
|
||||||
) {
|
String? deviceModel,
|
||||||
|
}) {
|
||||||
final size = MediaQuery.of(context).size;
|
final size = MediaQuery.of(context).size;
|
||||||
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
final physW = (size.width * pixelRatio).toInt();
|
final physW = (size.width * pixelRatio).toInt();
|
||||||
@@ -46,10 +71,15 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
: version;
|
: version;
|
||||||
final osName = _capitalize(Platform.operatingSystem);
|
final osName = _capitalize(Platform.operatingSystem);
|
||||||
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
final textScale =
|
||||||
|
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
|
||||||
|
|
||||||
final gitCommitLine = _gitHash.isNotEmpty
|
final gitCommitLine = _gitHash.isNotEmpty
|
||||||
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
|
||||||
: '';
|
: '';
|
||||||
|
final deviceModelLine =
|
||||||
|
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
|
||||||
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
|
||||||
'| Property | Value |\n'
|
'| Property | Value |\n'
|
||||||
'|----------|-------|\n'
|
'|----------|-------|\n'
|
||||||
@@ -57,12 +87,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
'$gitCommitLine'
|
'$gitCommitLine'
|
||||||
'| Platform | ${Platform.operatingSystem} |\n'
|
'| Platform | ${Platform.operatingSystem} |\n'
|
||||||
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
|
||||||
|
'$deviceModelLine'
|
||||||
'| Resolution | ${physW}x$physH px'
|
'| Resolution | ${physW}x$physH px'
|
||||||
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
|
||||||
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
|
||||||
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
'| Dart Version | ${Platform.version.split(' ').first} |\n'
|
||||||
'| Processors | ${Platform.numberOfProcessors} |\n'
|
'| Processors | ${Platform.numberOfProcessors} |\n'
|
||||||
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
|
||||||
|
'| Locale | $locale |\n'
|
||||||
|
'| Text Scale | $textScale× |\n'
|
||||||
|
'| DB Schema Version | $dbSchemaVersion |\n'
|
||||||
'| IMAP Accounts | $imapCount |\n'
|
'| IMAP Accounts | $imapCount |\n'
|
||||||
'| JMAP Accounts | $jmapCount |\n';
|
'| JMAP Accounts | $jmapCount |\n';
|
||||||
}
|
}
|
||||||
@@ -79,10 +113,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
String? deviceModel;
|
||||||
|
try {
|
||||||
|
deviceModel = await _deviceModelFuture;
|
||||||
|
} catch (_) {}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(
|
||||||
text: _buildMarkdown(context, pkg, imapCount, jmapCount),
|
text: _buildMarkdown(
|
||||||
|
context,
|
||||||
|
pkg,
|
||||||
|
imapCount,
|
||||||
|
jmapCount,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -128,9 +172,19 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
try {
|
try {
|
||||||
pkg = await _packageInfoFuture;
|
pkg = await _packageInfoFuture;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
String? deviceModel;
|
||||||
|
try {
|
||||||
|
deviceModel = await _deviceModelFuture;
|
||||||
|
} catch (_) {}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final body = Uri.encodeComponent(
|
final body = Uri.encodeComponent(
|
||||||
_buildMarkdown(context, pkg, imapCount, jmapCount),
|
_buildMarkdown(
|
||||||
|
context,
|
||||||
|
pkg,
|
||||||
|
imapCount,
|
||||||
|
jmapCount,
|
||||||
|
deviceModel: deviceModel,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
final url = Uri.parse(
|
final url = Uri.parse(
|
||||||
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
||||||
@@ -186,6 +240,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
snapshot.data,
|
snapshot.data,
|
||||||
imapCount,
|
imapCount,
|
||||||
jmapCount,
|
jmapCount,
|
||||||
|
deviceModel: _deviceModel,
|
||||||
),
|
),
|
||||||
selectable: true,
|
selectable: true,
|
||||||
onTapLink: (text, href, title) {
|
onTapLink: (text, href, title) {
|
||||||
|
|||||||
@@ -70,16 +70,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
onPressed: header == null
|
onPressed: header == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
unawaited(_reply(context, header, body, replyAll: false));
|
unawaited(
|
||||||
},
|
_replyWithRecipientDialog(context, header, body),
|
||||||
),
|
);
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.reply_all),
|
|
||||||
tooltip: 'Reply all',
|
|
||||||
onPressed: header == null
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
unawaited(_reply(context, header, body, replyAll: true));
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -121,6 +114,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
tooltip: 'Snooze',
|
tooltip: 'Snooze',
|
||||||
onPressed: header == null ? null : () => _snooze(context, header),
|
onPressed: header == null ? null : () => _snooze(context, header),
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.report_outlined),
|
||||||
|
tooltip: 'Mark as spam',
|
||||||
|
onPressed: header == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
unawaited(_markAsSpam(context, header));
|
||||||
|
},
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
@@ -303,17 +305,78 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reply(
|
Future<void> _replyWithRecipientDialog(
|
||||||
|
BuildContext context,
|
||||||
|
Email header,
|
||||||
|
EmailBody? body,
|
||||||
|
) async {
|
||||||
|
final account =
|
||||||
|
await ref.read(accountRepositoryProvider).getAccount(header.accountId);
|
||||||
|
final ownEmail = account?.email.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
final seen = <String>{};
|
||||||
|
final candidates = <_Candidate>[];
|
||||||
|
|
||||||
|
void addIfNew(EmailAddress addr, _Placement defaultPlacement) {
|
||||||
|
final key = addr.email.toLowerCase();
|
||||||
|
if (key == ownEmail || seen.contains(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
candidates.add(_Candidate(addr, defaultPlacement));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final addr in header.from) {
|
||||||
|
addIfNew(addr, _Placement.to);
|
||||||
|
}
|
||||||
|
for (final addr in header.to) {
|
||||||
|
addIfNew(addr, _Placement.to);
|
||||||
|
}
|
||||||
|
for (final addr in header.cc) {
|
||||||
|
addIfNew(addr, _Placement.cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (candidates.length <= 1) {
|
||||||
|
final to = candidates
|
||||||
|
.where((c) => c.placement == _Placement.to)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
final cc = candidates
|
||||||
|
.where((c) => c.placement == _Placement.cc)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
await _composeReply(context, header, body, to: to, cc: cc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<List<_Candidate>>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => _ReplyAllDialog(candidates: candidates),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == null || !context.mounted) return;
|
||||||
|
|
||||||
|
final to = confirmed
|
||||||
|
.where((c) => c.placement == _Placement.to)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
final cc = confirmed
|
||||||
|
.where((c) => c.placement == _Placement.cc)
|
||||||
|
.map((c) => c.address.email)
|
||||||
|
.join(', ');
|
||||||
|
await _composeReply(context, header, body, to: to, cc: cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _composeReply(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
EmailBody? body, {
|
EmailBody? body, {
|
||||||
required bool replyAll,
|
required String to,
|
||||||
|
required String cc,
|
||||||
}) async {
|
}) async {
|
||||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
|
||||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||||
? header.subject!
|
? header.subject!
|
||||||
: 'Re: ${header.subject ?? ''}';
|
: 'Re: ${header.subject ?? ''}';
|
||||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
|
||||||
final quoted = await _quotedBody(header, body);
|
final quoted = await _quotedBody(header, body);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
@@ -330,6 +393,38 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _markAsSpam(BuildContext context, Email header) async {
|
||||||
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
|
final junk = await mailboxRepo.findMailboxByRole(header.accountId, 'junk');
|
||||||
|
|
||||||
|
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, junk.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: junk.path,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) context.pop();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _forward(
|
Future<void> _forward(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Email header,
|
Email header,
|
||||||
@@ -670,6 +765,94 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _Placement { to, cc, skip }
|
||||||
|
|
||||||
|
class _Candidate {
|
||||||
|
_Candidate(this.address, this.placement);
|
||||||
|
final EmailAddress address;
|
||||||
|
_Placement placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReplyAllDialog extends StatefulWidget {
|
||||||
|
const _ReplyAllDialog({required this.candidates});
|
||||||
|
final List<_Candidate> candidates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ReplyAllDialog> createState() => _ReplyAllDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReplyAllDialogState extends State<_ReplyAllDialog> {
|
||||||
|
late final List<_Candidate> _candidates;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_candidates = [
|
||||||
|
for (final c in widget.candidates) _Candidate(c.address, c.placement),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Reply All'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: double.maxFinite,
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
for (final c in _candidates)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
c.address.toString(),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SegmentedButton<_Placement>(
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: _Placement.to,
|
||||||
|
label: Text('To'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _Placement.cc,
|
||||||
|
label: Text('Cc'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _Placement.skip,
|
||||||
|
label: Text('Skip'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {c.placement},
|
||||||
|
onSelectionChanged: (s) =>
|
||||||
|
setState(() => c.placement = s.first),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, _candidates),
|
||||||
|
child: const Text('Reply'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MimeRow {
|
class _MimeRow {
|
||||||
const _MimeRow(this.depth, this.label);
|
const _MimeRow(this.depth, this.label);
|
||||||
final int depth;
|
final int depth;
|
||||||
|
|||||||
@@ -249,6 +249,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.12"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.1.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.1.0"
|
||||||
drift:
|
drift:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1284,6 +1300,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.0"
|
version: "6.3.0"
|
||||||
|
win32_registry:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32_registry
|
||||||
|
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
workmanager:
|
workmanager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ dependencies:
|
|||||||
# App version metadata for crash reports
|
# App version metadata for crash reports
|
||||||
package_info_plus: ^10.1.0
|
package_info_plus: ^10.1.0
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
device_info_plus: ^13.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const _minCoveragePercent = 80;
|
|||||||
|
|
||||||
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
// Pure-abstract interfaces: no executable code, Dart VM never instruments them.
|
||||||
const _noCode = {
|
const _noCode = {
|
||||||
|
'lib/core/db_schema_version.dart',
|
||||||
'lib/core/repositories/account_repository.dart',
|
'lib/core/repositories/account_repository.dart',
|
||||||
'lib/core/repositories/draft_repository.dart',
|
'lib/core/repositories/draft_repository.dart',
|
||||||
'lib/core/repositories/email_repository.dart',
|
'lib/core/repositories/email_repository.dart',
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ void main() {
|
|||||||
expect(find.textContaining('Dark Mode'), findsWidgets);
|
expect(find.textContaining('Dark Mode'), findsWidgets);
|
||||||
expect(find.textContaining('IMAP Accounts'), findsWidgets);
|
expect(find.textContaining('IMAP Accounts'), findsWidgets);
|
||||||
expect(find.textContaining('JMAP Accounts'), findsWidgets);
|
expect(find.textContaining('JMAP Accounts'), findsWidgets);
|
||||||
|
expect(find.textContaining('Locale'), findsWidgets);
|
||||||
|
expect(find.textContaining('Text Scale'), findsWidgets);
|
||||||
|
expect(find.textContaining('DB Schema Version'), findsWidgets);
|
||||||
// Buttons are in the body, not in the AppBar actions
|
// Buttons are in the body, not in the AppBar actions
|
||||||
expect(find.byIcon(Icons.copy), findsOneWidget);
|
expect(find.byIcon(Icons.copy), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
expect(find.byIcon(Icons.bug_report), findsOneWidget);
|
||||||
@@ -167,6 +170,9 @@ void main() {
|
|||||||
expect(clipboardText, contains('Dark Mode'));
|
expect(clipboardText, contains('Dark Mode'));
|
||||||
expect(clipboardText, contains('IMAP Accounts'));
|
expect(clipboardText, contains('IMAP Accounts'));
|
||||||
expect(clipboardText, contains('JMAP Accounts'));
|
expect(clipboardText, contains('JMAP Accounts'));
|
||||||
|
expect(clipboardText, contains('Locale'));
|
||||||
|
expect(clipboardText, contains('Text Scale'));
|
||||||
|
expect(clipboardText, contains('DB Schema Version'));
|
||||||
expect(
|
expect(
|
||||||
clipboardText,
|
clipboardText,
|
||||||
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
contains('[sharedinbox.de](https://sharedinbox.de)'),
|
||||||
|
|||||||
@@ -179,6 +179,142 @@ void main() {
|
|||||||
expect(find.text('report.pdf'), findsOneWidget);
|
expect(find.text('report.pdf'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Reply All button is not present in app bar', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(
|
||||||
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Reply all',
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Reply on single-recipient email navigates directly to compose',
|
||||||
|
(tester) async {
|
||||||
|
// testEmail has from=[bob], to=[alice]. After removing alice (own),
|
||||||
|
// only bob remains → no dialog, navigate straight to compose.
|
||||||
|
final email = testEmail();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: [
|
||||||
|
..._overrides(
|
||||||
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
email: email,
|
||||||
|
),
|
||||||
|
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Reply',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// No dialog shown — straight navigation to compose.
|
||||||
|
expect(find.text('Reply All'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Reply on multi-recipient email shows Reply All dialog',
|
||||||
|
(tester) async {
|
||||||
|
// Email with an extra Cc recipient so the dialog is triggered.
|
||||||
|
final email = Email(
|
||||||
|
id: 'acc-1:42',
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
uid: 42,
|
||||||
|
subject: 'Hello world',
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
sentAt: DateTime(2024, 6),
|
||||||
|
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||||
|
to: const [EmailAddress(email: 'alice@example.com')],
|
||||||
|
cc: const [EmailAddress(name: 'Carol', email: 'carol@example.com')],
|
||||||
|
isSeen: false,
|
||||||
|
isFlagged: false,
|
||||||
|
hasAttachment: false,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(
|
||||||
|
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||||
|
email: email,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(
|
||||||
|
find.byWidgetPredicate(
|
||||||
|
(w) => w is Tooltip && w.message == 'Reply',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Dialog must appear with title 'Reply All'.
|
||||||
|
expect(find.text('Reply All'), findsOneWidget);
|
||||||
|
// Both non-own addresses should be listed in the dialog.
|
||||||
|
expect(find.textContaining('bob@example.com'), findsAtLeastNWidgets(1));
|
||||||
|
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Mark as spam 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 == 'Mark as spam',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 → snackbar 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 == 'Mark as spam',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No Junk folder found'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
testWidgets('Show Raw Email dialog shows size of email', (tester) async {
|
||||||
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
|
// 'A' * 2048 → fmtSize(2048) == '2.0 KB'
|
||||||
final rawContent = 'A' * 2048;
|
final rawContent = 'A' * 2048;
|
||||||
|
|||||||
Reference in New Issue
Block a user