From 25ce99ef10a7169b2538626c30e2c3bd87f70fae Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Sun, 8 Oct 2023 17:52:45 +0200 Subject: [PATCH 01/95] chore: start with rewrite --- .vscode/settings.json | 1 + .../account.dart => account/model.dart} | 21 +- .../account.g.dart => account/model.g.dart} | 2 +- lib/account/providers.dart | 88 ++- lib/account/providers.g.dart | 68 ++- lib/account/storage.dart | 9 +- lib/events/base_event.dart | 2 +- lib/extensions/extension_action_tile.dart | 12 +- lib/extensions/extensions.dart | 13 +- lib/lifecycle.dart | 8 +- lib/mail/provider.dart | 160 +++++ lib/mail/provider.g.dart | 204 +++++++ lib/main.dart | 305 ++++------ lib/models/async_mime_source.dart | 59 +- lib/models/async_mime_source_factory.dart | 13 +- lib/models/compose_data.dart | 22 +- lib/models/contact.dart | 6 +- lib/models/date_sectioned_message_source.dart | 18 +- lib/models/hive/hive_mime_storage.dart | 2 +- lib/models/mail_operation.dart | 21 +- lib/models/message.dart | 40 +- lib/models/message_date_section.dart | 6 +- lib/models/message_source.dart | 223 ++++--- lib/models/models.dart | 1 - lib/models/offline_mime_storage_factory.dart | 8 +- lib/models/sender.dart | 10 +- lib/models/shared_data.dart | 18 +- lib/models/swipe.dart | 2 +- lib/models/web_view_configuration.dart | 4 +- lib/oauth/oauth.dart | 16 +- lib/routes.dart | 62 +- lib/screens/account_add_screen.dart | 317 +++++----- lib/screens/account_edit_screen.dart | 5 +- .../account_server_details_screen.dart | 52 +- lib/screens/base.dart | 88 ++- lib/screens/compose_screen.dart | 428 +++++++------ lib/screens/home_screen.dart | 30 + lib/screens/location_screen.dart | 24 +- lib/screens/lock_screen.dart | 20 +- lib/screens/mail_screen.dart | 43 ++ lib/screens/media_screen.dart | 2 +- lib/screens/message_details_screen.dart | 22 +- lib/screens/message_source_screen.dart | 574 +++++++++--------- .../{all_screens.dart => screens.dart} | 2 + lib/screens/sourcecode_screen.dart | 6 +- lib/screens/splash_screen.dart | 4 +- lib/screens/webview_screen.dart | 8 +- lib/screens/welcome_screen.dart | 28 +- lib/services/background_service.dart | 14 +- lib/services/biometrics_service.dart | 24 +- lib/services/contact_service.dart | 9 +- lib/services/date_service.dart | 94 +-- lib/services/i18n_service.dart | 100 +-- lib/services/icon_service.dart | 2 +- lib/services/key_service.dart | 31 +- lib/services/location_service.dart | 12 +- lib/services/mail_service.dart | 60 +- lib/services/navigation_service.dart | 51 +- lib/services/notification_service.dart | 22 +- lib/services/providers.dart | 40 +- lib/services/scaffold_messenger_service.dart | 10 +- lib/settings/provider.dart | 2 +- .../view/settings_accounts_screen.dart | 178 +++--- .../view/settings_developer_mode_screen.dart | 2 +- .../view/settings_folders_screen.dart | 45 +- .../view/settings_signature_screen.dart | 2 +- lib/util/datetime.dart | 8 +- lib/util/http_helper.dart | 2 +- lib/util/indexed_cache.dart | 4 +- lib/util/modal_bottom_sheet_helper.dart | 9 +- lib/util/string_helper.dart | 4 +- lib/util/validator.dart | 2 +- lib/widgets/account_provider_selector.dart | 9 +- lib/widgets/account_selector.dart | 14 +- lib/widgets/app_drawer.dart | 192 +++--- lib/widgets/attachment_chip.dart | 41 +- lib/widgets/attachment_compose_bar.dart | 109 ++-- lib/widgets/button_text.dart | 8 +- lib/widgets/cupertino_status_bar.dart | 27 +- lib/widgets/editor_extensions.dart | 22 +- lib/widgets/empty_message.dart | 4 +- lib/widgets/expanding_wrap.dart | 74 +-- lib/widgets/expansion_wrap.dart | 30 +- lib/widgets/ical_composer.dart | 97 ++- lib/widgets/ical_interactive_media.dart | 89 ++- lib/widgets/icon_text.dart | 17 +- lib/widgets/inherited_widgets.dart | 197 ------ lib/widgets/legalese.dart | 6 +- lib/widgets/mail_address_chip.dart | 21 +- lib/widgets/mailbox_selector.dart | 16 +- lib/widgets/mailbox_tree.dart | 15 +- lib/widgets/menu_with_badge.dart | 12 +- lib/widgets/message_overview_content.dart | 24 +- lib/widgets/message_stack.dart | 155 +++-- lib/widgets/password_field.dart | 10 +- lib/widgets/recipient_input_field.dart | 43 +- lib/widgets/search_text_field.dart | 11 +- lib/widgets/signature.dart | 2 +- lib/widgets/text_with_links.dart | 16 +- pubspec.yaml | 60 +- test/model/async_mime_source_test.dart | 4 +- test/model/fake_mime_source.dart | 10 +- test/model/multiple_message_source_test.dart | 30 +- 103 files changed, 2745 insertions(+), 2424 deletions(-) rename lib/{models/account.dart => account/model.dart} (96%) rename lib/{models/account.g.dart => account/model.g.dart} (97%) create mode 100644 lib/mail/provider.dart create mode 100644 lib/mail/provider.g.dart create mode 100644 lib/screens/home_screen.dart create mode 100644 lib/screens/mail_screen.dart rename lib/screens/{all_screens.dart => screens.dart} (88%) delete mode 100644 lib/widgets/inherited_widgets.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 0337d9a..33de042 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.words": [ "autofocus", "Cupertino", + "giphy", "Ical", "icalendar", "Imap", diff --git a/lib/models/account.dart b/lib/account/model.dart similarity index 96% rename from lib/models/account.dart rename to lib/account/model.dart index 1800a1d..b6cbdf0 100644 --- a/lib/models/account.dart +++ b/lib/account/model.dart @@ -4,11 +4,11 @@ import 'package:json_annotation/json_annotation.dart'; import '../extensions/extensions.dart'; import '../locator.dart'; +import '../models/contact.dart'; import '../services/i18n_service.dart'; import '../services/mail_service.dart'; -import 'contact.dart'; -part 'account.g.dart'; +part 'model.g.dart'; /// Common functionality for accounts abstract class Account extends ChangeNotifier { @@ -18,6 +18,9 @@ abstract class Account extends ChangeNotifier { /// The name of the account String get name; + /// Retrieves the email or emails associated with this account + String get email; + set name(String value); /// The from address for this account @@ -106,7 +109,8 @@ class RealAccount extends Account { if (signature == null) { final extensions = appExtensions; if (extensions != null) { - final languageCode = locator().locale!.languageCode; + final languageCode = + locator().locale?.languageCode ?? 'en'; for (final ext in extensions) { final signature = ext.getSignatureHtml(languageCode); if (signature != null) { @@ -139,6 +143,7 @@ class RealAccount extends Account { } /// The email associated with this account + @override @JsonKey(includeToJson: false, includeFromJson: false) String get email => _account.email; set email(String value) { @@ -177,6 +182,7 @@ class RealAccount extends Account { Future addAlias(MailAddress alias) { _account = _account.copyWithAlias(alias); notifyListeners(); + return locator().saveAccount(_account); } @@ -184,6 +190,7 @@ class RealAccount extends Account { Future removeAlias(MailAddress alias) { _account.aliases.remove(alias); notifyListeners(); + return locator().saveAccount(_account); } @@ -233,21 +240,20 @@ class RealAccount extends Account { /// A unified account bundles folders of several accounts class UnifiedAccount extends Account { /// Creates a new [UnifiedAccount] - UnifiedAccount(this.accounts, String name) : _name = name; + UnifiedAccount(this.accounts); /// The accounts final List accounts; - String _name; @override bool get isVirtual => true; @override - String get name => _name; + String get name => ''; @override set name(String value) { - _name = value; + //_name = value; notifyListeners(); } @@ -255,6 +261,7 @@ class UnifiedAccount extends Account { MailAddress get fromAddress => accounts.first.fromAddress; /// The emails of this account + @override String get email => accounts.map((a) => a.email).join(';'); /// Removes the given [account] diff --git a/lib/models/account.g.dart b/lib/account/model.g.dart similarity index 97% rename from lib/models/account.g.dart rename to lib/account/model.g.dart index 43ec47c..806f9fc 100644 --- a/lib/models/account.g.dart +++ b/lib/account/model.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'account.dart'; +part of 'model.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/account/providers.dart b/lib/account/providers.dart index 9ce9523..0c28d6f 100644 --- a/lib/account/providers.dart +++ b/lib/account/providers.dart @@ -1,17 +1,89 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../models/account.dart'; +import 'model.dart'; +import 'storage.dart'; part 'providers.g.dart'; -/// Retrieves the list of real accounts +/// Retrieves the current account +@riverpod +class CurrentAccount extends _$CurrentAccount { + @override + Raw? build() { + final accounts = ref.watch(allAccountsProvider); + if (accounts.isEmpty) { + return null; + } + + return accounts.first; + } +} + +/// Provides all real email accounts +@Riverpod(keepAlive: true) +class RealAccounts extends _$RealAccounts { + late AccountStorage _storage; + + @override + List build() => []; + + /// Loads the accounts from disk + Future init() async { + _storage = const AccountStorage(); + final accounts = await _storage.loadAccounts(); + state = accounts; + } + + /// Adds a new account + void addAccount(RealAccount account) { + final cleanState = state.toList()..removeWhere((a) => a.key == account.key); + state = [...cleanState, account]; + _saveAccounts(); + } + + /// Removes the given [account] + void removeAccount(RealAccount account) { + state = state.where((a) => a.key != account.key).toList(); + _saveAccounts(); + } + + /// Changes the order of the accounts + void reorderAccounts(List accounts) { + state = accounts; + _saveAccounts(); + } + + /// Saves all data + Future save() => _saveAccounts(); + + Future _saveAccounts() async { + await _storage.saveAccounts(state); + } +} + +/// Provides the unified account, if any @Riverpod(keepAlive: true) -List realAccounts(RealAccountsRef ref) { - throw UnimplementedError(); +UnifiedAccount? unifiedAccount(UnifiedAccountRef ref) { + final allRealAccounts = ref.watch(realAccountsProvider); + final accounts = allRealAccounts.where((a) => !a.excludeFromUnified).toList(); + if (accounts.length <= 1) { + return null; + } + + return UnifiedAccount(accounts); } -/// Retrieves the current account -@riverpod -Raw currentAccount(CurrentAccountRef ref) { - throw UnimplementedError(); +/// Provides all accounts +@Riverpod(keepAlive: true) +class AllAccounts extends _$AllAccounts { + @override + List build() { + final realAccounts = ref.watch(realAccountsProvider); + final unifiedAccount = ref.watch(unifiedAccountProvider); + + return [ + if (unifiedAccount != null) unifiedAccount, + ...realAccounts, + ]; + } } diff --git a/lib/account/providers.g.dart b/lib/account/providers.g.dart index f64d3da..3151aa8 100644 --- a/lib/account/providers.g.dart +++ b/lib/account/providers.g.dart @@ -6,30 +6,32 @@ part of 'providers.dart'; // RiverpodGenerator // ************************************************************************** -String _$realAccountsHash() => r'8203d71da59bfb83b1ca5a4948b23f3b3d6c4428'; +String _$unifiedAccountHash() => r'e59dc865d2ef074d5da9cdc4d228551153ef0a53'; -/// Retrieves the list of real accounts +/// Provides the unified account, if any /// -/// Copied from [realAccounts]. -@ProviderFor(realAccounts) -final realAccountsProvider = Provider>.internal( - realAccounts, - name: r'realAccountsProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$realAccountsHash, +/// Copied from [unifiedAccount]. +@ProviderFor(unifiedAccount) +final unifiedAccountProvider = Provider.internal( + unifiedAccount, + name: r'unifiedAccountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$unifiedAccountHash, dependencies: null, allTransitiveDependencies: null, ); -typedef RealAccountsRef = ProviderRef>; -String _$currentAccountHash() => r'0fb8b802c624f8d611d1f30b0871a9d34c6632cd'; +typedef UnifiedAccountRef = ProviderRef; +String _$currentAccountHash() => r'247e43dde286f389677a2b1b299ddbf7099a9577'; /// Retrieves the current account /// -/// Copied from [currentAccount]. -@ProviderFor(currentAccount) -final currentAccountProvider = AutoDisposeProvider.internal( - currentAccount, +/// Copied from [CurrentAccount]. +@ProviderFor(CurrentAccount) +final currentAccountProvider = + AutoDisposeNotifierProvider.internal( + CurrentAccount.new, name: r'currentAccountProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null @@ -38,6 +40,40 @@ final currentAccountProvider = AutoDisposeProvider.internal( allTransitiveDependencies: null, ); -typedef CurrentAccountRef = AutoDisposeProviderRef; +typedef _$CurrentAccount = AutoDisposeNotifier; +String _$realAccountsHash() => r'af80cd3883b4dae338d70ab5a8b8c00b94bc6024'; + +/// Provides all real email accounts +/// +/// Copied from [RealAccounts]. +@ProviderFor(RealAccounts) +final realAccountsProvider = + NotifierProvider>.internal( + RealAccounts.new, + name: r'realAccountsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$realAccountsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$RealAccounts = Notifier>; +String _$allAccountsHash() => r'e97a4caa8ae7cdc52f6a1d9e7a4f7fcaf4f21da4'; + +/// Provides all accounts +/// +/// Copied from [AllAccounts]. +@ProviderFor(AllAccounts) +final allAccountsProvider = + NotifierProvider>.internal( + AllAccounts.new, + name: r'allAccountsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$allAccountsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AllAccounts = Notifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/account/storage.dart b/lib/account/storage.dart index 0064720..e13c037 100644 --- a/lib/account/storage.dart +++ b/lib/account/storage.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import '../models/account.dart'; +import 'model.dart'; /// Allows to load and store accounts class AccountStorage { @@ -11,7 +11,7 @@ class AccountStorage { const AccountStorage(); static const String _keyAccounts = 'accts'; - final _storage = const FlutterSecureStorage(); + FlutterSecureStorage get _storage => const FlutterSecureStorage(); /// Loads the accounts from the storage Future> loadAccounts() async { @@ -21,12 +21,14 @@ class AccountStorage { } final accountsJson = jsonDecode(jsonText) as List; try { + // ignore: unnecessary_lambdas return accountsJson.map((json) => RealAccount.fromJson(json)).toList(); } catch (e) { if (kDebugMode) { print('Unable to parse accounts: $e'); print(jsonText); } + return []; } } @@ -34,8 +36,9 @@ class AccountStorage { /// Saves the given [accounts] to the storage Future saveAccounts(List accounts) { final accountsJson = - accounts.whereType().map((a) => (a).toJson()).toList(); + accounts.whereType().map((a) => a.toJson()).toList(); final json = jsonEncode(accountsJson); + return _storage.write(key: _keyAccounts, value: json); } } diff --git a/lib/events/base_event.dart b/lib/events/base_event.dart index ec6fbd3..63e904c 100644 --- a/lib/events/base_event.dart +++ b/lib/events/base_event.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; class BaseEvent { - final BuildContext context; BaseEvent(this.context); + final BuildContext context; } diff --git a/lib/extensions/extension_action_tile.dart b/lib/extensions/extension_action_tile.dart index 2816e7a..57cddf4 100644 --- a/lib/extensions/extension_action_tile.dart +++ b/lib/extensions/extension_action_tile.dart @@ -1,20 +1,20 @@ import 'dart:io'; -import 'package:enough_mail_app/extensions/extensions.dart'; -import 'package:enough_mail_app/models/models.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart' hide WebViewConfiguration; +import '../account/model.dart'; import '../locator.dart'; +import '../models/models.dart'; import '../routes.dart'; +import '../services/i18n_service.dart'; +import '../services/navigation_service.dart'; +import 'extensions.dart'; class ExtensionActionTile extends StatelessWidget { + const ExtensionActionTile({super.key, required this.actionDescription}); final AppExtensionActionDescription actionDescription; - const ExtensionActionTile({Key? key, required this.actionDescription}) - : super(key: key); static Widget buildSideMenuForAccount( BuildContext context, RealAccount? currentAccount) { diff --git a/lib/extensions/extensions.dart b/lib/extensions/extensions.dart index 27325ba..1557b6f 100644 --- a/lib/extensions/extensions.dart +++ b/lib/extensions/extensions.dart @@ -2,11 +2,11 @@ import 'dart:convert'; import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/util/http_helper.dart'; import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../models/account.dart'; +import '../account/model.dart'; +import '../util/http_helper.dart'; part 'extensions.g.dart'; @@ -63,9 +63,7 @@ class AppExtension { Map toJson() => _$AppExtensionToJson(this); - static String urlFor(String domain) { - return 'https://$domain/.maily.json'; - } + static String urlFor(String domain) => 'https://$domain/.maily.json'; static Future> loadFor(MailAccount mailAccount) async { final domains = >{}; @@ -102,9 +100,8 @@ class AppExtension { } } - static Future loadFrom(String domain) async { - return loadFromUrl(urlFor(domain)); - } + static Future loadFrom(String domain) async => + loadFromUrl(urlFor(domain)); static Future loadFromUrl(String url) async { String? text = '<>'; diff --git a/lib/lifecycle.dart b/lib/lifecycle.dart index 20d7488..8d353ef 100644 --- a/lib/lifecycle.dart +++ b/lib/lifecycle.dart @@ -1,9 +1,9 @@ -import 'package:enough_mail_app/events/app_event_bus.dart'; +import 'events/app_event_bus.dart'; import 'package:flutter/material.dart'; class LifecycleManager extends StatefulWidget { + const LifecycleManager({super.key, required this.child}); final Widget child; - const LifecycleManager({Key? key, required this.child}) : super(key: key); @override State createState() => _LifecycleManagerState(); @@ -29,7 +29,5 @@ class _LifecycleManagerState extends State } @override - Widget build(BuildContext context) { - return widget.child; - } + Widget build(BuildContext context) => widget.child; } diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart new file mode 100644 index 0000000..640ab4c --- /dev/null +++ b/lib/mail/provider.dart @@ -0,0 +1,160 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../account/model.dart'; +import '../account/providers.dart'; +import '../events/app_event_bus.dart'; +import '../locator.dart'; +import '../models/async_mime_source_factory.dart'; +import '../models/message_source.dart'; +import '../services/providers.dart'; + +part 'provider.g.dart'; + +/// Provides the message source for the given account +@Riverpod(keepAlive: true) +class Source extends _$Source { + static const _clientId = Id(name: 'Maily', version: '1.0'); + final _mailClientsPerAccount = {}; + final _mimeSourceFactory = + const AsyncMimeSourceFactory(isOfflineModeSupported: false); + + @override + Future build({required Account account, Mailbox? mailbox}) { + if (account is RealAccount) { + return _buildRealAccount(account, mailbox); + } else if (account is UnifiedAccount) { + return _buildUnifiedAccount(account, mailbox); + } else { + throw UnimplementedError(); + } + } + + Future _buildRealAccount( + RealAccount account, [ + Mailbox? mailbox, + ]) async { + final mailClient = await _getClientAndStopPolling(account); + if (mailClient == null) { + throw Exception('Unable to connect to server'); + } + if (mailbox == null) { + mailbox = await mailClient.selectInbox(); + } else { + await mailClient.selectMailbox(mailbox); + } + final source = _mimeSourceFactory.createMailboxMimeSource( + mailClient, + mailbox, + ); //..addSubscriber(this); + // TODO(RV): add subscriber + + return MailboxMessageSource.fromMimeSource( + source, + mailClient.account.email, + mailbox.name, + ); + } + + Future _buildUnifiedAccount( + UnifiedAccount account, [ + Mailbox? mailbox, + ]) { + throw UnimplementedError(); + } + + Future _getClientAndStopPolling(RealAccount account) async { + final client = await getClientFor(account); + await client.stopPollingIfNeeded(); + if (!client.isConnected) { + await client.connect(); + } + + return client; + } + + Future getClientFor( + RealAccount account, + ) async => + _mailClientsPerAccount[account] ?? await createClientFor(account); + + Future createClientFor( + RealAccount account, { + bool store = true, + }) async { + final client = createMailClient(account.mailAccount); + if (store) { + _mailClientsPerAccount[account] = client; + } + await client.connect(); + await _loadMailboxesFor(client); + + return client; + } + + MailClient createMailClient(MailAccount mailAccount) { + final bool isLogEnabled = kDebugMode || + (mailAccount.attributes[RealAccount.attributeEnableLogging] ?? false); + + return MailClient( + mailAccount, + isLogEnabled: isLogEnabled, + logName: mailAccount.name, + eventBus: AppEventBus.eventBus, + clientId: _clientId, + refresh: _refreshToken, + onConfigChanged: (account) => + ref.read(realAccountsProvider.notifier).save(), + downloadSizeLimit: 32 * 1024, + ); + } + + Future _refreshToken( + MailClient mailClient, + OauthToken expiredToken, + ) { + final providerId = expiredToken.provider; + if (providerId == null) { + throw MailException( + mailClient, + 'no provider registered for token $expiredToken', + ); + } + final provider = locator()[providerId]; + if (provider == null) { + throw MailException( + mailClient, + 'no provider "$providerId" found - token: $expiredToken', + ); + } + final oauthClient = provider.oauthClient; + if (oauthClient == null || !oauthClient.isEnabled) { + throw MailException( + mailClient, + 'provider $providerId has no valid OAuth configuration', + ); + } + + return oauthClient.refresh(expiredToken); + } + + Future _loadMailboxesFor(MailClient client) async { + //final account = getAccountFor(client.account); + // if (account == null) { + // if (kDebugMode) { + // print('Unable to find account for ${client.account}'); + // } + + // return; + // } + final mailboxTree = + await client.listMailboxesAsTree(createIntermediate: false); + // final settings = _settings; + // if (settings.folderNameSetting != FolderNameSetting.server) { + // _setMailboxNames(settings, client); + // } + + // _mailboxesPerAccount[account] = mailboxTree; + } +} diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart new file mode 100644 index 0000000..de38de1 --- /dev/null +++ b/lib/mail/provider.g.dart @@ -0,0 +1,204 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sourceHash() => r'5ca461e75566218bc833dcfd15f48e321a35ab69'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$Source extends BuildlessAsyncNotifier { + late final Account account; + late final Mailbox? mailbox; + + Future build({ + required Account account, + Mailbox? mailbox, + }); +} + +/// Provides the message source for the given account +/// +/// Copied from [Source]. +@ProviderFor(Source) +const sourceProvider = SourceFamily(); + +/// Provides the message source for the given account +/// +/// Copied from [Source]. +class SourceFamily extends Family> { + /// Provides the message source for the given account + /// + /// Copied from [Source]. + const SourceFamily(); + + /// Provides the message source for the given account + /// + /// Copied from [Source]. + SourceProvider call({ + required Account account, + Mailbox? mailbox, + }) { + return SourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + SourceProvider getProviderOverride( + covariant SourceProvider provider, + ) { + return call( + account: provider.account, + mailbox: provider.mailbox, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'sourceProvider'; +} + +/// Provides the message source for the given account +/// +/// Copied from [Source]. +class SourceProvider extends AsyncNotifierProviderImpl { + /// Provides the message source for the given account + /// + /// Copied from [Source]. + SourceProvider({ + required Account account, + Mailbox? mailbox, + }) : this._internal( + () => Source() + ..account = account + ..mailbox = mailbox, + from: sourceProvider, + name: r'sourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$sourceHash, + dependencies: SourceFamily._dependencies, + allTransitiveDependencies: SourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + SourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.mailbox, + }) : super.internal(); + + final Account account; + final Mailbox? mailbox; + + @override + Future runNotifierBuild( + covariant Source notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(Source Function() create) { + return ProviderOverride( + origin: this, + override: SourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AsyncNotifierProviderElement createElement() { + return _SourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SourceProvider && + other.account == account && + other.mailbox == mailbox; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SourceRef on AsyncNotifierProviderRef { + /// The parameter `account` of this provider. + Account get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _SourceProviderElement + extends AsyncNotifierProviderElement with SourceRef { + _SourceProviderElement(super.provider); + + @override + Account get account => (origin as SourceProvider).account; + @override + Mailbox? get mailbox => (origin as SourceProvider).mailbox; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/main.dart b/lib/main.dart index bbb491a..2b02349 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,224 +1,189 @@ import 'dart:async'; -import 'dart:io'; import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../l10n/app_localizations.g.dart'; +import 'account/providers.dart'; import 'app_lifecycle/provider.dart'; -import 'l10n/extension.dart'; +import 'l10n/app_localizations.g.dart'; import 'locator.dart'; import 'logger.dart'; import 'routes.dart'; -import 'screens/all_screens.dart'; -import 'services/app_service.dart'; +import 'screens/screens.dart'; import 'services/background_service.dart'; -import 'services/biometrics_service.dart'; import 'services/i18n_service.dart'; -import 'services/key_service.dart'; -import 'services/mail_service.dart'; -import 'services/navigation_service.dart'; -import 'services/notification_service.dart'; import 'services/scaffold_messenger_service.dart'; import 'settings/provider.dart'; import 'settings/theme/provider.dart'; -import 'widgets/inherited_widgets.dart'; // AppStyles appStyles = AppStyles.instance; void main() { setupLocator(); runApp( const ProviderScope( - child: MyApp(), + child: MailyApp(), ), ); } -class MyApp extends ConsumerStatefulWidget { - const MyApp({super.key}); +class MailyApp extends HookConsumerWidget { + const MailyApp({super.key}); @override - ConsumerState createState() => _MyAppState(); + Widget build(BuildContext context, WidgetRef ref) { + useOnAppLifecycleStateChange((previous, current) { + logger.d('AppLifecycleState changed from $previous to $current'); + ref.read(appLifecycleStateProvider.notifier).state = current; + }); + + final themeSettingsData = ref.watch(themeProvider); + final languageTag = + ref.watch(settingsProvider.select((settings) => settings.languageTag)); + + final app = PlatformSnackApp.router( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + debugShowCheckedModeBanner: false, + title: 'Maily', + routerConfig: Routes.routerConfig, + scaffoldMessengerKey: + locator().scaffoldMessengerKey, + // builder: (context, child) => Consumer( + // builder: (context, ref, child) { + // final languageTag = + // ref.watch(settingsProvider.select((value) => value.languageTag)); + // final usedChild = child ?? const SplashScreen(); + + // return languageTag == null + // ? usedChild + // : Localizations.override( + // context: context, + // locale: Locale(languageTag), + // child: usedChild, + // ); + // }, + // ), + materialTheme: themeSettingsData.lightTheme, + materialDarkTheme: themeSettingsData.darkTheme, + materialThemeMode: themeSettingsData.themeMode, + cupertinoTheme: CupertinoThemeData( + brightness: themeSettingsData.brightness, + applyThemeToAll: true, + //TODO support theming on Cupertino + ), + ); + if (languageTag == null) { + return app; + } + + return Localizations.override( + context: context, + locale: Locale(languageTag), + child: app, + ); + } } -class _MyAppState extends ConsumerState with WidgetsBindingObserver { - late Future _appInitialization; - Locale? _locale; +class InitializationScreen extends ConsumerStatefulWidget { + const InitializationScreen({super.key}); + + @override + ConsumerState createState() => _InitializationScreen(); +} + +class _InitializationScreen extends ConsumerState { + late Future _appInitialization; bool _isInitialized = false; @override void initState() { - WidgetsBinding.instance.addObserver(this); _appInitialization = _initApp(); super.initState(); } - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (_isInitialized) { - final settings = ref.read(settingsProvider); - locator().didChangeAppLifecycleState(state, settings); - ref.read(appLifecycleStateProvider.notifier).state = state; - } - } - - Future _initApp() async { + Future _initApp() async { await ref.read(settingsProvider.notifier).init(); if (context.mounted) { ref.read(themeProvider.notifier).init(context); } + await ref.read(realAccountsProvider.notifier).init(); + final settings = ref.read(settingsProvider); + final i18nService = locator(); - final languageTag = settings.languageTag; - if (languageTag != null) { - final settingsLocale = AppLocalizations.supportedLocales - .firstWhereOrNull((l) => l.toLanguageTag() == languageTag); - if (settingsLocale != null) { - final settingsLocalizations = - await AppLocalizations.delegate.load(settingsLocale); - i18nService.init(settingsLocalizations, settingsLocale); - setState(() { - _locale = settingsLocale; - }); - } - } - final mailService = locator(); - // key service is required before mail service due to Oauth configs - await locator().init(); - await mailService.init(i18nService.localizations, settings); - - if (mailService.messageSource != null) { - final state = MailServiceWidget.of(context); - if (state != null) { - state - ..account = mailService.currentAccount - ..accounts = mailService.accounts; - } - // on ios show the app drawer: - if (Platform.isIOS) { - await locator() - .push(Routes.appDrawer, replace: true); - } - - /// the app has at least one configured account - unawaited(locator().push( - Routes.messageSource, - arguments: mailService.messageSource, - fade: true, - replace: !Platform.isIOS, - )); - // check for a tapped notification that started the app: - final notificationInitResult = - await locator().init(); - if (notificationInitResult != - NotificationServiceInitResult.appLaunchedByNotification) { - // the app has not been launched by a notification - await locator().checkForShare(); - } - if (settings.enableBiometricLock) { - unawaited(locator().push(Routes.lockScreen)); - final didAuthenticate = - await locator().authenticate(); - if (didAuthenticate) { - locator().pop(); - } - } - } else { - // this app has no mail accounts yet, so switch to welcome screen: - unawaited(locator() - .push(Routes.welcome, fade: true, replace: true)); + final languageTag = settings.languageTag ?? 'en'; + final settingsLocale = AppLocalizations.supportedLocales + .firstWhereOrNull((l) => l.toLanguageTag() == languageTag); + if (settingsLocale != null) { + final settingsLocalizations = + await AppLocalizations.delegate.load(settingsLocale); + i18nService.init(settingsLocalizations, settingsLocale); } + // final mailService = locator(); + // // key service is required before mail service due to Oauth configs + // await locator().init(); + // await mailService.init(i18nService.localizations, settings); + + // if (mailService.messageSource != null) { + // // on ios show the app drawer: + // if (Platform.isIOS) { + // await locator() + // .push(Routes.appDrawer, replace: true); + // } + + // /// the app has at least one configured account + // unawaited(locator().push( + // Routes.messageSource, + // arguments: mailService.messageSource, + // fade: true, + // replace: !Platform.isIOS, + // )); + // // check for a tapped notification that started the app: + // final notificationInitResult = + // await locator().init(); + // if (notificationInitResult != + // NotificationServiceInitResult.appLaunchedByNotification) { + // // the app has not been launched by a notification + // await locator().checkForShare(); + // } + // if (settings.enableBiometricLock) { + // unawaited(locator().push(Routes.lockScreen)); + // final didAuthenticate = + // await locator().authenticate(); + // if (didAuthenticate) { + // locator().pop(); + // } + // } + // } else { + // // this app has no mail accounts yet, so switch to welcome screen: + // unawaited(locator() + // .push(Routes.welcome, fade: true, replace: true)); + // } if (BackgroundService.isSupported) { await locator().init(); } + // final usedContext = Routes.navigatorKey.currentContext ?? context; + // if (usedContext.mounted) { + // usedContext.pushReplacement(Routes.home); + // } + logger.d('App initialized'); _isInitialized = true; - - return mailService; } @override - Widget build(BuildContext context) { - final themeSettingsData = ref.watch(themeProvider); - - return PlatformSnackApp( - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - locale: _locale, - debugShowCheckedModeBanner: false, - title: 'Maily', - onGenerateRoute: AppRouter.generateRoute, - initialRoute: Routes.splash, - navigatorKey: locator().navigatorKey, - scaffoldMessengerKey: - locator().scaffoldMessengerKey, - builder: (context, child) { - locator().init( - context.text, - Localizations.localeOf(context), - ); - child ??= FutureBuilder( - future: _appInitialization, - builder: (context, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return const SplashScreen(); - case ConnectionState.done: - // in the meantime the app has navigated away - break; - } - - return const SizedBox.shrink(); - }, - ); - - final mailService = locator(); - - return MailServiceWidget( - account: mailService.currentAccount, - accounts: mailService.accounts, - messageSource: mailService.messageSource, - child: child, - ); - }, - // home: Builder( - // builder: (context) { - // locator().init( - // context.text!, Localizations.localeOf(context)); - // return FutureBuilder( - // future: _appInitialization, - // builder: (context, snapshot) { - // switch (snapshot.connectionState) { - // case ConnectionState.none: - // case ConnectionState.waiting: - // case ConnectionState.active: - // return SplashScreen(); - // case ConnectionState.done: - // // in the meantime the app has navigated away - // break; - // } - // return Container(); - // }, - // ); - // }, - // ), - materialTheme: themeSettingsData.lightTheme, - materialDarkTheme: themeSettingsData.darkTheme, - materialThemeMode: themeSettingsData.themeMode, - cupertinoTheme: CupertinoThemeData( - brightness: themeSettingsData.brightness, - //TODO support theming on Cupertino - ), - ); - } + Widget build(BuildContext context) => FutureBuilder( + future: _appInitialization, + builder: (context, snapshot) { + final done = snapshot.connectionState == ConnectionState.done; + if (!done) { + return const SplashScreen(); + } + + return const HomeScreen(); + }, + ); } diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index f765f89..e894c0a 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -190,6 +190,7 @@ abstract class AsyncMimeSource { /// Keeps messages in a temporary cache abstract class CachedMimeSource extends AsyncMimeSource { + /// Creates a new cached mime source CachedMimeSource({int maxCacheSize = IndexedCache.defaultMaxCacheSize}) : cache = IndexedCache(maxCacheSize: maxCacheSize); final IndexedCache cache; @@ -200,6 +201,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { if (existingMessage != null) { return Future.value(existingMessage); } + return loadMessage(index); } @@ -210,6 +212,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { Future onMessageArrived(MimeMessage message, {int? index}) async { final usedIndex = await addMessage(message, index: index); notifySubscriberOnMessageArrived(message); + return handleOnMessageArrived(usedIndex, message); } @@ -225,11 +228,13 @@ abstract class CachedMimeSource extends AsyncMimeSource { (cache[i]?.decodeDate() ?? now).isAfter(messageDate)) { i++; } + return i; } final usedIndex = index ?? findIndex(message.decodeDate()); cache.insert(usedIndex, message); + return Future.value(usedIndex); } @@ -267,6 +272,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { messages.add(mime); } } + return handleOnMessagesVanished(messages); } @@ -295,6 +301,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { existing.flags = message.flags; } notifySubscribersOnMessageFlagsUpdated(existing ?? message); + return Future.value(); } @@ -324,34 +331,37 @@ abstract class CachedMimeSource extends AsyncMimeSource { // TODO(RV): Should a reload also be triggered when other messages are not cached? cache.clear(); notifySubscribersOnCacheInvalidated(); + return init(); } // ensure not to change the underlying set of messages in case overrides // want to handle the messages as well: - messages = [...messages]; + final messagesCopy = [...messages]; // detect new messages: - final newMessages = messages + final newMessages = messagesCopy .where((message) => (message.uid ?? 0) > firstCachedUid) .toList(); for (var i = newMessages.length; --i >= 0;) { final message = newMessages.elementAt(i); - onMessageArrived(message); - messages.remove(message); + await onMessageArrived(message); + messagesCopy.remove(message); } - if (messages.isEmpty) { + if (messagesCopy.isEmpty) { // only new messages have appeared... probably a sign to reload completely return; } final cachedMessages = List.generate( - messages.length, (index) => cache[index + newMessages.length]); + messagesCopy.length, + (index) => cache[index + newMessages.length], + ); // detect removed messages: final removedMessages = List.from( cachedMessages.where((cached) => cached != null && - messages.firstWhereOrNull((m) => m.guid == cached.guid) == null), + messagesCopy.firstWhereOrNull((m) => m.guid == cached.guid) == null), ); if (removedMessages.isNotEmpty) { final sequence = MessageSequence(isUidSequence: true); @@ -363,7 +373,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { cachedMessages.remove(removed); } if (sequence.isNotEmpty) { - onMessagesVanished(sequence); + await onMessagesVanished(sequence); } } @@ -372,10 +382,10 @@ abstract class CachedMimeSource extends AsyncMimeSource { for (final cached in cachedMessages) { if (cached != null) { final newMessage = - messages.firstWhereOrNull((m) => m.guid == cached.guid); + messagesCopy.firstWhereOrNull((m) => m.guid == cached.guid); if (newMessage != null && !areListsEqual(newMessage.flags, cached.flags)) { - onMessageFlagsUpdated(newMessage); + await onMessageFlagsUpdated(newMessage); } } } @@ -384,10 +394,11 @@ abstract class CachedMimeSource extends AsyncMimeSource { /// Keeps messages in a temporary cache and accesses them page-wise abstract class PagedCachedMimeSource extends CachedMimeSource { + /// Creates a new paged cached mime source PagedCachedMimeSource({ this.pageSize = 30, - int maxCacheSize = IndexedCache.defaultMaxCacheSize, - }) : super(maxCacheSize: maxCacheSize); + super.maxCacheSize, + }); /// The size of a single page final int pageSize; @@ -400,6 +411,7 @@ abstract class PagedCachedMimeSource extends CachedMimeSource { final sequence = MessageSequence.fromPage(pageIndex + 1, pageSize, size); final future = loadMessages(sequence); _pageLoadersByPageIndex[pageIndex] = future; + return future; } @@ -407,22 +419,23 @@ abstract class PagedCachedMimeSource extends CachedMimeSource { final completer = _pageLoadersByPageIndex[pageIndex] ?? queue(pageIndex); try { final messages = await completer; - int pageEndIndex = pageIndex * pageSize + messages.length - 1; + final int pageEndIndex = pageIndex * pageSize + messages.length - 1; if (cache[pageEndIndex] == null) { // messages have not been added by another thread yet: final receivingDate = DateTime.now(); messages.sort((m1, m2) => (m1.decodeDate() ?? receivingDate) .compareTo(m2.decodeDate() ?? receivingDate)); - _pageLoadersByPageIndex.remove(pageIndex); + await _pageLoadersByPageIndex.remove(pageIndex); for (int i = 0; i < messages.length; i++) { final cacheIndex = pageEndIndex - i; final message = messages[i]; cache[cacheIndex] = message; } } + return messages[pageEndIndex - index]; } on MailException { - _pageLoadersByPageIndex.remove(pageIndex); + await _pageLoadersByPageIndex.remove(pageIndex); rethrow; } } @@ -474,11 +487,11 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { } }); _mailUpdatedEventSubscription = - mailClient.eventBus.on().listen(((event) { + mailClient.eventBus.on().listen((event) { if (event.mailClient == mailClient) { onMessageFlagsUpdated(event.message); } - })); + }); _mailReconnectedEventSubscription = mailClient.eventBus .on() .listen(_onMailReconnected); @@ -506,7 +519,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { // and resync will be aborted, therefore. return; } - resyncMessagesManually(messages); + await resyncMessagesManually(messages); } } @@ -630,14 +643,12 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { ); @override - Future handleOnMessageArrived(int index, MimeMessage message) { - return Future.value(); - } + Future handleOnMessageArrived(int index, MimeMessage message) => + Future.value(); @override - Future handleOnMessagesVanished(List messages) { - return Future.value(); - } + Future handleOnMessagesVanished(List messages) => + Future.value(); @override Future fetchMessageContents( diff --git a/lib/models/async_mime_source_factory.dart b/lib/models/async_mime_source_factory.dart index b79bdce..6062f2f 100644 --- a/lib/models/async_mime_source_factory.dart +++ b/lib/models/async_mime_source_factory.dart @@ -1,7 +1,8 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/models/async_mime_source.dart'; -import 'package:enough_mail_app/models/offline_mime_source.dart'; -import 'package:enough_mail_app/models/offline_mime_storage_factory.dart'; + +import 'async_mime_source.dart'; +import 'offline_mime_source.dart'; +import 'offline_mime_storage_factory.dart'; /// Creates [AsyncMimeSource] instances class AsyncMimeSourceFactory { @@ -21,13 +22,16 @@ class AsyncMimeSourceFactory { /// Creates a new mailbox-based mime source AsyncMimeSource createMailboxMimeSource( - MailClient mailClient, Mailbox mailbox) { + MailClient mailClient, + Mailbox mailbox, + ) { final onlineSource = AsyncMailboxMimeSource(mailbox, mailClient); if (_isOfflineModeSupported) { final storage = _storageFactory.getMailboxStorage( mailAccount: mailClient.account, mailbox: mailbox, ); + return OfflineMailboxMimeSource( mailAccount: mailClient.account, mailbox: mailbox, @@ -35,6 +39,7 @@ class AsyncMimeSourceFactory { storage: storage, ); } + return onlineSource; } diff --git a/lib/models/compose_data.dart b/lib/models/compose_data.dart index 984702c..42e7c1a 100644 --- a/lib/models/compose_data.dart +++ b/lib/models/compose_data.dart @@ -8,15 +8,6 @@ enum ComposeMode { plainText, html } typedef MessageFinalizer = void Function(MessageBuilder messageBuilder); class ComposeData { - Message? get originalMessage => - (originalMessages?.isNotEmpty ?? false) ? originalMessages!.first : null; - final List? originalMessages; - final MessageBuilder messageBuilder; - final ComposeAction action; - final String? resumeText; - final Future? future; - final ComposeMode composeMode; - List? finalizers; ComposeData( this.originalMessages, @@ -27,13 +18,20 @@ class ComposeData { this.finalizers, this.composeMode = ComposeMode.html, }); + Message? get originalMessage => + (originalMessages?.isNotEmpty ?? false) ? originalMessages!.first : null; + final List? originalMessages; + final MessageBuilder messageBuilder; + final ComposeAction action; + final String? resumeText; + final Future? future; + final ComposeMode composeMode; + List? finalizers; - ComposeData resume(String text, {ComposeMode? composeMode}) { - return ComposeData(originalMessages, messageBuilder, action, + ComposeData resume(String text, {ComposeMode? composeMode}) => ComposeData(originalMessages, messageBuilder, action, resumeText: text, finalizers: finalizers, composeMode: composeMode ?? this.composeMode); - } /// Adds a finalizer /// diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 7cd2e4d..6eff503 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -27,12 +27,10 @@ class Contact { } class ContactManager { - final List addresses; ContactManager(this.addresses); + final List addresses; - Iterable find(String search) { - return addresses.where((address) => + Iterable find(String search) => addresses.where((address) => address.email.contains(search) || (address.hasPersonalName && address.personalName!.contains(search))); - } } diff --git a/lib/models/date_sectioned_message_source.dart b/lib/models/date_sectioned_message_source.dart index 08ba69f..a1f33d2 100644 --- a/lib/models/date_sectioned_message_source.dart +++ b/lib/models/date_sectioned_message_source.dart @@ -1,8 +1,8 @@ import 'dart:math'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/message_source.dart'; -import 'package:enough_mail_app/services/date_service.dart'; +import '../locator.dart'; +import 'message_source.dart'; +import '../services/date_service.dart'; import 'package:flutter/foundation.dart'; import '../services/i18n_service.dart'; @@ -10,6 +10,10 @@ import 'message.dart'; import 'message_date_section.dart'; class DateSectionedMessageSource extends ChangeNotifier { + + DateSectionedMessageSource(this.messageSource) { + messageSource.addListener(_update); + } final MessageSource messageSource; int _numberOfSections = 0; int get size { @@ -23,10 +27,6 @@ class DateSectionedMessageSource extends ChangeNotifier { late List _sections; bool isInitialized = false; - DateSectionedMessageSource(this.messageSource) { - messageSource.addListener(_update); - } - Future init() async { try { await messageSource.init(); @@ -182,8 +182,8 @@ class DateSectionedMessageSource extends ChangeNotifier { } class SectionElement { - final MessageDateSection? section; - final Message? message; SectionElement(this.section, this.message); + final MessageDateSection? section; + final Message? message; } diff --git a/lib/models/hive/hive_mime_storage.dart b/lib/models/hive/hive_mime_storage.dart index f2db0fc..cace10e 100644 --- a/lib/models/hive/hive_mime_storage.dart +++ b/lib/models/hive/hive_mime_storage.dart @@ -377,7 +377,7 @@ class StorageMessageEnvelope { Envelope toEnvelope() { List? parseAddresses(List? input) => - input?.map((s) => MailAddress.parse(s)).toList(); + input?.map(MailAddress.parse).toList(); MailAddress? parse(String? input) { if (input == null) { return null; diff --git a/lib/models/mail_operation.dart b/lib/models/mail_operation.dart index 1c06271..1bd3a11 100644 --- a/lib/models/mail_operation.dart +++ b/lib/models/mail_operation.dart @@ -3,9 +3,8 @@ import 'dart:convert'; import 'package:enough_mail/enough_mail.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:enough_mail_app/models/offline_mime_storage.dart'; - import 'hive/hive_mime_storage.dart'; +import 'offline_mime_storage.dart'; part 'mail_operation.g.dart'; @@ -52,7 +51,7 @@ class MailOperationQueue { Future _storeQueue() async { final list = _queue.map((e) => e.toJson()).toList(); final value = json.encode(list); - TextHiveStorage.instance.save(_keyQueue, value); + await TextHiveStorage.instance.save(_keyQueue, value); } /// Loads the [MailOperationQueue] @@ -62,7 +61,13 @@ class MailOperationQueue { return MailOperationQueue._(<_QueuedMailOperation>[]); } final data = json.decode(savedData) as List; - final entries = data.map((e) => _QueuedMailOperation.fromJson(e)).toList(); + final entries = data + .map( + // ignore: unnecessary_lambdas + (json) => _QueuedMailOperation.fromJson(json), + ) + .toList(); + return MailOperationQueue._(entries); } } @@ -110,16 +115,16 @@ class StoreFlagsOperation extends MailOperation { required this.sequence, }) : super(MailOperationType.storeFlags); + // De-serialized the JSON to a store flags operation + factory StoreFlagsOperation.fromJson(Map json) => + _$StoreFlagsOperationFromJson(json); + /// The flags to store final List flags; /// The sequence of messages final MessageSequence sequence; - // De-serialized the JSON to a store flags operation - factory StoreFlagsOperation.fromJson(Map json) => - _$StoreFlagsOperationFromJson(json); - /// Serializes the data to JSON Map toJson() => _$StoreFlagsOperationToJson(this); diff --git a/lib/models/message.dart b/lib/models/message.dart index d572000..cfbfb75 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,14 +1,11 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart' as url_launcher; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/widgets/inherited_widgets.dart'; - -import 'account.dart'; +import '../account/model.dart'; +import '../locator.dart'; +import '../services/mail_service.dart'; import 'message_source.dart'; class Message extends ChangeNotifier { @@ -48,6 +45,7 @@ class Message extends ChangeNotifier { infos.addAll(inlineAttachments); _attachments = infos; } + return infos; } @@ -65,7 +63,7 @@ class Message extends ChangeNotifier { bool get hasNext => sourceIndex < source.size; Future get next => source.next(this); - bool get hasPrevious => (sourceIndex > 0); + bool get hasPrevious => sourceIndex > 0; Future get previous => source.previous(this); bool get isSeen => mimeMessage.isSeen; @@ -153,17 +151,12 @@ class Message extends ChangeNotifier { isSelected = !_isSelected; } - static Message? of(BuildContext context) => - MessageWidget.of(context)?.message; - @override - String toString() { - return '${mailClient.account.name}[$sourceIndex]=$mimeMessage'; - } + String toString() => '${mailClient.account.name}[$sourceIndex]=$mimeMessage'; } extension NewsLetter on MimeMessage { - bool get isEmpty => (mimeData == null && envelope == null && body == null); + bool get isEmpty => mimeData == null && envelope == null && body == null; /// Checks if this is a newsletter with a `list-unsubscribe` header. bool get isNewsletter => hasHeader('list-unsubscribe'); @@ -172,13 +165,9 @@ extension NewsLetter on MimeMessage { bool get isNewsLetterSubscribable => hasHeader('list-subscribe'); /// Retrieves the List-Unsubscribe URIs, if present - List? decodeListUnsubscribeUris() { - return _decodeUris('list-unsubscribe'); - } + List? decodeListUnsubscribeUris() => _decodeUris('list-unsubscribe'); - List? decodeListSubscribeUris() { - return _decodeUris('list-subscribe'); - } + List? decodeListSubscribeUris() => _decodeUris('list-subscribe'); String? decodeListName() { final listPost = decodeHeaderValue('list-post'); @@ -233,9 +222,7 @@ extension NewsLetter on MimeMessage { return uris; } - bool hasListUnsubscribePostHeader() { - return hasHeader('list-unsubscribe-post'); - } + bool hasListUnsubscribePostHeader() => hasHeader('list-unsubscribe-post'); Future unsubscribe(MailClient client) async { final uris = decodeListUnsubscribeUris(); @@ -249,7 +236,7 @@ extension NewsLetter on MimeMessage { orElse: () => null)); // unsubscribe via one click POST request: https://tools.ietf.org/html/rfc8058 if (hasListUnsubscribePostHeader() && httpUri != null) { - var response = await unsubscribeWithOneClick(httpUri); + final response = await unsubscribeWithOneClick(httpUri); if (response.statusCode == 200) { return true; } @@ -295,7 +282,7 @@ extension NewsLetter on MimeMessage { } Future unsubscribeWithOneClick(Uri uri) { - var request = http.MultipartRequest('POST', uri) + final request = http.MultipartRequest('POST', uri) ..fields['List-Unsubscribe'] = 'One-Click'; return request.send(); } @@ -316,8 +303,7 @@ extension NewsLetter on MimeMessage { } class DisplayMessageArguments { + const DisplayMessageArguments(this.message, this.blockExternalContent); final Message message; final bool blockExternalContent; - - const DisplayMessageArguments(this.message, this.blockExternalContent); } diff --git a/lib/models/message_date_section.dart b/lib/models/message_date_section.dart index a601455..3c6da4d 100644 --- a/lib/models/message_date_section.dart +++ b/lib/models/message_date_section.dart @@ -1,9 +1,9 @@ -import 'package:enough_mail_app/services/date_service.dart'; +import '../services/date_service.dart'; class MessageDateSection { + + MessageDateSection(this.range, this.date, this.sourceStartIndex); final DateSectionRange range; final DateTime date; final int sourceStartIndex; - - MessageDateSection(this.range, this.date, this.sourceStartIndex); } diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 1265497..bff5120 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -1,14 +1,13 @@ +import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/notification_service.dart'; -import 'package:enough_mail_app/services/scaffold_messenger_service.dart'; -import 'package:enough_mail_app/widgets/inherited_widgets.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:collection/collection.dart' show IterableExtension; + +import '../account/model.dart'; +import '../locator.dart'; +import '../services/i18n_service.dart'; +import '../services/notification_service.dart'; +import '../services/scaffold_messenger_service.dart'; import '../util/indexed_cache.dart'; -import 'account.dart'; import 'async_mime_source.dart'; import 'message.dart'; @@ -25,17 +24,13 @@ abstract class MessageSource extends ChangeNotifier /// Retrieves the parent source's name String? get parentName => _parentMessageSource?.name; - /// Looks up the closest message source in the widget tree - static MessageSource? of(BuildContext context) => - MessageSourceWidget.of(context)?.messageSource; - /// The number of messages in this source int get size; /// Is the source empty? /// /// Compare [size] - bool get isEmpty => (size == 0); + bool get isEmpty => size == 0; /// The cache for messages final cache = IndexedCache(); @@ -102,6 +97,7 @@ abstract class MessageSource extends ChangeNotifier message = await loadMessage(index); cache[index] = message; } + return message; } @@ -113,6 +109,7 @@ abstract class MessageSource extends ChangeNotifier if (current.sourceIndex >= size - 1) { return Future.value(); } + return getMessageAt(current.sourceIndex + 1); } @@ -121,6 +118,7 @@ abstract class MessageSource extends ChangeNotifier if (current.sourceIndex == 0) { return Future.value(); } + return getMessageAt(current.sourceIndex - 1); } @@ -133,7 +131,9 @@ abstract class MessageSource extends ChangeNotifier if (removed) { final sourceIndex = message.sourceIndex; cache.forEachWhere( - (msg) => msg.sourceIndex > sourceIndex, (msg) => msg.sourceIndex--); + (msg) => msg.sourceIndex > sourceIndex, + (msg) => msg.sourceIndex--, + ); } final parent = _parentMessageSource; if (parent != null) { @@ -143,6 +143,7 @@ abstract class MessageSource extends ChangeNotifier if (removed && notify) { notifyListeners(); } + return removed; } @@ -164,8 +165,11 @@ abstract class MessageSource extends ChangeNotifier } @override - void onMailArrived(MimeMessage mime, AsyncMimeSource source, - {int index = 0}) { + void onMailArrived( + MimeMessage mime, + AsyncMimeSource source, { + int index = 0, + }) { // the source index is 0 since this is the new first message: final message = Message(mime, source.mailClient, this, index); insertIntoCache(index, message); @@ -181,10 +185,10 @@ abstract class MessageSource extends ChangeNotifier /// /// Just forwards to [deleteMessages] @Deprecated('use deleteMessages instead') - Future deleteMessage(Message message) { - return deleteMessages( - [message], locator().localizations.resultDeleted); - } + Future deleteMessage(Message message) => deleteMessages( + [message], + locator().localizations.resultDeleted, + ); /// Deletes the given messages Future deleteMessages( @@ -200,6 +204,7 @@ abstract class MessageSource extends ChangeNotifier ); } notifyListeners(); + return _deleteMessages(messages, notification); } @@ -230,24 +235,28 @@ abstract class MessageSource extends ChangeNotifier ); } - Future markAsJunk(Message message) { - return moveMessageToFlag(message, MailboxFlag.junk, - locator().localizations.resultMovedToJunk); - } + Future markAsJunk(Message message) => moveMessageToFlag( + message, + MailboxFlag.junk, + locator().localizations.resultMovedToJunk, + ); - Future markAsNotJunk(Message message) { - return moveMessageToFlag(message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox); - } + Future markAsNotJunk(Message message) => moveMessageToFlag( + message, + MailboxFlag.inbox, + locator().localizations.resultMovedToInbox, + ); Future moveMessageToFlag( Message message, MailboxFlag targetMailboxFlag, String notification, - ) { - return moveMessage(message, - message.mailClient.getMailbox(targetMailboxFlag)!, notification); - } + ) => + moveMessage( + message, + message.mailClient.getMailbox(targetMailboxFlag)!, + notification, + ); Future moveMessage( Message message, @@ -362,15 +371,17 @@ abstract class MessageSource extends ChangeNotifier } } - Future moveToInbox(Message message) async { - return moveMessageToFlag(message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox); - } + Future moveToInbox(Message message) async => moveMessageToFlag( + message, + MailboxFlag.inbox, + locator().localizations.resultMovedToInbox, + ); - Future archive(Message message) { - return moveMessageToFlag(message, MailboxFlag.archive, - locator().localizations.resultArchived); - } + Future archive(Message message) => moveMessageToFlag( + message, + MailboxFlag.archive, + locator().localizations.resultArchived, + ); Future markAsSeen(Message msg, bool isSeen) { final source = getMimeSource(msg); @@ -379,6 +390,7 @@ abstract class MessageSource extends ChangeNotifier if (isSeen) { locator().cancelNotificationForMailMessage(msg); } + return source.store([msg.mimeMessage], [MessageFlags.seen]); } msg.isSeen = isSeen; @@ -388,6 +400,7 @@ abstract class MessageSource extends ChangeNotifier if (parent != null && parentMsg != null) { return parent.markAsSeen(parentMsg, isSeen); } + return msg.mailClient.flagMessage(msg.mimeMessage, isSeen: isSeen); } @@ -405,6 +418,7 @@ abstract class MessageSource extends ChangeNotifier Future markAsFlagged(Message msg, bool isFlagged) { onMarkedAsFlagged(msg, isFlagged); + return msg.mailClient.flagMessage(msg.mimeMessage, isFlagged: isFlagged); } @@ -428,20 +442,29 @@ abstract class MessageSource extends ChangeNotifier notificationService.cancelNotificationForMailMessage(msg); } } - return storeMessageFlags(messages, [MessageFlags.seen], - action: isSeen ? StoreAction.add : StoreAction.remove); + + return storeMessageFlags( + messages, + [MessageFlags.seen], + action: isSeen ? StoreAction.add : StoreAction.remove, + ); } Future markMessagesAsFlagged(List messages, bool flagged) { for (final msg in messages) { msg.isFlagged = flagged; } - return storeMessageFlags(messages, [MessageFlags.flagged], - action: flagged ? StoreAction.add : StoreAction.remove); + + return storeMessageFlags( + messages, + [MessageFlags.flagged], + action: flagged ? StoreAction.add : StoreAction.remove, + ); } Map> orderByMimeSource( - List messages) { + List messages, + ) { final mimesBySource = >{}; for (final message in messages) { final source = getMimeSource(message); @@ -459,6 +482,7 @@ abstract class MessageSource extends ChangeNotifier mimesBySource[source] = [message.mimeMessage]; } } + return mimesBySource; } @@ -475,8 +499,11 @@ abstract class MessageSource extends ChangeNotifier // return sequenceByClient; // } - Future storeMessageFlags(List messages, List flags, - {StoreAction action = StoreAction.add}) { + Future storeMessageFlags( + List messages, + List flags, { + StoreAction action = StoreAction.add, + }) { final messagesBySource = orderByMimeSource(messages); final futures = >[]; for (final source in messagesBySource.keys) { @@ -519,6 +546,7 @@ abstract class MessageSource extends ChangeNotifier if (mimeSource == null) { throw Exception('Unable to detect mime source from $message'); } + return mimeSource.fetchMessageContents( message.mimeMessage, maxSize: maxSize, @@ -538,9 +566,12 @@ abstract class MessageSource extends ChangeNotifier class MailboxMessageSource extends MessageSource { MailboxMessageSource.fromMimeSource( - this._mimeSource, String description, String name, - {MessageSource? parent, bool isSearch = false}) - : super(parent: parent, isSearch: isSearch) { + this._mimeSource, + String description, + String name, { + super.parent, + super.isSearch, + }) { _description = description; _name = name; _mimeSource.addSubscriber(this); @@ -562,6 +593,7 @@ class MailboxMessageSource extends MessageSource { Future loadMessage(int index) async { //print('get uncached $index'); final mime = await _mimeSource.getMessage(index); + return Message(mime, _mimeSource.mailClient, this, index); } @@ -587,6 +619,7 @@ class MailboxMessageSource extends MessageSource { parent.removeMime(mime, removedMessage.mailClient); } } + return results; } @@ -622,6 +655,7 @@ class MailboxMessageSource extends MessageSource { MessageSource search(MailSearch search) { final searchSource = _mimeSource.search(search); final localizations = locator().localizations; + return MailboxMessageSource.fromMimeSource( searchSource, localizations.searchQueryDescription(name!), @@ -632,9 +666,7 @@ class MailboxMessageSource extends MessageSource { } @override - AsyncMimeSource? getMimeSource(Message message) { - return _mimeSource; - } + AsyncMimeSource? getMimeSource(Message message) => _mimeSource; @override void clear() { @@ -658,9 +690,14 @@ class _MultipleMessageSourceId { /// Provides a unified source of several messages sources. /// Each message is ordered by date class MultipleMessageSource extends MessageSource { - MultipleMessageSource(this.mimeSources, String name, MailboxFlag? flag, - {MessageSource? parent, bool isSearch = false}) - : super(parent: parent, isSearch: isSearch) { + /// Creates a new [MultipleMessageSource] + MultipleMessageSource( + this.mimeSources, + String name, + MailboxFlag? flag, { + super.parent, + super.isSearch, + }) { for (final s in mimeSources) { s.addSubscriber(this); _multipleMimeSources.add(_MultipleMimeSource(s)); @@ -689,14 +726,16 @@ class MultipleMessageSource extends MessageSource { complete += s.size; } //print('MultipleMessageSource.size: $complete'); + return complete; } @override void dispose() { for (final s in mimeSources) { - s.removeSubscriber(this); - s.dispose(); + s + ..removeSubscriber(this) + ..dispose(); } super.dispose(); } @@ -745,11 +784,12 @@ class MultipleMessageSource extends MessageSource { if (index < _indicesCache.length) { final id = _indicesCache[index]; final mime = await id.source.getMessage(id.index); + return Message(mime, id.source.mailClient, this, index); } // print( // 'get uncached $index with lastUncachedIndex=$_lastUncachedIndex and size $size'); - int diff = (index - _indicesCache.length); + int diff = index - _indicesCache.length; while (diff > 0) { final sourceIndex = index - diff; await getMessageAt(sourceIndex); @@ -761,7 +801,8 @@ class MultipleMessageSource extends MessageSource { _nextCalls[index] = nextCall; } final nextMessage = await nextCall; - _nextCalls.remove(index); + await _nextCalls.remove(index); + return nextMessage; } @@ -770,6 +811,7 @@ class MultipleMessageSource extends MessageSource { @override bool removeFromCache(Message message, {bool notify = true}) { _indicesCache.removeAt(message.sourceIndex); + return super.removeFromCache(message, notify: notify); } @@ -792,6 +834,7 @@ class MultipleMessageSource extends MessageSource { } final futureResults = await Future.wait(futures); final results = []; + for (final result in futureResults) { results.addAll(result); } @@ -799,10 +842,8 @@ class MultipleMessageSource extends MessageSource { } @override - AsyncMimeSource getMimeSource(Message message) { - return mimeSources - .firstWhere((source) => source.mailClient == message.mailClient); - } + AsyncMimeSource getMimeSource(Message message) => mimeSources + .firstWhere((source) => source.mailClient == message.mailClient); @override bool get shouldBlockImages => @@ -844,6 +885,7 @@ class MultipleMessageSource extends MessageSource { ); searchMessageSource._description = localizations.searchQueryDescription(name ?? ''); + return searchMessageSource; } @@ -874,8 +916,11 @@ class MultipleMessageSource extends MessageSource { } @override - void onMailArrived(MimeMessage mime, AsyncMimeSource source, - {int index = 0}) { + void onMailArrived( + MimeMessage mime, + AsyncMimeSource source, { + int index = 0, + }) { // find out index: final mimeDate = mime.decodeDate() ?? DateTime.now(); var msgIndex = 0; @@ -916,14 +961,14 @@ class _MultipleMimeSourceMessage { } class _MultipleMimeSource { + _MultipleMimeSource(this.mimeSource); final AsyncMimeSource mimeSource; int _currentIndex = 0; _MultipleMimeSourceMessage? _currentMessage; - _MultipleMimeSource(this.mimeSource); - Future<_MultipleMimeSourceMessage?> peek() async { _currentMessage ??= await _next(); + return _currentMessage; } @@ -938,6 +983,7 @@ class _MultipleMimeSource { } _currentIndex++; final mime = await mimeSource.getMessage(index); + return _MultipleMimeSourceMessage(index, this, mime); } @@ -967,13 +1013,11 @@ class _MultipleMimeSource { } class SingleMessageSource extends MessageSource { - Message? singleMessage; SingleMessageSource(MessageSource? parent) : super(parent: parent); + Message? singleMessage; @override - Future loadMessage(int index) { - return Future.value(singleMessage); - } + Future loadMessage(int index) => Future.value(singleMessage); @override Future> deleteAllMessages({bool expunge = false}) { @@ -981,9 +1025,7 @@ class SingleMessageSource extends MessageSource { } @override - Future init() { - return Future.value(); - } + Future init() => Future.value(); @override bool get isArchive => false; @@ -1016,14 +1058,11 @@ class SingleMessageSource extends MessageSource { bool get supportsSearching => false; @override - AsyncMimeSource? getMimeSource(Message message) { - return _parentMessageSource?.getMimeSource(message); - } + AsyncMimeSource? getMimeSource(Message message) => + _parentMessageSource?.getMimeSource(message); @override - Future markAllMessagesSeen(bool seen) { - return Future.value(); - } + Future markAllMessagesSeen(bool seen) => Future.value(); @override void clear() { @@ -1037,8 +1076,8 @@ class SingleMessageSource extends MessageSource { } class ListMessageSource extends MessageSource { - late List messages; ListMessageSource(MessageSource parent) : super(parent: parent); + late List messages; void initWithMimeMessages( List mimeMessages, MailClient mailClient, @@ -1061,9 +1100,7 @@ class ListMessageSource extends MessageSource { } @override - Future init() { - return Future.value(); - } + Future init() => Future.value(); @override bool get isArchive => false; @@ -1095,14 +1132,11 @@ class ListMessageSource extends MessageSource { bool get supportsSearching => false; @override - AsyncMimeSource? getMimeSource(Message message) { - return _parentMessageSource?.getMimeSource(message); - } + AsyncMimeSource? getMimeSource(Message message) => + _parentMessageSource?.getMimeSource(message); @override - Future markAllMessagesSeen(bool seen) { - return Future.value(); - } + Future markAllMessagesSeen(bool seen) => Future.value(); @override void clear() { @@ -1116,9 +1150,8 @@ class ListMessageSource extends MessageSource { } class ErrorMessageSource extends MessageSource { - final Account account; - ErrorMessageSource(this.account); + final Account account; @override Future loadMessage(int index) { @@ -1139,9 +1172,7 @@ class ErrorMessageSource extends MessageSource { } @override - Future init() { - return Future.value(); - } + Future init() => Future.value(); @override bool get isArchive => false; diff --git a/lib/models/models.dart b/lib/models/models.dart index a9c9cc2..0f8ae89 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -1,5 +1,4 @@ export '../settings/model.dart'; -export 'account.dart'; export 'async_mime_source.dart'; export 'compose_data.dart'; export 'contact.dart'; diff --git a/lib/models/offline_mime_storage_factory.dart b/lib/models/offline_mime_storage_factory.dart index 8dbf6ef..a462767 100644 --- a/lib/models/offline_mime_storage_factory.dart +++ b/lib/models/offline_mime_storage_factory.dart @@ -1,6 +1,6 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/models/hive/hive.dart'; -import 'package:enough_mail_app/models/offline_mime_storage.dart'; +import 'hive/hive.dart'; +import 'offline_mime_storage.dart'; /// Provides access to storage facilities class OfflineMimeStorageFactory { @@ -11,7 +11,5 @@ class OfflineMimeStorageFactory { OfflineMimeStorage getMailboxStorage({ required MailAccount mailAccount, required Mailbox mailbox, - }) { - return HiveMailboxMimeStorage(mailAccount: mailAccount, mailbox: mailbox); - } + }) => HiveMailboxMimeStorage(mailAccount: mailAccount, mailbox: mailbox); } diff --git a/lib/models/sender.dart b/lib/models/sender.dart index 1788705..c3bac4d 100644 --- a/lib/models/sender.dart +++ b/lib/models/sender.dart @@ -1,15 +1,13 @@ import 'package:enough_mail/enough_mail.dart'; -import 'account.dart'; + +import '../account/model.dart'; class Sender { + Sender(this.address, this.account, {this.isPlaceHolderForPlusAlias = false}); MailAddress address; final RealAccount account; final bool isPlaceHolderForPlusAlias; - Sender(this.address, this.account, {this.isPlaceHolderForPlusAlias = false}); - @override - String toString() { - return address.toString(); - } + String toString() => address.toString(); } diff --git a/lib/models/shared_data.dart b/lib/models/shared_data.dart index c3e2056..dbced4c 100644 --- a/lib/models/shared_data.dart +++ b/lib/models/shared_data.dart @@ -7,27 +7,27 @@ import 'package:enough_mail/enough_mail.dart'; enum SharedDataAddState { added, notAdded } class SharedDataAddResult { + + const SharedDataAddResult(this.state, [this.details]); static const added = SharedDataAddResult(SharedDataAddState.added); static const notAdded = SharedDataAddResult(SharedDataAddState.notAdded); final SharedDataAddState state; final dynamic details; - - const SharedDataAddResult(this.state, [this.details]); } abstract class SharedData { - final MediaType mediaType; SharedData(this.mediaType); + final MediaType mediaType; Future addToMessageBuilder(MessageBuilder builder); Future addToEditor(HtmlEditorApi editorApi); } class SharedFile extends SharedData { - final File file; SharedFile(this.file, MediaType? mediaType) : super(mediaType ?? MediaType.guessFromFileName(file.path)); + final File file; @override Future addToMessageBuilder( @@ -48,10 +48,10 @@ class SharedFile extends SharedData { } class SharedBinary extends SharedData { - final Uint8List? data; - final String? filename; SharedBinary(this.data, this.filename, MediaType mediaType) : super(mediaType); + final Uint8List? data; + final String? filename; @override Future addToMessageBuilder( @@ -72,10 +72,10 @@ class SharedBinary extends SharedData { } class SharedText extends SharedData { - final String text; - final String? subject; SharedText(this.text, MediaType? mediaType, {this.subject}) : super(mediaType ?? MediaType.textPlain); + final String text; + final String? subject; @override Future addToMessageBuilder(MessageBuilder builder) { @@ -94,9 +94,9 @@ class SharedText extends SharedData { } class SharedMailto extends SharedData { - final Uri mailto; SharedMailto(this.mailto) : super(MediaType.fromSubtype(MediaSubtype.textHtml)); + final Uri mailto; @override Future addToEditor(HtmlEditorApi editorApi) { diff --git a/lib/models/swipe.dart b/lib/models/swipe.dart index 3198864..1ed236c 100644 --- a/lib/models/swipe.dart +++ b/lib/models/swipe.dart @@ -1,4 +1,4 @@ -import 'package:enough_mail_app/services/icon_service.dart'; +import '../services/icon_service.dart'; import 'package:flutter/material.dart'; import '../l10n/app_localizations.g.dart'; diff --git a/lib/models/web_view_configuration.dart b/lib/models/web_view_configuration.dart index 73f2a2a..493f54b 100644 --- a/lib/models/web_view_configuration.dart +++ b/lib/models/web_view_configuration.dart @@ -1,6 +1,6 @@ class WebViewConfiguration { - final String? title; - final Uri uri; WebViewConfiguration(this.title, this.uri); + final String? title; + final Uri uri; } diff --git a/lib/oauth/oauth.dart b/lib/oauth/oauth.dart index 193912c..c1c7a32 100644 --- a/lib/oauth/oauth.dart +++ b/lib/oauth/oauth.dart @@ -1,23 +1,23 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/key_service.dart'; -import 'package:enough_mail_app/util/http_helper.dart'; +import '../locator.dart'; +import '../services/key_service.dart'; +import '../util/http_helper.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_web_auth/flutter_web_auth.dart'; class OauthClientId { - final String id; - final String? secret; const OauthClientId(this.id, this.secret); + final String id; + final String? secret; } abstract class OauthClient { + OauthClient(this.incomingHostName); final String incomingHostName; - bool get isEnabled => (oauthClientId != null); + bool get isEnabled => oauthClientId != null; OauthClientId? get oauthClientId => locator().oauth[incomingHostName]; - OauthClient(this.incomingHostName); Future authenticate(String email) async { try { @@ -114,10 +114,10 @@ class GmailOAuthClient extends OauthClient { } class OutlookOAuthClient extends OauthClient { + OutlookOAuthClient() : super('outlook.office365.com'); // source: https://docs.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth static const String _scope = 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access'; - OutlookOAuthClient() : super('outlook.office365.com'); @override Future _authenticate(String email, String provider) async { diff --git a/lib/routes.dart b/lib/routes.dart index 146d775..0d38017 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,18 +1,23 @@ import 'dart:io'; import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/models/models.dart'; -import 'package:enough_mail_app/screens/all_screens.dart'; -import 'package:enough_mail_app/widgets/app_drawer.dart'; import 'package:enough_media/enough_media.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'account/model.dart'; +import 'main.dart'; +import 'models/models.dart'; +import 'screens/screens.dart'; import 'settings/view/view.dart'; +import 'widgets/app_drawer.dart'; class Routes { - static const String home = '/'; + Routes._(); + static const String _root = '/'; + static const String home = '/home'; static const String accountAdd = '/accountAdd'; static const String accountEdit = '/accountEdit'; static const String accountServerDetails = '/accountServerDetails'; @@ -35,13 +40,38 @@ class Routes { static const String mailContents = '/mailContents'; static const String mailCompose = '/mailCompose'; static const String welcome = '/welcome'; - static const String splash = '/'; + static const String splash = '/splash'; static const String interactiveMedia = '/interactiveMedia'; static const String locationPicker = '/locationPicker'; static const String sourceCode = '/sourceCode'; static const String webview = '/webview'; static const String appDrawer = '/appDrawer'; static const String lockScreen = '/lock'; + + static final navigatorKey = GlobalKey(); + + /// The routing configuration + static GoRouter routerConfig = GoRouter( + navigatorKey: navigatorKey, + routes: [ + GoRoute( + path: _root, + builder: (context, state) => const InitializationScreen(), + ), + GoRoute( + path: splash, + builder: (context, state) => const SplashScreen(), + ), + GoRoute( + path: splash, + builder: (context, state) => const SplashScreen(), + ), + GoRoute( + path: home, + builder: (context, state) => const HomeScreen(), + ), + ], + ); } class AppRouter { @@ -50,14 +80,14 @@ class AppRouter { switch (name) { case Routes.accountAdd: page = AccountAddScreen( - launchedFromWelcome: (arguments == true), + launchedFromWelcome: arguments == true, ); break; case Routes.accountServerDetails: - page = AccountServerDetailsScreen(account: arguments as RealAccount); + page = AccountServerDetailsScreen(account: arguments! as RealAccount); break; case Routes.accountEdit: - page = AccountEditScreen(account: arguments as RealAccount); + page = AccountEditScreen(account: arguments! as RealAccount); break; case Routes.settings: page = const SettingsScreen(); @@ -100,10 +130,10 @@ class AppRouter { break; case Routes.messageSourceFuture: page = AsyncMessageSourceScreen( - messageSourceFuture: arguments as Future); + messageSourceFuture: arguments! as Future); break; case Routes.messageSource: - page = MessageSourceScreen(messageSource: arguments as MessageSource); + page = MessageSourceScreen(messageSource: arguments! as MessageSource); break; case Routes.mailDetails: if (arguments is Message) { @@ -118,14 +148,14 @@ class AppRouter { } break; case Routes.mailContents: - page = MessageContentsScreen(message: arguments as Message); + page = MessageContentsScreen(message: arguments! as Message); break; case Routes.mailCompose: - page = ComposeScreen(data: arguments as ComposeData); + page = ComposeScreen(data: arguments! as ComposeData); break; case Routes.interactiveMedia: page = InteractiveMediaScreen( - mediaWidget: arguments as InteractiveMediaWidget); + mediaWidget: arguments! as InteractiveMediaWidget); break; case Routes.locationPicker: page = const LocationScreen(); @@ -137,10 +167,10 @@ class AppRouter { page = const WelcomeScreen(); break; case Routes.sourceCode: - page = SourceCodeScreen(mimeMessage: arguments as MimeMessage); + page = SourceCodeScreen(mimeMessage: arguments! as MimeMessage); break; case Routes.webview: - page = WebViewScreen(configuration: arguments as WebViewConfiguration); + page = WebViewScreen(configuration: arguments! as WebViewConfiguration); break; case Routes.appDrawer: page = const AppDrawer(); @@ -156,11 +186,13 @@ class AppRouter { body: Center(child: Text('No route defined for $name')), ); } + return page; } static Route generateRoute(RouteSettings settings) { final page = generatePage(settings.name, settings.arguments); + return Platform.isAndroid ? MaterialPageRoute(builder: (_) => page) : CupertinoPageRoute(builder: (_) => page); diff --git a/lib/screens/account_add_screen.dart b/lib/screens/account_add_screen.dart index eae8471..7f6aecf 100644 --- a/lib/screens/account_add_screen.dart +++ b/lib/screens/account_add_screen.dart @@ -1,35 +1,34 @@ import 'dart:io'; import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/extensions/extensions.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/routes.dart'; -import 'package:enough_mail_app/screens/base.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; -import 'package:enough_mail_app/services/providers.dart'; -import 'package:enough_mail_app/util/modal_bottom_sheet_helper.dart'; -import 'package:enough_mail_app/util/validator.dart'; -import 'package:enough_mail_app/widgets/account_provider_selector.dart'; -import 'package:enough_mail_app/widgets/button_text.dart'; -import 'package:enough_mail_app/widgets/password_field.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; +import '../account/model.dart'; +import '../extensions/extensions.dart'; import '../l10n/app_localizations.g.dart'; +import '../l10n/extension.dart'; +import '../locator.dart'; +import '../routes.dart'; +import '../services/i18n_service.dart'; +import '../services/mail_service.dart'; +import '../services/navigation_service.dart'; +import '../services/providers.dart'; +import '../util/modal_bottom_sheet_helper.dart'; +import '../util/validator.dart'; +import '../widgets/account_provider_selector.dart'; +import '../widgets/button_text.dart'; +import '../widgets/password_field.dart'; +import 'base.dart'; class AccountAddScreen extends StatefulWidget { - final bool launchedFromWelcome; - const AccountAddScreen({ - Key? key, + super.key, required this.launchedFromWelcome, - }) : super(key: key); + }); + final bool launchedFromWelcome; @override State createState() => _AccountAddScreenState(); @@ -133,17 +132,16 @@ class _AccountAddScreenState extends State { children: [ Expanded( child: PlatformStepper( - type: StepperType.vertical, onStepContinue: _isContinueAvailable ? () async { - var step = _currentStep + 1; + final step = _currentStep + 1; if (step < _availableSteps) { setState(() { _currentStep = step; _isContinueAvailable = false; }); } - _onStepProgressed(step); + await _onStepProgressed(step); } : null, onStepCancel: () => Navigator.pop(context), @@ -238,7 +236,7 @@ class _AccountAddScreenState extends State { }); } else { final domainName = email.substring(email.lastIndexOf('@') + 1); - var mailAccount = MailAccount.fromDiscoveredSettingsWithAuth( + final mailAccount = MailAccount.fromDiscoveredSettingsWithAuth( name: domainName, email: email, auth: OauthAuthentication(email, token), @@ -268,7 +266,7 @@ class _AccountAddScreenState extends State { setState(() { _isAccountVerifying = true; }); - var mailAccount = MailAccount.fromDiscoveredSettings( + final mailAccount = MailAccount.fromDiscoveredSettings( name: _emailController.text, userName: _emailController.text, email: _emailController.text, @@ -306,12 +304,12 @@ class _AccountAddScreenState extends State { account.name = _accountNameController.text; account.userName = _userNameController.text; final service = locator(); - final added = await service.addAccount(account, mailClient, context); + final added = await service.addAccount(account, mailClient); if (added) { if (Platform.isIOS && widget.launchedFromWelcome) { - locator().push(Routes.appDrawer, clear: true); + await locator().push(Routes.appDrawer, clear: true); } - locator().push( + await locator().push( Routes.messageSource, arguments: service.messageSource, clear: !Platform.isIOS && widget.launchedFromWelcome, @@ -321,54 +319,52 @@ class _AccountAddScreenState extends State { } } - Step _buildEmailStep(BuildContext context, AppLocalizations localizations) { - return Step( - title: _currentStep == 0 - ? Text(localizations.addAccountEmailLabel) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(localizations.addAccountEmailLabel), - Text( - _emailController.text, - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.max, - children: [ - DecoratedPlatformTextField( - autocorrect: false, - controller: _emailController, - keyboardType: TextInputType.emailAddress, - cupertinoShowLabel: false, - onChanged: (value) { - final isValid = Validator.validateEmail(value); - final account = _realAccount; - if (isValid && account != null) { - account.email = value; - } - if (isValid != _isContinueAvailable) { - setState(() { - _isContinueAvailable = isValid; - }); - } - }, - decoration: InputDecoration( - labelText: localizations.addAccountEmailLabel, - hintText: localizations.addAccountEmailHint, - icon: const Icon(Icons.email), + Step _buildEmailStep(BuildContext context, AppLocalizations localizations) => + Step( + title: _currentStep == 0 + ? Text(localizations.addAccountEmailLabel) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(localizations.addAccountEmailLabel), + Text( + _emailController.text, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + content: Column( + children: [ + DecoratedPlatformTextField( + autocorrect: false, + controller: _emailController, + keyboardType: TextInputType.emailAddress, + cupertinoShowLabel: false, + onChanged: (value) { + final isValid = Validator.validateEmail(value); + final account = _realAccount; + if (isValid && account != null) { + account.email = value; + } + if (isValid != _isContinueAvailable) { + setState(() { + _isContinueAvailable = isValid; + }); + } + }, + decoration: InputDecoration( + labelText: localizations.addAccountEmailLabel, + hintText: localizations.addAccountEmailHint, + icon: const Icon(Icons.email), + ), + autofocus: true, ), - autofocus: true, - ), - ], - ), - //state: StepState.editing, - isActive: true, - ); - } + ], + ), + //state: StepState.editing, + isActive: true, + ); Step _buildPasswordStep( BuildContext context, AppLocalizations localizations) { @@ -379,7 +375,6 @@ class _AccountAddScreenState extends State { //state: StepState.complete, isActive: _currentStep >= 1, content: Column( - mainAxisSize: MainAxisSize.max, children: [ if (_isProviderResolving) Row( @@ -415,7 +410,7 @@ class _AccountAddScreenState extends State { ), if (appSpecificPasswordSetupUrl != null) ...[ Padding( - padding: const EdgeInsets.only(top: 8.0), + padding: const EdgeInsets.only(top: 8), child: Text( localizations.addAccountOauthSignInWithAppPassword), ), @@ -459,7 +454,7 @@ class _AccountAddScreenState extends State { controller: _passwordController, cupertinoShowLabel: false, onChanged: (value) { - bool isValid = value.isNotEmpty && + final bool isValid = value.isNotEmpty && (_provider?.clientConfig != null || _isManualSettings); if (isValid != _isContinueAvailable) { @@ -521,103 +516,101 @@ class _AccountAddScreenState extends State { Step _buildAccountSetupStep( BuildContext context, AppLocalizations localizations, - ) { - return Step( - title: Text(_isAccountVerified - ? localizations.addAccountSetupAccountStep - : localizations.addAccountVerificationStep), - content: Column( - mainAxisSize: MainAxisSize.max, - children: [ - if (_isAccountVerifying) - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - child: const PlatformProgressIndicator(), - ), - Expanded( - child: Text( - localizations.addAccountVerifyingSettingsLabel( - _emailController.text, + ) => + Step( + title: Text(_isAccountVerified + ? localizations.addAccountSetupAccountStep + : localizations.addAccountVerificationStep), + content: Column( + children: [ + if (_isAccountVerifying) + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + child: const PlatformProgressIndicator(), + ), + Expanded( + child: Text( + localizations.addAccountVerifyingSettingsLabel( + _emailController.text, + ), ), ), + ], + ) + else if (_isAccountVerified) ...[ + Text( + localizations.addAccountVerifyingSuccessInfo( + _emailController.text, ), - ], - ) - else if (_isAccountVerified) ...[ - Text( - localizations.addAccountVerifyingSuccessInfo( - _emailController.text, - ), - ), - DecoratedPlatformTextField( - controller: _userNameController, - keyboardType: TextInputType.text, - textCapitalization: TextCapitalization.words, - onChanged: (value) { - bool isValid = - value.isNotEmpty && _accountNameController.text.isNotEmpty; - if (isValid != _isContinueAvailable) { - setState(() { - _isContinueAvailable = isValid; - }); - } - }, - decoration: InputDecoration( - labelText: localizations.addAccountNameOfUserLabel, - hintText: localizations.addAccountNameOfUserHint, - icon: const Icon(Icons.account_circle), ), - autofocus: true, - cupertinoAlignLabelOnTop: true, - ), - DecoratedPlatformTextField( - controller: _accountNameController, - keyboardType: TextInputType.text, - onChanged: (value) { - bool isValid = - value.isNotEmpty && _userNameController.text.isNotEmpty; - if (isValid != _isContinueAvailable) { - setState(() { - _isContinueAvailable = isValid; - }); - } - }, - decoration: InputDecoration( - labelText: localizations.addAccountNameOfAccountLabel, - hintText: localizations.addAccountNameOfAccountHint, - icon: const Icon(Icons.email), + DecoratedPlatformTextField( + controller: _userNameController, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.words, + onChanged: (value) { + final bool isValid = value.isNotEmpty && + _accountNameController.text.isNotEmpty; + if (isValid != _isContinueAvailable) { + setState(() { + _isContinueAvailable = isValid; + }); + } + }, + decoration: InputDecoration( + labelText: localizations.addAccountNameOfUserLabel, + hintText: localizations.addAccountNameOfUserHint, + icon: const Icon(Icons.account_circle), + ), + autofocus: true, + cupertinoAlignLabelOnTop: true, ), - cupertinoAlignLabelOnTop: true, - ), - ] else ...[ - Text( - localizations.addAccountVerifyingFailedInfo( - _emailController.text, + DecoratedPlatformTextField( + controller: _accountNameController, + keyboardType: TextInputType.text, + onChanged: (value) { + final bool isValid = + value.isNotEmpty && _userNameController.text.isNotEmpty; + if (isValid != _isContinueAvailable) { + setState(() { + _isContinueAvailable = isValid; + }); + } + }, + decoration: InputDecoration( + labelText: localizations.addAccountNameOfAccountLabel, + hintText: localizations.addAccountNameOfAccountHint, + icon: const Icon(Icons.email), + ), + cupertinoAlignLabelOnTop: true, ), - ), - if (_provider?.manualImapAccessSetupUrl != null) ...[ - Padding( - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), - child: Text( - localizations.accountAddImapAccessSetupMightBeRequired, + ] else ...[ + Text( + localizations.addAccountVerifyingFailedInfo( + _emailController.text, ), ), - PlatformTextButton( - child: ButtonText( - localizations.addAccountSetupImapAccessButtonLabel, + if (_provider?.manualImapAccessSetupUrl != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Text( + localizations.accountAddImapAccessSetupMightBeRequired, + ), ), - onPressed: () => launcher.launchUrl( - Uri.parse(_provider!.manualImapAccessSetupUrl!), + PlatformTextButton( + child: ButtonText( + localizations.addAccountSetupImapAccessButtonLabel, + ), + onPressed: () => launcher.launchUrl( + Uri.parse(_provider!.manualImapAccessSetupUrl!), + ), ), - ), + ], ], ], - ], - ), - ); - } + ), + ); void _onProviderChanged(Provider provider, String email) { final mailAccount = MailAccount.fromDiscoveredSettings( diff --git a/lib/screens/account_edit_screen.dart b/lib/screens/account_edit_screen.dart index 0fe660f..36a03da 100644 --- a/lib/screens/account_edit_screen.dart +++ b/lib/screens/account_edit_screen.dart @@ -7,10 +7,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../account/model.dart'; import '../l10n/app_localizations.g.dart'; import '../l10n/extension.dart'; import '../locator.dart'; -import '../models/account.dart'; import '../routes.dart'; import '../services/icon_service.dart'; import '../services/mail_service.dart'; @@ -136,7 +136,6 @@ class AccountEditScreen extends HookConsumerWidget { .excludeAccountFromUnified( account, exclude, - context, ); }, title: Text( @@ -305,7 +304,7 @@ class AccountEditScreen extends HookConsumerWidget { if (!context.mounted) { return; } - await mailService.removeAccount(account, context); + await mailService.removeAccount(account); if (mailService.accounts.isEmpty) { await locator().push( Routes.welcome, diff --git a/lib/screens/account_server_details_screen.dart b/lib/screens/account_server_details_screen.dart index 378ceb9..d8ef7c2 100644 --- a/lib/screens/account_server_details_screen.dart +++ b/lib/screens/account_server_details_screen.dart @@ -1,27 +1,28 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/screens/base.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; -import 'package:enough_mail_app/util/localized_dialog_helper.dart'; -import 'package:enough_mail_app/widgets/password_field.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../account/model.dart'; +import '../l10n/extension.dart'; +import '../locator.dart'; +import '../services/mail_service.dart'; +import '../services/navigation_service.dart'; +import '../util/localized_dialog_helper.dart'; +import '../widgets/password_field.dart'; +import 'base.dart'; + class AccountServerDetailsScreen extends StatelessWidget { - final RealAccount account; - final String? title; - final bool includeDrawer; const AccountServerDetailsScreen({ - Key? key, + super.key, required this.account, this.title, this.includeDrawer = true, - }) : super(key: key); + }); + final RealAccount account; + final String? title; + final bool includeDrawer; @override Widget build(BuildContext context) { @@ -39,25 +40,24 @@ class AccountServerDetailsScreen extends StatelessWidget { } class AccountServerDetailsEditor extends StatefulWidget { - final RealAccount account; - const AccountServerDetailsEditor({ - Key? key, + super.key, required this.account, - }) : super(key: key); + }); + final RealAccount account; @override State createState() => _AccountServerDetailsEditorState(); - void testConnection(BuildContext context) async { + Future testConnection(BuildContext context) async { await _AccountServerDetailsEditorState._currentState ?.testConnection(context); } } class _SaveButton extends StatefulWidget { - const _SaveButton({Key? key}) : super(key: key); + const _SaveButton(); @override _SaveButtonState createState() => _SaveButtonState(); @@ -287,7 +287,7 @@ class _AccountServerDetailsEditorState child: Material( child: SafeArea( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( children: [ DecoratedPlatformTextField( @@ -338,10 +338,9 @@ class _AccountServerDetailsEditorState localizations.accountDetailsAdvancedIncomingSectionTitle), children: [ Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Text(localizations .accountDetailsIncomingServerTypeLabel), ), @@ -365,10 +364,9 @@ class _AccountServerDetailsEditorState ], ), Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Text(localizations .accountDetailsIncomingSecurityLabel), ), @@ -430,10 +428,9 @@ class _AccountServerDetailsEditorState localizations.accountDetailsAdvancedOutgoingSectionTitle), children: [ Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Text(localizations .accountDetailsOutgoingServerTypeLabel), ), @@ -453,10 +450,9 @@ class _AccountServerDetailsEditorState ], ), Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Text(localizations .accountDetailsOutgoingSecurityLabel), ), diff --git a/lib/screens/base.dart b/lib/screens/base.dart index e35feb3..91ea32a 100644 --- a/lib/screens/base.dart +++ b/lib/screens/base.dart @@ -1,16 +1,16 @@ -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/widgets/app_drawer.dart'; -import 'package:enough_mail_app/widgets/menu_with_badge.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import '../locator.dart'; +import '../services/mail_service.dart'; +import '../widgets/app_drawer.dart'; +import '../widgets/menu_with_badge.dart'; class BasePage extends StatelessWidget { const BasePage({ - Key? key, + super.key, this.title, this.subtitle, this.content, @@ -21,7 +21,7 @@ class BasePage extends StatelessWidget { this.bottom, this.includeDrawer = true, this.isRoot = false, - }) : super(key: key); + }); final String? title; final String? subtitle; @@ -35,8 +35,7 @@ class BasePage extends StatelessWidget { final bool isRoot; @override - Widget build(BuildContext context) { - return Base.buildAppChrome( + Widget build(BuildContext context) => Base.buildAppChrome( context, title: title, subtitle: subtitle, @@ -49,18 +48,17 @@ class BasePage extends StatelessWidget { includeDrawer: includeDrawer, isRoot: isRoot, ); - } } class BaseAppBar extends StatelessWidget { const BaseAppBar({ - Key? key, + super.key, this.title, this.actions, this.subtitle, this.floatingActionButton, this.includeDrawer = true, - }) : super(key: key); + }); final String? title; final List? actions; @@ -69,15 +67,13 @@ class BaseAppBar extends StatelessWidget { final bool includeDrawer; @override - Widget build(BuildContext context) { - return Base.buildAppBar( + Widget build(BuildContext context) => Base.buildAppBar( context, title, subtitle: subtitle, floatingActionButton: floatingActionButton, includeDrawer: includeDrawer, ); - } } class Base { @@ -130,8 +126,7 @@ class Base { FloatingActionButton? floatingActionButton, bool includeDrawer = true, bool isRoot = false, - }) { - return PlatformAppBar( + }) => PlatformAppBar( material: (context, platform) => MaterialAppBarData( elevation: 0, ), @@ -151,7 +146,6 @@ class Base { automaticallyImplyLeading: true, trailingActions: actions ?? [], ); - } static Widget? buildTitle(String? title, String? subtitle) { if (subtitle == null) { @@ -171,7 +165,7 @@ class Base { overflow: TextOverflow.fade, ), Padding( - padding: const EdgeInsets.only(top: 4.0), + padding: const EdgeInsets.only(top: 4), child: Text( subtitle, overflow: TextOverflow.fade, @@ -183,17 +177,10 @@ class Base { } } - static Widget buildDrawer(BuildContext context) { - return const AppDrawer(); - } + static Widget buildDrawer(BuildContext context) => const AppDrawer(); } class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { - final double maxHeight; - final double minHeight; - final double? elevation; - final Widget child; - final Widget? background; SliverSingleChildHeaderDelegate( {required this.maxHeight, @@ -201,20 +188,24 @@ class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { required this.child, this.elevation, this.background}); + final double maxHeight; + final double minHeight; + final double? elevation; + final Widget child; + final Widget? background; @override Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { - return Material( + BuildContext context, double shrinkOffset, bool overlapsContent) => Material( elevation: elevation ?? 0, child: ConstrainedBox( constraints: BoxConstraints(minHeight: maxHeight), child: Stack( children: [ Positioned( - bottom: 0.0, - left: 0.0, - right: 0.0, + bottom: 0, + left: 0, + right: 0, top: 0, child: background!, ), @@ -223,7 +214,6 @@ class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { ), ), ); - } @override double get maxExtent => kToolbarHeight + maxHeight; @@ -232,19 +222,12 @@ class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { double get minExtent => kToolbarHeight + minHeight; @override - bool shouldRebuild(SliverSingleChildHeaderDelegate oldDelegate) { - return maxHeight != oldDelegate.maxHeight || + bool shouldRebuild(SliverSingleChildHeaderDelegate oldDelegate) => maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight || child != oldDelegate.child; - } } class CustomApBarSliverDelegate extends SliverPersistentHeaderDelegate { - final Widget? child; - final Widget? title; - final Widget? background; - final double minHeight; - final double maxHeight; CustomApBarSliverDelegate({ this.title, @@ -253,6 +236,11 @@ class CustomApBarSliverDelegate extends SliverPersistentHeaderDelegate { this.background, this.minHeight = 0, }); + final Widget? child; + final Widget? title; + final Widget? background; + final double minHeight; + final double maxHeight; @override Widget build( @@ -265,22 +253,22 @@ class CustomApBarSliverDelegate extends SliverPersistentHeaderDelegate { child: Stack( children: [ Positioned( - bottom: 0.0, - left: 0.0, - right: 0.0, + bottom: 0, + left: 0, + right: 0, top: 0, child: background!, ), Positioned( - bottom: 0.0, - left: 0.0, - right: 0.0, + bottom: 0, + left: 0, + right: 0, child: Opacity(opacity: percent, child: child), ), Positioned( - top: 0.0, - left: 0.0, - right: 0.0, + top: 0, + left: 0, + right: 0, child: AppBar( title: Opacity(opacity: 1 - percent, child: title), backgroundColor: Colors.transparent, @@ -303,7 +291,5 @@ class CustomApBarSliverDelegate extends SliverPersistentHeaderDelegate { double get maxExtent => kToolbarHeight + maxHeight; @override - bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) { - return true; - } + bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => true; } diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index 016c93d..73f33c5 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -8,10 +8,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../account/model.dart'; import '../l10n/app_localizations.g.dart'; import '../l10n/extension.dart'; import '../locator.dart'; -import '../models/account.dart'; import '../models/compose_data.dart'; import '../models/sender.dart'; import '../models/shared_data.dart'; @@ -28,7 +28,6 @@ import '../widgets/app_drawer.dart'; import '../widgets/attachment_compose_bar.dart'; import '../widgets/button_text.dart'; import '../widgets/editor_extensions.dart'; -import '../widgets/inherited_widgets.dart'; import '../widgets/recipient_input_field.dart'; class ComposeScreen extends ConsumerStatefulWidget { @@ -393,6 +392,7 @@ class _ComposeScreenState extends ConsumerState { : widget.data.action == ComposeAction.forward ? localizations.composeTitleForward : localizations.composeTitleNew; + return WillPopScope( onWillPop: () async { // let it pop but show snackbar to return: @@ -401,243 +401,241 @@ class _ComposeScreenState extends ConsumerState { localizations.composeLeftByMistake, undo: _returnToCompose, ); + return true; }, - child: MessageWidget( - message: widget.data.originalMessage, - child: PlatformScaffold( - material: (context, platform) => - MaterialScaffoldData(drawer: const AppDrawer()), - body: CustomScrollView( - slivers: [ - PlatformSliverAppBar( - title: Text(titleText), - pinned: true, - stretch: true, - actions: [ - AddAttachmentPopupButton( - composeData: widget.data, - update: () => setState(() {}), - ), - PlatformIconButton( - icon: const Icon(Icons.send), - onPressed: () => _send(localizations), - ), - PlatformPopupMenuButton<_OverflowMenuChoice>( - onSelected: (result) { - switch (result) { - case _OverflowMenuChoice.showSourceCode: - _showSourceCode(); - break; - case _OverflowMenuChoice.saveAsDraft: - _saveAsDraft(); - break; - case _OverflowMenuChoice.requestReadReceipt: - _requestReadReceipt(); - break; - case _OverflowMenuChoice.convertToPlainTextEditor: - _convertToPlainTextEditor(); - break; - case _OverflowMenuChoice.convertToHtmlEditor: - _convertToHtmlEditor(); - break; - } - }, - itemBuilder: (context) => [ + child: PlatformScaffold( + material: (context, platform) => + MaterialScaffoldData(drawer: const AppDrawer()), + body: CustomScrollView( + slivers: [ + PlatformSliverAppBar( + title: Text(titleText), + pinned: true, + stretch: true, + actions: [ + AddAttachmentPopupButton( + composeData: widget.data, + update: () => setState(() {}), + ), + PlatformIconButton( + icon: const Icon(Icons.send), + onPressed: () => _send(localizations), + ), + PlatformPopupMenuButton<_OverflowMenuChoice>( + onSelected: (result) { + switch (result) { + case _OverflowMenuChoice.showSourceCode: + _showSourceCode(); + break; + case _OverflowMenuChoice.saveAsDraft: + _saveAsDraft(); + break; + case _OverflowMenuChoice.requestReadReceipt: + _requestReadReceipt(); + break; + case _OverflowMenuChoice.convertToPlainTextEditor: + _convertToPlainTextEditor(); + break; + case _OverflowMenuChoice.convertToHtmlEditor: + _convertToHtmlEditor(); + break; + } + }, + itemBuilder: (context) => [ + PlatformPopupMenuItem<_OverflowMenuChoice>( + value: _OverflowMenuChoice.saveAsDraft, + child: Text(localizations.composeSaveDraftAction), + ), + PlatformPopupMenuItem<_OverflowMenuChoice>( + value: _OverflowMenuChoice.requestReadReceipt, + child: + Text(localizations.composeRequestReadReceiptAction), + ), + if (_composeMode == ComposeMode.html) + PlatformPopupMenuItem<_OverflowMenuChoice>( + value: _OverflowMenuChoice.convertToPlainTextEditor, + child: Text(localizations + .composeConvertToPlainTextEditorAction), + ) + else PlatformPopupMenuItem<_OverflowMenuChoice>( - value: _OverflowMenuChoice.saveAsDraft, - child: Text(localizations.composeSaveDraftAction), + value: _OverflowMenuChoice.convertToHtmlEditor, + child: Text( + localizations.composeConvertToHtmlEditorAction, + ), ), + if (ref.read(settingsProvider).enableDeveloperMode) PlatformPopupMenuItem<_OverflowMenuChoice>( - value: _OverflowMenuChoice.requestReadReceipt, - child: - Text(localizations.composeRequestReadReceiptAction), + value: _OverflowMenuChoice.showSourceCode, + child: Text(localizations.viewSourceAction), ), - if (_composeMode == ComposeMode.html) - PlatformPopupMenuItem<_OverflowMenuChoice>( - value: _OverflowMenuChoice.convertToPlainTextEditor, - child: Text(localizations - .composeConvertToPlainTextEditorAction), - ) - else - PlatformPopupMenuItem<_OverflowMenuChoice>( - value: _OverflowMenuChoice.convertToHtmlEditor, - child: Text( - localizations.composeConvertToHtmlEditorAction), - ), - if (ref.read(settingsProvider).enableDeveloperMode) - PlatformPopupMenuItem<_OverflowMenuChoice>( - value: _OverflowMenuChoice.showSourceCode, - child: Text(localizations.viewSourceAction), + ], + ), + ], // actions + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.detailsHeaderFrom, + style: Theme.of(context).textTheme.bodySmall, + ), + PlatformDropdownButton( + //isExpanded: true, + items: _senders + .map( + (s) => DropdownMenuItem( + value: s, + child: Text( + s.toString(), + overflow: TextOverflow.fade, + ), + ), + ) + .toList(), + onChanged: (s) async { + final builder = widget.data.messageBuilder; + + builder.from = [s!.address]; + final lastSignature = _signature; + _from = s; + final newSignature = _signature; + if (newSignature != lastSignature) { + await _htmlEditorApi! + .replaceAll(lastSignature, newSignature); + } + if (_isReadReceiptRequested) { + builder.requestReadReceipt(recipient: _from.address); + } + setState(() {}); + + _checkAccountContactManager(_from.account); + }, + value: _from, + hint: Text(localizations.composeSenderHint), + ), + RecipientInputField( + contactManager: _from.account.contactManager, + addresses: _toRecipients, + autofocus: _focus == _Autofocus.to, + labelText: localizations.detailsHeaderTo, + hintText: localizations.composeRecipientHint, + additionalSuffixIcon: PlatformTextButton( + child: ButtonText(localizations.detailsHeaderCc), + onPressed: () => setState( + () => _isCcBccVisible = !_isCcBccVisible, ), - ], - ), - ], // actions - ), - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizations.detailsHeaderFrom, - style: Theme.of(context).textTheme.bodySmall, ), - PlatformDropdownButton( - //isExpanded: true, - items: _senders - .map( - (s) => DropdownMenuItem( - value: s, - child: Text( - s.toString(), - overflow: TextOverflow.fade, - ), - ), - ) - .toList(), - onChanged: (s) async { - final builder = widget.data.messageBuilder; - - builder.from = [s!.address]; - final lastSignature = _signature; - _from = s; - final newSignature = _signature; - if (newSignature != lastSignature) { - await _htmlEditorApi! - .replaceAll(lastSignature, newSignature); - } - if (_isReadReceiptRequested) { - builder.requestReadReceipt( - recipient: _from.address); - } - setState(() {}); - - _checkAccountContactManager(_from.account); - }, - value: _from, - hint: Text(localizations.composeSenderHint), + ), + if (_isCcBccVisible) ...[ + RecipientInputField( + addresses: _ccRecipients, + contactManager: _from.account.contactManager, + labelText: localizations.detailsHeaderCc, + hintText: localizations.composeRecipientHint, ), RecipientInputField( + addresses: _bccRecipients, contactManager: _from.account.contactManager, - addresses: _toRecipients, - autofocus: _focus == _Autofocus.to, - labelText: localizations.detailsHeaderTo, + labelText: localizations.detailsHeaderBcc, hintText: localizations.composeRecipientHint, - additionalSuffixIcon: PlatformTextButton( - child: ButtonText(localizations.detailsHeaderCc), - onPressed: () => setState( - () => _isCcBccVisible = !_isCcBccVisible, - ), - ), ), - if (_isCcBccVisible) ...[ - RecipientInputField( - addresses: _ccRecipients, - contactManager: _from.account.contactManager, - labelText: localizations.detailsHeaderCc, - hintText: localizations.composeRecipientHint, - ), - RecipientInputField( - addresses: _bccRecipients, - contactManager: _from.account.contactManager, - labelText: localizations.detailsHeaderBcc, - hintText: localizations.composeRecipientHint, - ), - ], - TextEditor( - controller: _subjectController, - autofocus: _focus == _Autofocus.subject, - decoration: InputDecoration( - labelText: localizations.composeSubjectLabel, - hintText: localizations.composeSubjectHint, - ), - cupertinoShowLabel: false, + ], + TextEditor( + controller: _subjectController, + autofocus: _focus == _Autofocus.subject, + decoration: InputDecoration( + labelText: localizations.composeSubjectLabel, + hintText: localizations.composeSubjectHint, ), - if (widget.data.messageBuilder.attachments.isNotEmpty || - (_downloadAttachmentsFuture != null)) ...[ - Padding( - padding: const EdgeInsets.only(top: 8), - child: AttachmentComposeBar( - composeData: widget.data, - isDownloading: - _downloadAttachmentsFuture != null), + cupertinoShowLabel: false, + ), + if (widget.data.messageBuilder.attachments.isNotEmpty || + (_downloadAttachmentsFuture != null)) ...[ + Padding( + padding: const EdgeInsets.only(top: 8), + child: AttachmentComposeBar( + composeData: widget.data, + isDownloading: _downloadAttachmentsFuture != null, ), - const Divider( - color: Colors.grey, - ) - ], + ), + const Divider( + color: Colors.grey, + ) ], - ), + ], ), ), - if (_isReadReceiptRequested) - SliverToBoxAdapter( - child: PlatformCheckboxListTile( - value: true, - title: Text(localizations.composeRequestReadReceiptAction), - onChanged: (value) { - _removeReadReceiptRequest(); - }, - ), - ), - if (_composeMode == ComposeMode.html && _htmlEditorApi != null) - SliverHeaderHtmlEditorControls( - editorApi: _htmlEditorApi, - suffix: EditorArtExtensionButton(editorApi: _htmlEditorApi!), - ) - else if (_composeMode == ComposeMode.plainText && - _plainTextEditorApi != null) - SliverHeaderTextEditorControls( - editorApi: _plainTextEditorApi, - ), + ), + if (_isReadReceiptRequested) SliverToBoxAdapter( - child: FutureBuilder( - future: _loadMailTextFuture, - builder: (widget, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return const Center(child: PlatformProgressIndicator()); - case ConnectionState.done: - if (_composeMode == ComposeMode.html) { - final text = snapshot.data ?? '

'; - return HtmlEditor( + child: PlatformCheckboxListTile( + value: true, + title: Text(localizations.composeRequestReadReceiptAction), + onChanged: (value) { + _removeReadReceiptRequest(); + }, + ), + ), + if (_composeMode == ComposeMode.html && _htmlEditorApi != null) + SliverHeaderHtmlEditorControls( + editorApi: _htmlEditorApi, + suffix: EditorArtExtensionButton(editorApi: _htmlEditorApi!), + ) + else if (_composeMode == ComposeMode.plainText && + _plainTextEditorApi != null) + SliverHeaderTextEditorControls( + editorApi: _plainTextEditorApi, + ), + SliverToBoxAdapter( + child: FutureBuilder( + future: _loadMailTextFuture, + builder: (widget, snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.none: + case ConnectionState.waiting: + case ConnectionState.active: + return const Center(child: PlatformProgressIndicator()); + case ConnectionState.done: + if (_composeMode == ComposeMode.html) { + final text = snapshot.data ?? '

'; + return HtmlEditor( + onCreated: (api) { + setState(() { + _htmlEditorApi = api; + }); + }, + enableDarkMode: + Theme.of(context).brightness == Brightness.dark, + initialContent: text, + minHeight: 400, + ); + } else { + // compose mode is plainText + _plainTextController.text = snapshot.data ?? ''; + return Padding( + padding: const EdgeInsets.all(8), + child: TextEditor( + controller: _plainTextController, + minLines: 10, + maxLines: null, onCreated: (api) { setState(() { - _htmlEditorApi = api; + _plainTextEditorApi = api; }); }, - enableDarkMode: - Theme.of(context).brightness == Brightness.dark, - initialContent: text, - minHeight: 400, - ); - } else { - // compose mode is plainText - _plainTextController.text = snapshot.data ?? ''; - return Padding( - padding: const EdgeInsets.all(8), - child: TextEditor( - controller: _plainTextController, - minLines: 10, - maxLines: null, - onCreated: (api) { - setState(() { - _plainTextEditorApi = api; - }); - }, - ), - ); - } - } - }, - ), + ), + ); + } + } + }, ), - ], - ), + ), + ], ), ), ); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..60ecc8b --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../account/model.dart'; +import '../account/providers.dart'; +import '../settings/provider.dart'; +import 'screens.dart'; + +/// Screen shown after accounts have been loaded: +/// Either the welcome content or the first account's inbox is shown +class HomeScreen extends ConsumerWidget { + /// Creates a [HomeScreen] + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(allAccountsProvider); + if (accounts.isEmpty) { + return const WelcomeScreen(); + } + final enableBiometricLock = ref.read(settingsProvider).enableBiometricLock; + if (enableBiometricLock) { + return const LockScreen(); + } + + return MailScreen( + account: accounts.firstWhere((a) => a is RealAccount), + ); + } +} diff --git a/lib/screens/location_screen.dart b/lib/screens/location_screen.dart index 2145dbc..b8b6074 100644 --- a/lib/screens/location_screen.dart +++ b/lib/screens/location_screen.dart @@ -1,10 +1,6 @@ import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/screens/base.dart'; -import 'package:enough_mail_app/services/location_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,10 +9,14 @@ import 'package:latlng/latlng.dart'; import 'package:location/location.dart'; import 'package:map/map.dart'; +import '../l10n/extension.dart'; import '../locator.dart'; +import '../services/location_service.dart'; +import '../services/navigation_service.dart'; +import 'base.dart'; class LocationScreen extends StatefulWidget { - const LocationScreen({Key? key}) : super(key: key); + const LocationScreen({super.key}); @override State createState() => _LocationScreenState(); @@ -29,7 +29,7 @@ class _LocationScreenState extends State { late MapController _controller; Future? _findLocation; late Offset _dragStart; - double _scaleStart = 1.0; + double _scaleStart = 1; @override void initState() { @@ -70,16 +70,16 @@ class _LocationScreenState extends State { ); } - void _onLocationSelected() async { + Future _onLocationSelected() async { final context = _repaintBoundaryKey.currentContext; if (context == null) { locator().pop(); return; } - final boundary = context.findRenderObject() as RenderRepaintBoundary; - final image = await boundary.toImage(pixelRatio: 3.0); + final boundary = context.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 3); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - var pngBytes = byteData?.buffer.asUint8List(); + final pngBytes = byteData?.buffer.asUint8List(); locator().pop(pngBytes); } @@ -94,7 +94,7 @@ class _LocationScreenState extends State { onScaleEnd: (details) { if (kDebugMode) { print( - "Location: ${_controller.center.latitude}, ${_controller.center.longitude}"); + 'Location: ${_controller.center.latitude}, ${_controller.center.longitude}'); } }, child: SizedBox( @@ -122,7 +122,7 @@ class _LocationScreenState extends State { Align( alignment: Alignment.bottomRight, child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: PlatformIconButton( icon: const Icon( Icons.location_searching, diff --git a/lib/screens/lock_screen.dart b/lib/screens/lock_screen.dart index c9437ce..76102e7 100644 --- a/lib/screens/lock_screen.dart +++ b/lib/screens/lock_screen.dart @@ -1,16 +1,16 @@ -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/screens/base.dart'; -import 'package:enough_mail_app/services/biometrics_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; import 'package:enough_platform_widgets/platform.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import '../l10n/app_localizations.g.dart'; +import '../l10n/extension.dart'; +import '../locator.dart'; +import '../services/biometrics_service.dart'; +import '../services/navigation_service.dart'; +import 'base.dart'; class LockScreen extends StatelessWidget { - const LockScreen({Key? key}) : super(key: key); + const LockScreen({super.key}); @override Widget build(BuildContext context) { @@ -24,8 +24,7 @@ class LockScreen extends StatelessWidget { ); } - Widget _buildContent(BuildContext context, AppLocalizations localizations) { - return WillPopScope( + Widget _buildContent(BuildContext context, AppLocalizations localizations) => WillPopScope( onWillPop: () => Future.value(false), child: Center( child: Column( @@ -33,7 +32,7 @@ class LockScreen extends StatelessWidget { children: [ Icon(PlatformInfo.isCupertino ? CupertinoIcons.lock : Icons.lock), Padding( - padding: const EdgeInsets.all(32.0), + padding: const EdgeInsets.all(32), child: Text(localizations.lockScreenIntro), ), PlatformTextButton( @@ -44,9 +43,8 @@ class LockScreen extends StatelessWidget { ), ), ); - } - void _authenticate(BuildContext context) async { + Future _authenticate(BuildContext context) async { final didAuthencate = await locator().authenticate(); if (didAuthencate) { locator().pop(); diff --git a/lib/screens/mail_screen.dart b/lib/screens/mail_screen.dart new file mode 100644 index 0000000..f3ff4f3 --- /dev/null +++ b/lib/screens/mail_screen.dart @@ -0,0 +1,43 @@ +import 'package:enough_platform_widgets/platform.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../account/model.dart'; +import '../l10n/extension.dart'; +import '../mail/provider.dart'; +import 'base.dart'; +import 'message_source_screen.dart'; + +/// Displays the mail for a given account +class MailScreen extends ConsumerWidget { + /// Creates a [MailScreen] + const MailScreen({super.key, required this.account}); + + /// The account to display + final Account account; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final text = context.text; + final sourceFuture = ref.watch(sourceProvider(account: account)); + final title = + account is UnifiedAccount ? text.unifiedAccountName : account.name; + final subtitle = account.fromAddress.email; + + return sourceFuture.when( + loading: () => BasePage( + title: title, + subtitle: subtitle, + content: const Center( + child: PlatformProgressIndicator(), + ), + ), + error: (error, stack) => BasePage( + title: title, + subtitle: subtitle, + content: Center(child: Text('$error')), + ), + data: (source) => MessageSourceScreen(messageSource: source), + ); + } +} diff --git a/lib/screens/media_screen.dart b/lib/screens/media_screen.dart index 202b3e5..29dde86 100644 --- a/lib/screens/media_screen.dart +++ b/lib/screens/media_screen.dart @@ -11,9 +11,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path_provider/path_provider.dart' as pathprovider; import 'package:share_plus/share_plus.dart'; +import '../account/model.dart'; import '../l10n/extension.dart'; import '../locator.dart'; -import '../models/account.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; import '../models/message_source.dart'; diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index d6972a8..1dfdc00 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -29,7 +29,6 @@ import '../widgets/button_text.dart'; import '../widgets/empty_message.dart'; import '../widgets/expansion_wrap.dart'; import '../widgets/ical_interactive_media.dart'; -import '../widgets/inherited_widgets.dart'; import '../widgets/mail_address_chip.dart'; import '../widgets/message_actions.dart'; import '../widgets/message_overview_content.dart'; @@ -198,10 +197,8 @@ class _MessageContentState extends ConsumerState<_MessageContent> { @override Widget build(BuildContext context) { final localizations = context.text; - return MessageWidget( - message: widget.message, - child: _buildMailDetails(localizations), - ); + + return _buildMailDetails(localizations); } Widget _buildMailDetails(AppLocalizations localizations) => @@ -322,7 +319,9 @@ class _MessageContentState extends ConsumerState<_MessageContent> { ], ), if (ReadReceiptButton.shouldBeShown(mime, ref.read(settingsProvider))) - const ReadReceiptButton(), + ReadReceiptButton( + message: widget.message, + ), ], ); } @@ -706,7 +705,8 @@ class _ThreadSequenceButtonState extends State { } class ReadReceiptButton extends StatefulWidget { - const ReadReceiptButton({super.key}); + const ReadReceiptButton({super.key, required this.message}); + final Message message; @override State createState() => _ReadReceiptButtonState(); @@ -721,12 +721,14 @@ class _ReadReceiptButtonState extends State { @override Widget build(BuildContext context) { - final message = Message.of(context)!; + final message = widget.message; final mime = message.mimeMessage; final localizations = context.text; if (mime.isReadReceiptSent) { - return Text(localizations.detailsReadReceiptSentStatus, - style: Theme.of(context).textTheme.bodySmall); + return Text( + localizations.detailsReadReceiptSentStatus, + style: Theme.of(context).textTheme.bodySmall, + ); } else if (_isSendingReadReceipt) { return const PlatformProgressIndicator(); } else { diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index a6c467f..7c7c66d 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -28,7 +28,6 @@ import '../widgets/app_drawer.dart'; import '../widgets/cupertino_status_bar.dart'; import '../widgets/empty_message.dart'; import '../widgets/icon_text.dart'; -import '../widgets/inherited_widgets.dart'; import '../widgets/mailbox_tree.dart'; import '../widgets/menu_with_badge.dart'; import '../widgets/message_overview_content.dart'; @@ -40,10 +39,13 @@ enum _Visualization { stack, list } /// Loads a message source future class AsyncMessageSourceScreen extends StatelessWidget { + /// Creates a [AsyncMessageSourceScreen] const AsyncMessageSourceScreen({ super.key, required this.messageSourceFuture, }); + + /// The future to load the message source final Future messageSourceFuture; @override @@ -62,6 +64,7 @@ class AsyncMessageSourceScreen extends StatelessWidget { ), ); } + return BasePage( title: context.text.homeLoadingMessageSourceTitle, content: Center( @@ -78,6 +81,7 @@ class MessageSourceScreen extends ConsumerStatefulWidget { super.key, required this.messageSource, }); + final MessageSource messageSource; @override @@ -152,19 +156,18 @@ class _MessageSourceScreenState extends ConsumerState if (source == locator().messageSource) { // listen to changes: _updateMessageSource = true; - MailServiceWidget.of(context); } else if (_updateMessageSource) { _updateMessageSource = false; - final state = MailServiceWidget.of(context); - if (state != null) { - final source = state.messageSource; - if (source != null) { - _sectionedMessageSource.removeListener(_update); - _sectionedMessageSource = DateSectionedMessageSource(source); - _sectionedMessageSource.addListener(_update); - _messageLoader = initMessageSource(); - } - } + // final state = MailServiceWidget.of(context); + // if (state != null) { + // final source = state.messageSource; + // if (source != null) { + // _sectionedMessageSource.removeListener(_update); + // _sectionedMessageSource = DateSectionedMessageSource(source); + // _sectionedMessageSource.addListener(_update); + // _messageLoader = initMessageSource(); + // } + // } } final searchColor = theme.brightness == Brightness.light ? theme.colorScheme.onSecondary @@ -354,304 +357,295 @@ class _MessageSourceScreenState extends ConsumerState : null, ) : null, - body: MessageSourceWidget( - messageSource: source, - child: FutureBuilder( - future: _messageLoader, - builder: (context, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return Center( - child: Row( - children: [ - const Padding( - padding: EdgeInsets.all(8), - child: PlatformProgressIndicator(), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - localizations.homeLoading( - source.name ?? source.description ?? ''), - ), + body: FutureBuilder( + future: _messageLoader, + builder: (context, snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.none: + case ConnectionState.waiting: + case ConnectionState.active: + return Center( + child: Row( + children: [ + const Padding( + padding: EdgeInsets.all(8), + child: PlatformProgressIndicator(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + localizations.homeLoading( + source.name ?? source.description ?? ''), ), ), - ], - ), - ); - case ConnectionState.done: - if (_visualization == _Visualization.stack) { - return WillPopScope( - onWillPop: () { - switchVisualization(_Visualization.list); - return Future.value(false); - }, - child: MessageStack(messageSource: source), - ); - } - final settings = ref.read(settingsProvider); - final swipeLeftToRightAction = settings.swipeLeftToRightAction; - final swipeRightToLeftAction = settings.swipeRightToLeftAction; - + ), + ], + ), + ); + case ConnectionState.done: + if (_visualization == _Visualization.stack) { return WillPopScope( onWillPop: () { - if (_isInSelectionMode) { - leaveSelectionMode(); - return Future.value(false); - } - return Future.value(true); + switchVisualization(_Visualization.list); + return Future.value(false); }, - child: RefreshIndicator( - onRefresh: () async { - await _sectionedMessageSource.refresh(); - }, - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - PlatformSliverAppBar( - stretch: true, - title: appBarTitle, - leading: - (locator().hasAccountsWithErrors()) - ? MenuWithBadge( - iOSText: - '\u2329 ${localizations.accountsTitle}', - ) - : null, - previousPageTitle: - source.parentName ?? localizations.accountsTitle, - floating: !_isInSearchMode, - pinned: _isInSearchMode, - actions: appBarActions, - cupertinoTransitionBetweenRoutes: true, - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - //print('building message item at $index'); - if (showSearchTextField) { - if (index == 0) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: CupertinoSearch( - messageSource: source, + child: MessageStack(messageSource: source), + ); + } + final settings = ref.read(settingsProvider); + final swipeLeftToRightAction = settings.swipeLeftToRightAction; + final swipeRightToLeftAction = settings.swipeRightToLeftAction; + + return WillPopScope( + onWillPop: () { + if (_isInSelectionMode) { + leaveSelectionMode(); + return Future.value(false); + } + return Future.value(true); + }, + child: RefreshIndicator( + onRefresh: () async { + await _sectionedMessageSource.refresh(); + }, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + PlatformSliverAppBar( + stretch: true, + title: appBarTitle, + leading: + (locator().hasAccountsWithErrors()) + ? MenuWithBadge( + iOSText: + '\u2329 ${localizations.accountsTitle}', + ) + : null, + previousPageTitle: + source.parentName ?? localizations.accountsTitle, + floating: !_isInSearchMode, + pinned: _isInSearchMode, + actions: appBarActions, + cupertinoTransitionBetweenRoutes: true, + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + //print('building message item at $index'); + if (showSearchTextField) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: CupertinoSearch( + messageSource: source, + ), + ); + } + index--; + } + if (zeroPosWidget != null) { + if (index == 0) { + return zeroPosWidget; + } + index--; + } + return FutureBuilder( + future: + _sectionedMessageSource.getElementAt(index), + initialData: _sectionedMessageSource + .getCachedElementAt(index), + builder: (context, snapshot) { + if (snapshot.hasError) { + return PlatformListTile( + title: const Row( + children: [ + Icon(Icons.replay), + // TODO(RV): localize reload + Text(' reload'), + ], ), + onTap: () { + // TODO(RV): implement reload + setState(() {}); + }, ); } - index--; - } - if (zeroPosWidget != null) { - if (index == 0) { - return zeroPosWidget; - } - index--; - } - return FutureBuilder( - future: - _sectionedMessageSource.getElementAt(index), - initialData: _sectionedMessageSource - .getCachedElementAt(index), - builder: (context, snapshot) { - if (snapshot.hasError) { - return PlatformListTile( - title: const Row( - children: [ - Icon(Icons.replay), - // TODO(RV): localize reload - Text(' reload'), - ], - ), - onTap: () { - // TODO(RV): implement reload - setState(() {}); - }, - ); - } - final element = snapshot.data; + final element = snapshot.data; - if (element == null) { - return const EmptyMessage(); - } - final section = element.section; - if (section != null) { - final text = i18nService.formatDateRange( - section.range, section.date); - return GestureDetector( - onLongPress: () async { - _selectedMessages = - await _sectionedMessageSource - .getMessagesForSection(section); - for (final m in _selectedMessages) { - m.isSelected = true; - } - setState(() { - _isInSelectionMode = true; - }); - }, - onTap: !_isInSelectionMode - ? null - : () async { - final sectionMessages = - await _sectionedMessageSource - .getMessagesForSection( - section); - final doSelect = !sectionMessages - .first.isSelected; - for (final msg - in sectionMessages) { - if (doSelect) { - if (!msg.isSelected) { - msg.isSelected = true; - _selectedMessages.add(msg); - } - } else { - if (msg.isSelected) { - msg.isSelected = false; - _selectedMessages - .remove(msg); - } + if (element == null) { + return const EmptyMessage(); + } + final section = element.section; + if (section != null) { + final text = i18nService.formatDateRange( + section.range, section.date); + return GestureDetector( + onLongPress: () async { + _selectedMessages = + await _sectionedMessageSource + .getMessagesForSection(section); + for (final m in _selectedMessages) { + m.isSelected = true; + } + setState(() { + _isInSelectionMode = true; + }); + }, + onTap: !_isInSelectionMode + ? null + : () async { + final sectionMessages = + await _sectionedMessageSource + .getMessagesForSection( + section); + final doSelect = !sectionMessages + .first.isSelected; + for (final msg in sectionMessages) { + if (doSelect) { + if (!msg.isSelected) { + msg.isSelected = true; + _selectedMessages.add(msg); + } + } else { + if (msg.isSelected) { + msg.isSelected = false; + _selectedMessages.remove(msg); } } - setState(() {}); - }, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 16, - right: 8, - bottom: 4, - top: 16, - ), - child: Text( - text, - style: TextStyle( - color: - theme.colorScheme.secondary, - ), - ), + } + setState(() {}); + }, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 8, + bottom: 4, + top: 16, ), - const Divider() - ], - ), - ); - } - final message = element.message!; - // print( - // '$index subject=${message.mimeMessage?.decodeSubject()}'); - return Dismissible( - key: ValueKey(message), - dismissThresholds: { - DismissDirection.startToEnd: - swipeLeftToRightAction - .dismissThreshold, - DismissDirection.endToStart: - swipeRightToLeftAction - .dismissThreshold, - }, - background: Container( - color: swipeLeftToRightAction - .colorBackground, - padding: const EdgeInsets.symmetric( - horizontal: 8), - alignment: - AlignmentDirectional.centerStart, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8), - child: Text( - swipeLeftToRightAction - .name(localizations), - style: TextStyle( - color: swipeLeftToRightAction - .colorForeground), + child: Text( + text, + style: TextStyle( + color: + theme.colorScheme.secondary, ), ), - Icon(swipeLeftToRightAction.icon, - color: swipeLeftToRightAction - .colorIcon), - ], - ), + ), + const Divider() + ], ), - secondaryBackground: Container( - color: swipeRightToLeftAction - .colorBackground, - padding: const EdgeInsets.symmetric( - horizontal: 8), - alignment: AlignmentDirectional.centerEnd, - child: Row( - mainAxisAlignment: - MainAxisAlignment.end, - children: [ - Icon( - swipeRightToLeftAction.icon, - color: swipeRightToLeftAction - .colorIcon, - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8), - child: Text( - swipeRightToLeftAction - .name(localizations), - style: TextStyle( - color: swipeRightToLeftAction - .colorForeground), - ), + ); + } + final message = element.message!; + // print( + // '$index subject=${message.mimeMessage?.decodeSubject()}'); + return Dismissible( + key: ValueKey(message), + dismissThresholds: { + DismissDirection.startToEnd: + swipeLeftToRightAction.dismissThreshold, + DismissDirection.endToStart: + swipeRightToLeftAction.dismissThreshold, + }, + background: Container( + color: + swipeLeftToRightAction.colorBackground, + padding: const EdgeInsets.symmetric( + horizontal: 8), + alignment: AlignmentDirectional.centerStart, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8), + child: Text( + swipeLeftToRightAction + .name(localizations), + style: TextStyle( + color: swipeLeftToRightAction + .colorForeground), ), - ], - ), + ), + Icon(swipeLeftToRightAction.icon, + color: swipeLeftToRightAction + .colorIcon), + ], ), - child: MessageOverview( - message, - _isInSelectionMode, - onMessageTap, - onMessageLongPress, - isSentMessage: isSentFolder, + ), + secondaryBackground: Container( + color: + swipeRightToLeftAction.colorBackground, + padding: const EdgeInsets.symmetric( + horizontal: 8), + alignment: AlignmentDirectional.centerEnd, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Icon( + swipeRightToLeftAction.icon, + color: + swipeRightToLeftAction.colorIcon, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8), + child: Text( + swipeRightToLeftAction + .name(localizations), + style: TextStyle( + color: swipeRightToLeftAction + .colorForeground), + ), + ), + ], ), - confirmDismiss: (direction) { - final swipeAction = direction == - DismissDirection.startToEnd - ? swipeLeftToRightAction - : swipeRightToLeftAction; - fireSwipeAction(swipeAction, message); - return Future.value( - swipeAction.isMessageMoving, - ); - }, - ); - }, - ); - }, - childCount: _sectionedMessageSource.size + - ((zeroPosWidget != null) ? 1 : 0) + - (showSearchTextField ? 1 : 0), - semanticIndexCallback: - (Widget widget, int localIndex) { - if (widget is MessageOverview) { - return widget.message.sourceIndex; - } - return null; - }, - ), + ), + child: MessageOverview( + message, + _isInSelectionMode, + onMessageTap, + onMessageLongPress, + isSentMessage: isSentFolder, + ), + confirmDismiss: (direction) { + final swipeAction = + direction == DismissDirection.startToEnd + ? swipeLeftToRightAction + : swipeRightToLeftAction; + fireSwipeAction(swipeAction, message); + return Future.value( + swipeAction.isMessageMoving, + ); + }, + ); + }, + ); + }, + childCount: _sectionedMessageSource.size + + ((zeroPosWidget != null) ? 1 : 0) + + (showSearchTextField ? 1 : 0), + semanticIndexCallback: + (Widget widget, int localIndex) { + if (widget is MessageOverview) { + return widget.message.sourceIndex; + } + return null; + }, ), - ], - ), + ), + ], ), - ); - } - }, - ), + ), + ); + } + }, ), ); } diff --git a/lib/screens/all_screens.dart b/lib/screens/screens.dart similarity index 88% rename from lib/screens/all_screens.dart rename to lib/screens/screens.dart index 981864f..53dfa93 100644 --- a/lib/screens/all_screens.dart +++ b/lib/screens/screens.dart @@ -2,8 +2,10 @@ export 'account_add_screen.dart'; export 'account_edit_screen.dart'; export 'account_server_details_screen.dart'; export 'compose_screen.dart'; +export 'home_screen.dart'; export 'location_screen.dart'; export 'lock_screen.dart'; +export 'mail_screen.dart'; export 'media_screen.dart'; export 'message_details_screen.dart'; export 'message_source_screen.dart'; diff --git a/lib/screens/sourcecode_screen.dart b/lib/screens/sourcecode_screen.dart index 5049265..212a370 100644 --- a/lib/screens/sourcecode_screen.dart +++ b/lib/screens/sourcecode_screen.dart @@ -1,12 +1,12 @@ import 'package:enough_mail/mime.dart'; -import 'package:enough_mail_app/screens/base.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'base.dart'; + class SourceCodeScreen extends StatelessWidget { + const SourceCodeScreen({super.key, required this.mimeMessage}); final MimeMessage? mimeMessage; - const SourceCodeScreen({Key? key, required this.mimeMessage}) - : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index d9296fd..f05e30b 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,11 +1,11 @@ import 'dart:math'; -import 'package:enough_mail_app/l10n/extension.dart'; +import '../l10n/extension.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; class SplashScreen extends StatelessWidget { - const SplashScreen({Key? key}) : super(key: key); + const SplashScreen({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/screens/webview_screen.dart b/lib/screens/webview_screen.dart index 1836aad..873507d 100644 --- a/lib/screens/webview_screen.dart +++ b/lib/screens/webview_screen.dart @@ -1,14 +1,14 @@ -import 'package:enough_mail_app/models/web_view_configuration.dart'; -import 'package:enough_mail_app/screens/base.dart'; import 'package:enough_mail_flutter/enough_mail_flutter.dart' as webview; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import '../models/web_view_configuration.dart'; +import 'base.dart'; + // import '../l10n/app_localizations.g.dart'; class WebViewScreen extends StatelessWidget { - const WebViewScreen({Key? key, required this.configuration}) - : super(key: key); + const WebViewScreen({super.key, required this.configuration}); final WebViewConfiguration configuration; diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 801ea03..a6a046e 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,9 +1,9 @@ -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/routes.dart'; -import 'package:enough_mail_app/services/icon_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; -import 'package:enough_mail_app/widgets/button_text.dart'; -import 'package:enough_mail_app/widgets/legalese.dart'; +import '../l10n/extension.dart'; +import '../routes.dart'; +import '../services/icon_service.dart'; +import '../services/navigation_service.dart'; +import '../widgets/button_text.dart'; +import '../widgets/legalese.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:introduction_screen/introduction_screen.dart'; @@ -13,7 +13,7 @@ import '../l10n/app_localizations.g.dart'; import '../locator.dart'; class WelcomeScreen extends StatelessWidget { - const WelcomeScreen({Key? key}) : super(key: key); + const WelcomeScreen({super.key}); @override Widget build(BuildContext context) { @@ -32,9 +32,7 @@ class WelcomeScreen extends StatelessWidget { locator() .push(Routes.accountAdd, arguments: true); }, - showDoneButton: true, next: ButtonText(localizations.actionNext), - showNextButton: true, skip: ButtonText(localizations.actionSkip), showSkipButton: true, ), @@ -42,8 +40,7 @@ class WelcomeScreen extends StatelessWidget { ); //Material App } - List _buildPages(AppLocalizations localizations) { - return [ + List _buildPages(AppLocalizations localizations) => [ PageViewModel( title: localizations.welcomePanel1Title, body: localizations.welcomePanel1Text, @@ -88,13 +85,11 @@ class WelcomeScreen extends StatelessWidget { footer: _buildFooter(localizations), ), ]; - } - Widget _buildFooter(AppLocalizations localizations) { - return Column( + Widget _buildFooter(AppLocalizations localizations) => Column( children: [ Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16), child: Shimmer( duration: const Duration(seconds: 4), interval: const Duration(seconds: 6), @@ -111,10 +106,9 @@ class WelcomeScreen extends StatelessWidget { ), ), const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Legalese(), ), ], ); - } } diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index cc8b4fd..454b5b3 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -3,9 +3,9 @@ import 'dart:math'; import 'package:background_fetch/background_fetch.dart'; import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/models/async_mime_source_factory.dart'; -import 'package:enough_mail_app/models/background_update_info.dart'; -import 'package:enough_mail_app/services/notification_service.dart'; +import '../models/async_mime_source_factory.dart'; +import '../models/background_update_info.dart'; +import 'notification_service.dart'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,13 +40,11 @@ class BackgroundService { } } BackgroundFetch.finish(taskId); - }, (String taskId) { - BackgroundFetch.finish(taskId); - }); + }, BackgroundFetch.finish); await BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); } - static void backgroundFetchHeadlessTask(HeadlessTask task) async { + static Future backgroundFetchHeadlessTask(HeadlessTask task) async { final taskId = task.taskId; if (kDebugMode) { print( @@ -221,7 +219,7 @@ class BackgroundService { fetchPreference: FetchPreference.envelope); for (final mimeMessage in mimeMessages) { if (!mimeMessage.isSeen) { - notificationService.sendLocalNotificationForMail( + await notificationService.sendLocalNotificationForMail( mimeMessage, mailClient, ); diff --git a/lib/services/biometrics_service.dart b/lib/services/biometrics_service.dart index a5752db..90d344f 100644 --- a/lib/services/biometrics_service.dart +++ b/lib/services/biometrics_service.dart @@ -1,15 +1,20 @@ -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/app_service.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; +import 'dart:async'; + import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:local_auth/local_auth.dart'; +import '../locator.dart'; +import 'app_service.dart'; +import 'i18n_service.dart'; + +/// Handles biometrics class BiometricsService { bool _isResolved = false; bool _isSupported = false; final _localAuth = LocalAuthentication(); + /// Checks if the device supports biometrics Future isDeviceSupported() async { if (_isResolved) { return _isSupported; @@ -25,9 +30,11 @@ class BiometricsService { } } _isResolved = true; + return _isSupported; } + /// Authenticates the user with biometrics Future authenticate({String? reason}) async { if (!_isResolved) { await isDeviceSupported(); @@ -41,20 +48,20 @@ class BiometricsService { final result = await _localAuth.authenticate( localizedReason: reason, options: const AuthenticationOptions( - stickyAuth: false, sensitiveTransaction: false, ), ); - Future.delayed(const Duration(seconds: 2)).then( - (value) => - locator().ignoreBiometricsCheckAtNextResume = false, - ); + unawaited(Future.delayed(const Duration(seconds: 2)).then( + (_) => locator().ignoreBiometricsCheckAtNextResume = false, + )); + return result; } catch (e, s) { if (kDebugMode) { print('Authentication failed with $e $s'); } } + return false; } @@ -68,6 +75,7 @@ class BiometricsService { return localizations.securityUnlockWithTouchId; } } + return localizations.securityUnlockReason; } } diff --git a/lib/services/contact_service.dart b/lib/services/contact_service.dart index 1f999f2..7d8a9b8 100644 --- a/lib/services/contact_service.dart +++ b/lib/services/contact_service.dart @@ -1,10 +1,11 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/models/contact.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; import 'package:flutter/foundation.dart'; +import '../account/model.dart'; +import '../locator.dart'; +import '../models/contact.dart'; +import 'mail_service.dart'; + class ContactService { Future getForAccount(RealAccount account) async { var contactManager = account.contactManager; diff --git a/lib/services/date_service.dart b/lib/services/date_service.dart index 71a9f18..677a881 100644 --- a/lib/services/date_service.dart +++ b/lib/services/date_service.dart @@ -1,78 +1,94 @@ -import 'package:enough_mail_app/locator.dart'; - +import '../locator.dart'; import 'i18n_service.dart'; +/// The date section of a given date enum DateSectionRange { + /// The date is in the future, more distant than tomorrow future, + + /// The date is tomorrow tomorrow, + + /// The date is today today, + + /// The date is yesterday yesterday, + + /// The date is in the current week thisWeek, + + /// The date is in the last week lastWeek, + + /// The date is in the current month thisMonth, + + /// The date is in the current year monthOfThisYear, - monthAndYear + + /// The date is in a different year + monthAndYear, } +/// Allows to determine the date section of a given date class DateService { - DateTime? _today; + /// Creates a new [DateService] + DateService() { + _setupDates(); + } + + late DateTime _today; late DateTime _tomorrow; late DateTime _dayAfterTomorrow; - DateTime? _yesterday; - DateTime? _thisWeek; + late DateTime _yesterday; + late DateTime _thisWeek; late DateTime _lastWeek; void _setupDates() { - final nw = DateTime.now(); - _today = DateTime(nw.year, nw.month, nw.day); - _tomorrow = _today!.add(const Duration(days: 1)); + final now = DateTime.now(); + _today = DateTime(now.year, now.month, now.day); + _tomorrow = _today.add(const Duration(days: 1)); _dayAfterTomorrow = _tomorrow.add(const Duration(days: 1)); - _yesterday = _today!.subtract(const Duration(days: 1)); + _yesterday = _today.subtract(const Duration(days: 1)); final firstDayOfWeek = locator().firstDayOfWeek; - if (_today!.weekday == firstDayOfWeek) { + if (_today.weekday == firstDayOfWeek) { _thisWeek = _today; - } else if (_yesterday!.weekday == firstDayOfWeek) { + } else if (_yesterday.weekday == firstDayOfWeek) { _thisWeek = _yesterday; } else { - if (_today!.weekday > firstDayOfWeek) { - _thisWeek = - _today!.subtract(Duration(days: _today!.weekday - firstDayOfWeek)); - } else { - _thisWeek = _today! - .subtract(Duration(days: (_today!.weekday + 7 - firstDayOfWeek))); - } + _thisWeek = _today.weekday > firstDayOfWeek + ? _today.subtract(Duration(days: _today.weekday - firstDayOfWeek)) + : _today + .subtract(Duration(days: _today.weekday + 7 - firstDayOfWeek)); } - _lastWeek = _thisWeek!.subtract(const Duration(days: 7)); + _lastWeek = _thisWeek.subtract(const Duration(days: 7)); } + /// Determines the date section of the given [localTime] DateSectionRange determineDateSection(DateTime localTime) { - if (_today == null || _today!.weekday != DateTime.now().weekday) { + if (_today.weekday != DateTime.now().weekday) { _setupDates(); } - if (localTime.isAfter(_today!)) { - if (localTime.isBefore(_tomorrow)) { - return DateSectionRange.today; - } else { - if (localTime.isBefore(_dayAfterTomorrow)) { - return DateSectionRange.tomorrow; - } else { - return DateSectionRange.future; - } - } + if (localTime.isAfter(_today)) { + return localTime.isBefore(_tomorrow) + ? DateSectionRange.today + : localTime.isBefore(_dayAfterTomorrow) + ? DateSectionRange.tomorrow + : DateSectionRange.future; } - if (localTime.isAfter(_yesterday!)) { + if (localTime.isAfter(_yesterday)) { return DateSectionRange.yesterday; - } else if (localTime.isAfter(_thisWeek!)) { + } else if (localTime.isAfter(_thisWeek)) { return DateSectionRange.thisWeek; } else if (localTime.isAfter(_lastWeek)) { return DateSectionRange.lastWeek; - } else if (localTime.year == _today!.year) { - if (localTime.month == _today!.month) { - return DateSectionRange.thisMonth; - } else { - return DateSectionRange.monthOfThisYear; - } + } else if (localTime.year == _today.year) { + return localTime.month == _today.month + ? DateSectionRange.thisMonth + : DateSectionRange.monthOfThisYear; } + return DateSectionRange.monthAndYear; } } diff --git a/lib/services/i18n_service.dart b/lib/services/i18n_service.dart index eb70304..2079d1d 100644 --- a/lib/services/i18n_service.dart +++ b/lib/services/i18n_service.dart @@ -1,12 +1,13 @@ import 'package:enough_icalendar/enough_icalendar.dart'; -import 'package:enough_mail_app/services/date_service.dart'; import 'package:flutter/material.dart'; +import 'package:intl/date_symbol_data_local.dart' as date_intl; import 'package:intl/date_symbols.dart'; import 'package:intl/intl.dart' as intl; -import 'package:intl/date_symbol_data_local.dart' as date_intl; -import '../l10n/app_localizations.g.dart'; import 'package:intl/intl.dart'; +import '../l10n/app_localizations.g.dart'; +import 'date_service.dart'; + class I18nService { /// Day of week for countries (in two letter code) for which the week does not start on Monday /// Source: http://chartsbin.com/view/41671 @@ -82,30 +83,32 @@ class I18nService { _localizations = localizations; _locale = locale; final countryCode = locale.countryCode?.toLowerCase(); - if (countryCode == null) { - firstDayOfWeek = DateTime.monday; - } else { - firstDayOfWeek = - firstDayOfWeekPerCountryCode[countryCode] ?? DateTime.monday; - } + firstDayOfWeek = countryCode == null + ? DateTime.monday + : firstDayOfWeekPerCountryCode[countryCode] ?? DateTime.monday; final localeText = locale.toString(); - date_intl.initializeDateFormatting(localeText).then((value) { - _dateTimeFormatToday = intl.DateFormat.jm(localeText); - _dateTimeFormatLastWeek = intl.DateFormat.E(localeText).add_jm(); - _dateTimeFormat = intl.DateFormat.yMd(localeText).add_jm(); - _dateTimeFormatLong = intl.DateFormat.yMMMMEEEEd(localeText).add_jm(); - _dateFormatDayInLastWeek = intl.DateFormat.E(localeText); - _dateFormatDayBeforeLastWeek = intl.DateFormat.yMd(localeText); - _dateFormatLong = intl.DateFormat.yMMMMEEEEd(localeText); - _dateFormatShort = intl.DateFormat.yMd(localeText); - _dateFormatWeekday = intl.DateFormat.EEEE(localeText); - // _dateFormatMonth = intl.DateFormat.MMMM(localeText); - // _dateFormatNoTime = intl.DateFormat.yMEd(localeText); - }); + date_intl.initializeDateFormatting(localeText).then( + (_) { + _dateTimeFormatToday = intl.DateFormat.jm(localeText); + _dateTimeFormatLastWeek = intl.DateFormat.E(localeText).add_jm(); + _dateTimeFormat = intl.DateFormat.yMd(localeText).add_jm(); + _dateTimeFormatLong = intl.DateFormat.yMMMMEEEEd(localeText).add_jm(); + _dateFormatDayInLastWeek = intl.DateFormat.E(localeText); + _dateFormatDayBeforeLastWeek = intl.DateFormat.yMd(localeText); + _dateFormatLong = intl.DateFormat.yMMMMEEEEd(localeText); + _dateFormatShort = intl.DateFormat.yMd(localeText); + _dateFormatWeekday = intl.DateFormat.EEEE(localeText); + // _dateFormatMonth = intl.DateFormat.MMMM(localeText); + // _dateFormatNoTime = intl.DateFormat.yMEd(localeText); + }, + ); } - String formatDateTime(DateTime? dateTime, - {bool alwaysUseAbsoluteFormat = false, useLongFormat = false}) { + String formatDateTime( + DateTime? dateTime, { + bool alwaysUseAbsoluteFormat = false, + useLongFormat = false, + }) { if (dateTime == null) { return _localizations.dateUndefined; } @@ -116,11 +119,14 @@ class I18nService { return _dateTimeFormat.format(dateTime); } final nw = DateTime.now(); - final today = nw.subtract(Duration( + final today = nw.subtract( + Duration( hours: nw.hour, minutes: nw.minute, seconds: nw.second, - milliseconds: nw.millisecond)); + milliseconds: nw.millisecond, + ), + ); final lastWeek = today.subtract(const Duration(days: 7)); String date; if (dateTime.isAfter(today)) { @@ -128,12 +134,11 @@ class I18nService { } else if (dateTime.isAfter(lastWeek)) { date = _dateTimeFormatLastWeek.format(dateTime); } else { - if (useLongFormat) { - date = _dateTimeFormatLong.format(dateTime); - } else { - date = _dateTimeFormat.format(dateTime); - } + date = useLongFormat + ? _dateTimeFormatLong.format(dateTime) + : _dateTimeFormat.format(dateTime); } + return date; } @@ -142,21 +147,22 @@ class I18nService { return _localizations.dateUndefined; } - if (useLongFormat) { - return _dateFormatLong.format(dateTime); - } else { - return _dateFormatShort.format(dateTime); - } + return useLongFormat + ? _dateFormatLong.format(dateTime) + : _dateFormatShort.format(dateTime); } String formatDay(DateTime dateTime) { final messageDate = dateTime; final nw = DateTime.now(); - final today = nw.subtract(Duration( + final today = nw.subtract( + Duration( hours: nw.hour, minutes: nw.minute, seconds: nw.second, - milliseconds: nw.millisecond)); + milliseconds: nw.millisecond, + ), + ); if (messageDate.isAfter(today)) { return localizations.dateDayToday; } else if (messageDate.isAfter(today.subtract(const Duration(days: 1)))) { @@ -169,14 +175,13 @@ class I18nService { } } - String formatWeekDay(DateTime dateTime) { - return _dateFormatWeekday.format(dateTime); - } + String formatWeekDay(DateTime dateTime) => + _dateFormatWeekday.format(dateTime); List formatWeekDays({int? startOfWeekDay, bool abbreviate = false}) { startOfWeekDay ??= firstDayOfWeek; final dateSymbols = - (date_intl.dateTimeSymbolMap()[_locale.toString()] as DateSymbols); + date_intl.dateTimeSymbolMap()[_locale.toString()] as DateSymbols; final weekdays = abbreviate ? dateSymbols.STANDALONESHORTWEEKDAYS : dateSymbols.STANDALONEWEEKDAYS; @@ -189,6 +194,7 @@ class I18nService { final name = weekdays[nameIndex]; result.add(WeekDay(day, name)); } + return result; } @@ -215,9 +221,8 @@ class I18nService { } } - String formatTimeOfDay(TimeOfDay timeOfDay, BuildContext context) { - return timeOfDay.format(context); - } + String formatTimeOfDay(TimeOfDay timeOfDay, BuildContext context) => + timeOfDay.format(context); String? formatMemory(int? size) { if (size == null) { @@ -231,6 +236,7 @@ class I18nService { unitIndex--; } final sizeFormat = NumberFormat('###.0#'); + return '${sizeFormat.format(sizeD)} ${units[unitIndex]}'; } @@ -266,13 +272,13 @@ class I18nService { if (buffer.isEmpty) { buffer.write(localizations.durationEmpty); } + return buffer.toString(); } } class WeekDay { + const WeekDay(this.day, this.name); final int day; final String name; - - const WeekDay(this.day, this.name); } diff --git a/lib/services/icon_service.dart b/lib/services/icon_service.dart index 42401ac..dec566f 100644 --- a/lib/services/icon_service.dart +++ b/lib/services/icon_service.dart @@ -158,7 +158,7 @@ class IconService { case 6: return Icon(Icons.looks_6_outlined, size: size); default: - final style = size == null ? null : TextStyle(fontSize: (size * 0.8)); + final style = size == null ? null : TextStyle(fontSize: size * 0.8); final borderColor = (Theme.of(context).brightness == Brightness.dark) ? const Color(0xffeeeeee) : const Color(0xff000000); diff --git a/lib/services/key_service.dart b/lib/services/key_service.dart index ef634ba..72ced82 100644 --- a/lib/services/key_service.dart +++ b/lib/services/key_service.dart @@ -1,13 +1,17 @@ /// contains rate limited beta keys, /// production keys are stored locally only -import 'package:enough_mail_app/oauth/oauth.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show rootBundle; +import '../logger.dart'; +import '../oauth/oauth.dart'; + +/// Allows to load the keys from assets/keys.txt class KeyService { + /// Creates a new [KeyService] KeyService(); - Future init() async { + /// Loads the key data + Future init() async { try { final text = await rootBundle.loadString('assets/keys.txt'); final lines = @@ -26,26 +30,27 @@ class KeyService { if (valueIndex == -1) { oauth[key] = OauthClientId(value, null); } else { - oauth[key] = OauthClientId(value.substring(0, valueIndex), - value.substring(valueIndex + 1)); + oauth[key] = OauthClientId( + value.substring(0, valueIndex), + value.substring(valueIndex + 1), + ); } } } } catch (e) { - if (kDebugMode) { - print( - 'no assets/keys.txt found. Ensure to specify it in the pubspec.yaml and add the relevant keys there.'); - } + logger.e( + 'no assets/keys.txt found. ' + 'Ensure to specify it in the pubspec.yaml and ' + 'add the relevant keys there.', + ); } } String? _giphy; String? get giphy => _giphy; - bool get hasGiphy => (_giphy != null); + bool get hasGiphy => _giphy != null; final oauth = {}; - bool hasOauthFor(String incomingHostname) { - return (oauth[incomingHostname] != null); - } + bool hasOauthFor(String incomingHostname) => oauth[incomingHostname] != null; } diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index 170f78f..2152c8a 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -1,24 +1,28 @@ import 'package:location/location.dart'; +/// Allows to query the current location class LocationService { Location? _location; bool _serviceEnabled = false; PermissionStatus _permissionStatus = PermissionStatus.denied; + /// Retrieves the current location Future getCurrentLocation() async { - _location ??= Location(); + final location = _location ?? Location(); + _location = location; if (!_serviceEnabled) { - _serviceEnabled = await _location!.requestService(); + _serviceEnabled = await location.requestService(); if (!_serviceEnabled) { return null; } } if (_permissionStatus == PermissionStatus.denied) { - _permissionStatus = await _location!.requestPermission(); + _permissionStatus = await location.requestPermission(); if (_permissionStatus != PermissionStatus.granted) { return null; } } - return await _location!.getLocation(); + + return location.getLocation(); } } diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index ebda47d..7dc130d 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -3,13 +3,12 @@ import 'dart:convert'; import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../account/model.dart'; import '../events/app_event_bus.dart'; import '../l10n/app_localizations.g.dart'; import '../locator.dart'; -import '../models/account.dart'; import '../models/async_mime_source.dart'; import '../models/async_mime_source_factory.dart'; import '../models/message_source.dart'; @@ -17,7 +16,6 @@ import '../models/sender.dart'; import '../routes.dart'; import '../settings/model.dart'; import '../util/gravatar.dart'; -import '../widgets/inherited_widgets.dart'; import 'navigation_service.dart'; import 'notification_service.dart'; import 'providers.dart'; @@ -39,7 +37,7 @@ class MailService implements MimeSourceSubscriber { static const String _keyAccounts = 'accts'; final _storage = const FlutterSecureStorage(); - final _mailClientsPerAccount = {}; + final _mailClientsPerAccount = {}; final _mailboxesPerAccount = >{}; late AppLocalizations _localizations; AppLocalizations get localizations => _localizations; @@ -50,11 +48,13 @@ class MailService implements MimeSourceSubscriber { if (withErrors == null) { return accounts; } + return accounts.where((account) => !withErrors.contains(account)).toList(); } List get accountsWithErrors { final withErrors = _accountsWithErrors; + return withErrors ?? []; } @@ -133,6 +133,7 @@ class MailService implements MimeSourceSubscriber { final Account account = currentAccount ?? unifiedAccount ?? accounts.first; final source = await _createMessageSource(null, account); messageSource = source; + return source.search(search); } @@ -143,7 +144,6 @@ class MailService implements MimeSourceSubscriber { if (mailAccountsForUnified.length > 1) { unifiedAccount = UnifiedAccount( List.from(mailAccountsForUnified), - _localizations.unifiedAccountName, ); final mailboxes = [ Mailbox.virtual(_localizations.unifiedFolderInbox, [MailboxFlag.inbox]), @@ -286,15 +286,12 @@ class MailService implements MimeSourceSubscriber { Future addAccount( RealAccount newAccount, MailClient mailClient, - BuildContext context, ) async { - // TODO(RV): remove BuildContext usage in service // TODO(RV): check if other account with the same name already exists - final state = MailServiceWidget.of(context); final existing = accounts.firstWhereOrNull((account) => account is RealAccount && account.email == newAccount.email); if (existing != null) { - await removeAccount(existing as RealAccount, context); + await removeAccount(existing as RealAccount); } newAccount = await _checkForAddingSentMessages(newAccount); _currentAccount = newAccount; @@ -312,12 +309,8 @@ class MailService implements MimeSourceSubscriber { } final source = await getMessageSourceFor(newAccount); messageSource = source; - if (state != null) { - state.account = newAccount; - state.accounts = accounts; - state.messageSource = source; - } await saveAccounts(); + return true; } @@ -534,10 +527,7 @@ class MailService implements MimeSourceSubscriber { return Sender(MailAddress(null, email), sender.account); } - Future testRemoveAccount(Account account, BuildContext context) async { - // as the original context may belong to a widget that is now disposed, use the navigator's context: - context = locator().currentContext!; - final state = MailServiceWidget.of(context); + Future testRemoveAccount(Account account) async { if (account == currentAccount) { final nextAccount = hasUnifiedAccount ? unifiedAccount @@ -551,17 +541,10 @@ class MailService implements MimeSourceSubscriber { messageSource = null; await locator().push(Routes.welcome, clear: true); } - if (state != null) { - state.messageSource = messageSource; - state.account = _currentAccount; - state.accounts = accounts; - } - } else if (state != null) { - state.accounts = accounts; } } - Future removeAccount(RealAccount account, BuildContext context) async { + Future removeAccount(RealAccount account) async { accounts.remove(account); _mailboxesPerAccount.remove(account); _mailClientsPerAccount.remove(account); @@ -575,17 +558,11 @@ class MailService implements MimeSourceSubscriber { } catch (e) { // ignore } - // TODO(RV): remove usage of BuildContext - // as the original context may belong to a widget that is now disposed, use the navigator's context: - context = locator().currentContext!; - final state = MailServiceWidget.of(context); if (!account.excludeFromUnified) { // updates the unified account await excludeAccountFromUnified( account, true, - context, - updateContext: false, ); } if (account == currentAccount) { @@ -601,13 +578,6 @@ class MailService implements MimeSourceSubscriber { messageSource = null; await locator().push(Routes.welcome, clear: true); } - if (state != null) { - state.messageSource = messageSource; - state.account = _currentAccount; - state.accounts = accounts; - } - } else if (state != null) { - state.accounts = accounts; } await saveAccounts(); @@ -754,15 +724,14 @@ class MailService implements MimeSourceSubscriber { if (futures.isEmpty) { return Future.value(); } + return Future.wait(futures); } Future excludeAccountFromUnified( RealAccount account, bool exclude, - BuildContext context, { - bool updateContext = true, - }) async { + ) async { account.excludeFromUnified = exclude; final unified = unifiedAccount; if (exclude) { @@ -778,13 +747,8 @@ class MailService implements MimeSourceSubscriber { } if (currentAccount == unified && unified != null) { messageSource = await _createMessageSource(null, unified); - if (updateContext) { - final state = MailServiceWidget.of(context); - if (state != null) { - state.messageSource = messageSource; - } - } } + return saveAccounts(); } diff --git a/lib/services/navigation_service.dart b/lib/services/navigation_service.dart index ec26b49..5f0bcdd 100644 --- a/lib/services/navigation_service.dart +++ b/lib/services/navigation_service.dart @@ -1,17 +1,18 @@ -import 'package:enough_mail_app/routes.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import '../routes.dart'; + class NavigationService { - final navigatorKey = GlobalKey(); + GlobalKey get navigatorKey => Routes.navigatorKey; BuildContext? get currentContext => navigatorKey.currentContext; String? get currentRouteName => _currentRouteName; String? _currentRouteName; - Future push( + Future push( String routeName, { Object? arguments, bool replace = false, @@ -21,26 +22,25 @@ class NavigationService { }) { _currentRouteName = routeName; final page = AppRouter.generatePage(routeName, arguments); - Route route; + final state = navigatorKey.currentState; + if (state == null) { + return Future.value(); + } + Route route; if (containsModals) { - route = MaterialWithModalsPageRoute(builder: (_) => page); + route = MaterialWithModalsPageRoute(builder: (_) => page); } else if (fade && !PlatformInfo.isCupertino) { - route = FadeRoute(page: page); + route = FadeRoute(page: page); } else { route = PlatformInfo.isCupertino - ? CupertinoPageRoute(builder: (_) => page) - : MaterialPageRoute(builder: (_) => page); + ? CupertinoPageRoute(builder: (_) => page) + : MaterialPageRoute(builder: (_) => page); } if (clear) { - navigatorKey.currentState!.popUntil((route) => false); - } - if (replace) { - // history.replace(routeName, route); - return navigatorKey.currentState!.pushReplacement(route); - } else { - // history.push(routeName, route); - return navigatorKey.currentState!.push(route); + state.popUntil((route) => false); } + + return replace ? state.pushReplacement(route) : state.push(route); } // void replace(String oldRouteName, String newRouteName, {Object arguments}) { @@ -60,20 +60,27 @@ class NavigationService { // } void popUntil(String routeName) { + final state = navigatorKey.currentState; + if (state == null) { + return; + } // history.popUntil(routeName); - navigatorKey.currentState!.popUntil(ModalRoute.withName(routeName)); + state.popUntil(ModalRoute.withName(routeName)); _currentRouteName = routeName; } - void pop([Object? result]) { + void pop([T? result]) { + final state = navigatorKey.currentState; + if (state == null) { + return; + } // history.pop(); - navigatorKey.currentState!.pop(result); + state.pop(result); _currentRouteName = null; } } -class FadeRoute extends PageRouteBuilder { - final Widget page; +class FadeRoute extends PageRouteBuilder { FadeRoute({required this.page}) : super( pageBuilder: ( @@ -93,4 +100,6 @@ class FadeRoute extends PageRouteBuilder { child: child, ), ); + + final Widget page; } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 52906d7..8c17818 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -6,13 +6,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:json_annotation/json_annotation.dart'; -import 'package:enough_mail_app/models/message.dart' as maily; -import 'package:enough_mail_app/models/message_source.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; - import '../locator.dart'; +import '../models/message.dart' as maily; +import '../models/message_source.dart'; import '../routes.dart'; +import 'mail_service.dart'; +import 'navigation_service.dart'; part 'notification_service.g.dart'; @@ -124,7 +123,7 @@ class NotificationService { final message = maily.Message(mimeMessage, mailClient, messageSource, 0); messageSource.singleMessage = message; - locator() + await locator() .push(Routes.mailDetails, arguments: message); } on MailException catch (e, s) { if (kDebugMode) { @@ -134,14 +133,10 @@ class NotificationService { } } - Future sendLocalNotificationForMailLoadEvent(MailLoadEvent event) { - return sendLocalNotificationForMail(event.message, event.mailClient); - } + Future sendLocalNotificationForMailLoadEvent(MailLoadEvent event) => sendLocalNotificationForMail(event.message, event.mailClient); - Future sendLocalNotificationForMailMessage(maily.Message message) { - return sendLocalNotificationForMail( + Future sendLocalNotificationForMailMessage(maily.Message message) => sendLocalNotificationForMail( message.mimeMessage, message.mailClient); - } Future sendLocalNotificationForMail( MimeMessage mimeMessage, MailClient mailClient) { @@ -191,9 +186,8 @@ class NotificationService { importance: Importance.max, priority: Priority.high, channelShowBadge: channelShowBadge, - showWhen: (when != null), + showWhen: when != null, when: when?.millisecondsSinceEpoch, - playSound: true, sound: const RawResourceAndroidNotificationSound('pop'), ); } else if (Platform.isIOS) { diff --git a/lib/services/providers.dart b/lib/services/providers.dart index bb0b556..a4cda8c 100644 --- a/lib/services/providers.dart +++ b/lib/services/providers.dart @@ -1,15 +1,12 @@ import 'package:enough_mail/discover.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/oauth/oauth.dart'; +import '../l10n/extension.dart'; +import '../oauth/oauth.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; class ProviderService { - final _providersByDomains = {}; - final _providers = []; - List get providers => _providers; ProviderService() { addAll([ @@ -22,6 +19,9 @@ class ProviderService { MailboxOrgProvider(), ]); } + final _providersByDomains = {}; + final _providers = []; + List get providers => _providers; /// Retrieves the provider for the given [incomingHostName] Provider? operator [](String incomingHostName) => @@ -56,7 +56,7 @@ class ProviderService { } void addAll(Iterable providers) { - for (var p in providers) { + for (final p in providers) { add(p); } } @@ -74,12 +74,22 @@ class ProviderService { } class Provider { + + const Provider( + this.key, + this.incomingHostName, + this.clientConfig, { + this.oauthClient, + this.appSpecificPasswordSetupUrl, + this.manualImapAccessSetupUrl, + this.domains, + }); /// The key of the provider, help to resolves image resources and possibly other settings like branding guidelines final String key; final String incomingHostName; final ClientConfig clientConfig; final OauthClient? oauthClient; - bool get hasOAuthClient => (oauthClient != null && oauthClient!.isEnabled); + bool get hasOAuthClient => oauthClient != null && oauthClient!.isEnabled; final String? appSpecificPasswordSetupUrl; final String? manualImapAccessSetupUrl; final List? domains; @@ -89,16 +99,6 @@ class Provider { ? null : clientConfig.emailProviders!.first.displayName; - const Provider( - this.key, - this.incomingHostName, - this.clientConfig, { - this.oauthClient, - this.appSpecificPasswordSetupUrl, - this.manualImapAccessSetupUrl, - this.domains, - }); - /// Builds the sign in button for this provider /// /// As this is UI, consider moving to a widget extension class? @@ -121,7 +121,7 @@ class Provider { color: Colors.white, ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -132,7 +132,7 @@ class Provider { errorBuilder: (context, error, stacktrace) => Container(), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8), child: PlatformText(buttonText), ), ], @@ -213,7 +213,7 @@ class GmailProvider extends Provider { errorBuilder: (context, error, stacktrace) => Container(), ), Padding( - padding: const EdgeInsets.only(left: 8.0, right: 16.0), + padding: const EdgeInsets.only(left: 8, right: 16), child: PlatformText( localizations.addAccountOauthSignInGoogle, style: GoogleFonts.roboto( diff --git a/lib/services/scaffold_messenger_service.dart b/lib/services/scaffold_messenger_service.dart index ddad366..1a36698 100644 --- a/lib/services/scaffold_messenger_service.dart +++ b/lib/services/scaffold_messenger_service.dart @@ -1,8 +1,8 @@ import 'dart:io'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/widgets/cupertino_status_bar.dart'; +import '../locator.dart'; +import 'i18n_service.dart'; +import '../widgets/cupertino_status_bar.dart'; import 'package:flutter/material.dart'; class ScaffoldMessengerService { @@ -27,8 +27,7 @@ class ScaffoldMessengerService { } } - SnackBar _buildTextSnackBar(String text, {Function()? undo}) { - return SnackBar( + SnackBar _buildTextSnackBar(String text, {Function()? undo}) => SnackBar( content: Text(text), action: undo == null ? null @@ -37,7 +36,6 @@ class ScaffoldMessengerService { onPressed: undo, ), ); - } void _showSnackBar(SnackBar snackBar) { scaffoldMessengerKey.currentState?.showSnackBar(snackBar); diff --git a/lib/settings/provider.dart b/lib/settings/provider.dart index dc24f58..698eb60 100644 --- a/lib/settings/provider.dart +++ b/lib/settings/provider.dart @@ -1,8 +1,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../account/model.dart'; import '../locator.dart'; import '../logger.dart'; -import '../models/account.dart'; import '../models/compose_data.dart'; import '../services/i18n_service.dart'; import 'model.dart'; diff --git a/lib/settings/view/settings_accounts_screen.dart b/lib/settings/view/settings_accounts_screen.dart index fe7958b..ef6255e 100644 --- a/lib/settings/view/settings_accounts_screen.dart +++ b/lib/settings/view/settings_accounts_screen.dart @@ -2,114 +2,126 @@ import 'dart:async'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../account/model.dart'; +import '../../account/providers.dart'; import '../../l10n/app_localizations.g.dart'; import '../../l10n/extension.dart'; import '../../locator.dart'; -import '../../models/account.dart'; import '../../routes.dart'; import '../../screens/base.dart'; -import '../../services/mail_service.dart'; import '../../services/navigation_service.dart'; import '../../widgets/button_text.dart'; -import '../../widgets/inherited_widgets.dart'; -class SettingsAccountsScreen extends StatefulWidget { +/// Allows to select an account for editing and to re-order the accounts +class SettingsAccountsScreen extends HookConsumerWidget { + /// Creates a [SettingsAccountsScreen] const SettingsAccountsScreen({super.key}); @override - State createState() => _SettingsAccountsScreenState(); -} - -class _SettingsAccountsScreenState extends State { - bool _reorderAccounts = false; - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final reorderAccountsState = useState(false); + final accounts = ref.watch(realAccountsProvider); final localizations = context.text; return BasePage( title: localizations.accountsTitle, - content: _reorderAccounts - ? _buildReorderableListView(context) - : _buildAccountSettings(context, localizations), - ); - } - - Widget _buildAccountSettings( - BuildContext context, AppLocalizations localizations) { - final accounts = MailServiceWidget.of(context)?.accounts ?? []; - return SingleChildScrollView( - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final account in accounts) - PlatformListTile( - leading: Icon(CommonPlatformIcons.account), - title: Text(account.name), - onTap: () => locator() - .push(Routes.accountEdit, arguments: account), - ), - PlatformListTile( - leading: Icon(CommonPlatformIcons.add), - title: Text(localizations.drawerEntryAddAccount), - onTap: () => locator().push(Routes.accountAdd), + content: reorderAccountsState.value + ? _buildReorderableListView( + context, + localizations, + ref, + reorderAccountsState, + accounts, + ) + : _buildAccountSettings( + context, + localizations, + ref, + reorderAccountsState, + accounts, ), - if (accounts.length > 1) - Padding( - padding: const EdgeInsets.all(8), - child: PlatformElevatedButton( - onPressed: () { - setState(() { - _reorderAccounts = true; - }); - }, - child: ButtonText(localizations.accountsActionReorder), - ), - ), - ], - ), - ), ); } - Widget _buildReorderableListView(BuildContext context) { - final accounts = List.from( - MailServiceWidget.of(context)?.accounts?.whereType() ?? - []); - return WillPopScope( - onWillPop: () { - setState(() { - _reorderAccounts = false; - }); - return Future.value(false); - }, - child: SafeArea( - child: Material( - child: ReorderableListView( - onReorder: (oldIndex, newIndex) async { - // print('moved $oldIndex to $newIndex'); - final account = accounts.removeAt(oldIndex); - if (newIndex > accounts.length) { - accounts.add(account); - } else { - accounts.insert(newIndex, account); - } - setState(() {}); - await locator().reorderAccounts(accounts); - }, + Widget _buildAccountSettings( + BuildContext context, + AppLocalizations localizations, + WidgetRef ref, + ValueNotifier reorderAccountsState, + List accounts, + ) => + SingleChildScrollView( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final account in accounts) - ListTile( - key: ValueKey(account), - leading: const Icon(Icons.account_circle), + PlatformListTile( + leading: Icon(CommonPlatformIcons.account), title: Text(account.name), + onTap: () => locator() + .push(Routes.accountEdit, arguments: account), + ), + PlatformListTile( + leading: Icon(CommonPlatformIcons.add), + title: Text(localizations.drawerEntryAddAccount), + onTap: () => + locator().push(Routes.accountAdd), + ), + if (accounts.length > 1) + Padding( + padding: const EdgeInsets.all(8), + child: PlatformElevatedButton( + onPressed: () => reorderAccountsState.value = true, + child: ButtonText(localizations.accountsActionReorder), + ), ), ], ), ), - ), - ); - } + ); + + Widget _buildReorderableListView( + BuildContext context, + AppLocalizations localizations, + WidgetRef ref, + ValueNotifier reorderAccountsState, + List accounts, + ) => + WillPopScope( + onWillPop: () { + reorderAccountsState.value = false; + + return Future.value(false); + }, + child: SafeArea( + child: Material( + child: ReorderableListView( + onReorder: (oldIndex, newIndex) async { + // print('moved $oldIndex to $newIndex'); + final account = accounts.removeAt(oldIndex); + if (newIndex > accounts.length) { + accounts.add(account); + } else { + accounts.insert(newIndex, account); + } + ref + .read(realAccountsProvider.notifier) + .reorderAccounts(accounts); + }, + children: [ + for (final account in accounts) + ListTile( + key: ValueKey(account), + leading: const Icon(Icons.account_circle), + title: Text(account.name), + ), + ], + ), + ), + ), + ); } diff --git a/lib/settings/view/settings_developer_mode_screen.dart b/lib/settings/view/settings_developer_mode_screen.dart index 1b0eabf..7310c68 100644 --- a/lib/settings/view/settings_developer_mode_screen.dart +++ b/lib/settings/view/settings_developer_mode_screen.dart @@ -4,10 +4,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../account/model.dart'; import '../../extensions/extensions.dart'; import '../../l10n/extension.dart'; import '../../locator.dart'; -import '../../models/account.dart'; import '../../screens/base.dart'; import '../../services/mail_service.dart'; import '../../services/navigation_service.dart'; diff --git a/lib/settings/view/settings_folders_screen.dart b/lib/settings/view/settings_folders_screen.dart index 5003370..3df38fc 100644 --- a/lib/settings/view/settings_folders_screen.dart +++ b/lib/settings/view/settings_folders_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../account/model.dart'; import '../../l10n/extension.dart'; import '../../locator.dart'; import '../../models/models.dart'; @@ -38,7 +39,7 @@ class SettingsFoldersScreen extends ConsumerWidget { content: SingleChildScrollView( child: SafeArea( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -71,7 +72,7 @@ class SettingsFoldersScreen extends ConsumerWidget { ), ], const Divider( - height: 8.0, + height: 8, ), const FolderManagement(), ], @@ -82,7 +83,7 @@ class SettingsFoldersScreen extends ConsumerWidget { ); } - void _editFolderNames( + Future _editFolderNames( BuildContext context, Settings settings, WidgetRef ref, @@ -107,18 +108,18 @@ class SettingsFoldersScreen extends ConsumerWidget { if (result == true) { settings = settings.copyWith(customFolderNames: customNames); locator().applyFolderNameSettings(settings); - ref.read(settingsProvider.notifier).update(settings); + await ref.read(settingsProvider.notifier).update(settings); } } - void _onFolderNameSettingChanged( + Future _onFolderNameSettingChanged( BuildContext context, FolderNameSetting? value, WidgetRef ref, ) async { final settings = ref.read(settingsProvider); - ref.read(settingsProvider.notifier).update( + await ref.read(settingsProvider.notifier).update( settings.copyWith(folderNameSetting: value), ); locator().applyFolderNameSettings(settings); @@ -126,8 +127,7 @@ class SettingsFoldersScreen extends ConsumerWidget { } class CustomFolderNamesEditor extends HookConsumerWidget { - const CustomFolderNamesEditor({Key? key, required this.customNames}) - : super(key: key); + const CustomFolderNamesEditor({super.key, required this.customNames}); final List customNames; @@ -211,7 +211,7 @@ class CustomFolderNamesEditor extends HookConsumerWidget { } class FolderManagement extends StatefulWidget { - const FolderManagement({Key? key}) : super(key: key); + const FolderManagement({super.key}); @override State createState() => _FolderManagementState(); @@ -249,10 +249,12 @@ class _FolderManagementState extends State { AccountSelector( account: _account, onChanged: (account) { - setState(() { - _mailbox = null; - _account = account!; - }); + if (account != null) { + setState(() { + _mailbox = null; + _account = account; + }); + } }, ), const Divider(), @@ -287,18 +289,17 @@ class _FolderManagementState extends State { } class MailboxWidget extends StatelessWidget { - final RealAccount account; - final Mailbox? mailbox; - final void Function() onMailboxAdded; - final void Function() onMailboxDeleted; const MailboxWidget( - {Key? key, + {super.key, required this.mailbox, required this.account, required this.onMailboxAdded, - required this.onMailboxDeleted}) - : super(key: key); + required this.onMailboxDeleted}); + final RealAccount account; + final Mailbox? mailbox; + final void Function() onMailboxAdded; + final void Function() onMailboxDeleted; @override Widget build(BuildContext context) { @@ -332,7 +333,7 @@ class MailboxWidget extends StatelessWidget { ); } - void _createFolder(context) async { + Future _createFolder(context) async { final localizations = context.text; final folderNameController = TextEditingController(); final result = await LocalizedDialogHelper.showWidgetDialog( @@ -368,7 +369,7 @@ class MailboxWidget extends StatelessWidget { } } - void _deleteFolder(BuildContext context) async { + Future _deleteFolder(BuildContext context) async { final localizations = context.text; final confirmed = await LocalizedDialogHelper.askForConfirmation( context, diff --git a/lib/settings/view/settings_signature_screen.dart b/lib/settings/view/settings_signature_screen.dart index bbe3cd8..2e06837 100644 --- a/lib/settings/view/settings_signature_screen.dart +++ b/lib/settings/view/settings_signature_screen.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../account/model.dart'; import '../../l10n/extension.dart'; import '../../locator.dart'; -import '../../models/account.dart'; import '../../models/compose_data.dart'; import '../../routes.dart'; import '../../screens/base.dart'; diff --git a/lib/util/datetime.dart b/lib/util/datetime.dart index 3df2596..50b08f0 100644 --- a/lib/util/datetime.dart +++ b/lib/util/datetime.dart @@ -1,11 +1,7 @@ import 'package:flutter/material.dart'; extension DateTimeExtension on DateTime { - TimeOfDay toTimeOfDay() { - return TimeOfDay.fromDateTime(this); - } + TimeOfDay toTimeOfDay() => TimeOfDay.fromDateTime(this); - DateTime withTimeOfDay(TimeOfDay timeOfDay) { - return DateTime(year, month, day, timeOfDay.hour, timeOfDay.minute); - } + DateTime withTimeOfDay(TimeOfDay timeOfDay) => DateTime(year, month, day, timeOfDay.hour, timeOfDay.minute); } diff --git a/lib/util/http_helper.dart b/lib/util/http_helper.dart index d02a373..8329ee4 100644 --- a/lib/util/http_helper.dart +++ b/lib/util/http_helper.dart @@ -53,6 +53,7 @@ class HttpHelper { } class HttpResult { + HttpResult(this.statusCode, [this.data]); final int statusCode; String? _text; String? get text { @@ -68,5 +69,4 @@ class HttpResult { } final Uint8List? data; - HttpResult(this.statusCode, [this.data]); } diff --git a/lib/util/indexed_cache.dart b/lib/util/indexed_cache.dart index 6dbcb56..b0b3142 100644 --- a/lib/util/indexed_cache.dart +++ b/lib/util/indexed_cache.dart @@ -2,11 +2,11 @@ import 'package:collection/collection.dart'; /// Temporarily stores values that can be accessed by an integer index. class IndexedCache { - /// default maximum cache size is 200 - static const int defaultMaxCacheSize = 200; /// Creates a new cache IndexedCache({this.maxCacheSize = defaultMaxCacheSize}); + /// default maximum cache size is 200 + static const int defaultMaxCacheSize = 200; /// The maximum size of the cache final int maxCacheSize; diff --git a/lib/util/modal_bottom_sheet_helper.dart b/lib/util/modal_bottom_sheet_helper.dart index eeeaee2..15ec988 100644 --- a/lib/util/modal_bottom_sheet_helper.dart +++ b/lib/util/modal_bottom_sheet_helper.dart @@ -1,8 +1,9 @@ -import 'package:enough_mail_app/screens/base.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import '../screens/base.dart'; + class ModelBottomSheetHelper { ModelBottomSheetHelper._(); @@ -18,7 +19,7 @@ class ModelBottomSheetHelper { final bottomSheetContent = SafeArea( bottom: false, child: Padding( - padding: const EdgeInsets.only(top: 32.0), + padding: const EdgeInsets.only(top: 32), child: Base.buildAppChrome( context, title: title, @@ -40,7 +41,7 @@ class ModelBottomSheetHelper { result = await showCupertinoModalBottomSheet( context: context, builder: (context) => bottomSheetContent, - elevation: 8.0, + elevation: 8, expand: true, isDismissible: true, ); @@ -48,7 +49,7 @@ class ModelBottomSheetHelper { result = await showMaterialModalBottomSheet( context: context, builder: (context) => bottomSheetContent, - elevation: 8.0, + elevation: 8, expand: true, backgroundColor: Colors.transparent, ); diff --git a/lib/util/string_helper.dart b/lib/util/string_helper.dart index f3a019b..7280d2d 100644 --- a/lib/util/string_helper.dart +++ b/lib/util/string_helper.dart @@ -78,8 +78,8 @@ class StringHelper { } class _StringSequence { - final int startIndex; - final int length; _StringSequence(this.startIndex, this.length); + final int startIndex; + final int length; } diff --git a/lib/util/validator.dart b/lib/util/validator.dart index c910e98..87bb314 100644 --- a/lib/util/validator.dart +++ b/lib/util/validator.dart @@ -5,6 +5,6 @@ class Validator { } final atIndex = value.lastIndexOf('@'); final dotIndex = value.lastIndexOf('.'); - return (atIndex > 0 && dotIndex > atIndex && dotIndex < value.length - 2); + return atIndex > 0 && dotIndex > atIndex && dotIndex < value.length - 2; } } diff --git a/lib/widgets/account_provider_selector.dart b/lib/widgets/account_provider_selector.dart index 9e21729..40c028a 100644 --- a/lib/widgets/account_provider_selector.dart +++ b/lib/widgets/account_provider_selector.dart @@ -1,13 +1,12 @@ -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/providers.dart'; +import '../l10n/extension.dart'; +import '../locator.dart'; +import '../services/providers.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; class AccountProviderSelector extends StatelessWidget { + const AccountProviderSelector({super.key, required this.onSelected}); final void Function(Provider? provider) onSelected; - const AccountProviderSelector({Key? key, required this.onSelected}) - : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/widgets/account_selector.dart b/lib/widgets/account_selector.dart index cc5c6f7..78a58f8 100644 --- a/lib/widgets/account_selector.dart +++ b/lib/widgets/account_selector.dart @@ -1,20 +1,20 @@ -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import '../account/model.dart'; import '../locator.dart'; +import '../services/mail_service.dart'; class AccountSelector extends StatelessWidget { - final RealAccount? account; - final bool excludeAccountsWithErrors; - final void Function(RealAccount? account) onChanged; const AccountSelector({ - Key? key, + super.key, required this.onChanged, required this.account, this.excludeAccountsWithErrors = true, - }) : super(key: key); + }); + final RealAccount? account; + final bool excludeAccountsWithErrors; + final void Function(RealAccount? account) onChanged; @override Widget build(BuildContext context) { diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 88b93f6..9500009 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -2,38 +2,37 @@ import 'dart:io'; import 'package:badges/badges.dart' as badges; import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/extensions/extension_action_tile.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/services/icon_service.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; -import 'package:enough_mail_app/util/localized_dialog_helper.dart'; -import 'package:enough_mail_app/widgets/inherited_widgets.dart'; -import 'package:enough_mail_app/widgets/mailbox_tree.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../account/model.dart'; +import '../account/providers.dart'; +import '../extensions/extension_action_tile.dart'; import '../l10n/app_localizations.g.dart'; +import '../l10n/extension.dart'; +import '../locator.dart'; import '../routes.dart'; +import '../services/icon_service.dart'; +import '../services/mail_service.dart'; +import '../services/navigation_service.dart'; +import '../util/localized_dialog_helper.dart'; +import 'mailbox_tree.dart'; -class AppDrawer extends StatelessWidget { - const AppDrawer({Key? key}) : super(key: key); +class AppDrawer extends ConsumerWidget { + const AppDrawer({super.key, this.currentAccount}); + + final Account? currentAccount; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(allAccountsProvider); final mailService = locator(); final theme = Theme.of(context); final localizations = context.text; final iconService = locator(); - final mailState = MailServiceWidget.of(context)!; - final currentAccount = mailState.account ?? mailService.currentAccount; - var accounts = mailState.accounts ?? mailService.accounts; - if (mailService.hasUnifiedAccount) { - accounts = accounts.toList(); - accounts.insert(0, mailService.unifiedAccount!); - } + final currentAccount = this.currentAccount; + return PlatformDrawer( child: SafeArea( child: Column( @@ -41,7 +40,7 @@ class AppDrawer extends StatelessWidget { Material( elevation: 18, child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: _buildAccountHeader( currentAccount, mailService.accounts, theme), ), @@ -88,7 +87,7 @@ class AppDrawer extends StatelessWidget { navService.push(Routes.settings); }, ), - ) + ), ], ), ), @@ -172,91 +171,94 @@ class AppDrawer extends StatelessWidget { } Widget _buildAccountSelection( - BuildContext context, - MailService mailService, - List accounts, - Account? currentAccount, - AppLocalizations localizations) { - if (accounts.length > 1) { - return ExpansionTile( - leading: mailService.hasAccountsWithErrors() ? const Badge() : null, - title: Text(localizations - .drawerAccountsSectionTitle(mailService.accounts.length)), - children: [ - for (final account in accounts) - SelectablePlatformListTile( - leading: mailService.hasError(account) - ? const Icon(Icons.error_outline) - : null, - tileColor: mailService.hasError(account) ? Colors.red : null, - title: Text(account.name), - selected: account == currentAccount, - onTap: () async { - final navService = locator(); - if (!Platform.isIOS) { - // close drawer - navService.pop(); - } - if (mailService.hasError(account)) { - navService.push(Routes.accountEdit, arguments: account); - } else { - final accountWidgetState = MailServiceWidget.of(context); - if (accountWidgetState != null) { - accountWidgetState.account = account; - } - final messageSource = locator() - .getMessageSourceFor(account, switchToAccount: true); - navService.push(Routes.messageSourceFuture, - arguments: messageSource, - replace: !Platform.isIOS, - fade: true); - } - }, - onLongPress: () { - final navService = locator(); - if (account is UnifiedAccount) { - navService.push(Routes.settingsAccounts, fade: true); - } else { - navService.push(Routes.accountEdit, - arguments: account, fade: true); - } - }, - ), - _buildAddAccountTile(localizations), - ], - ); - } else { - return _buildAddAccountTile(localizations); - } - } + BuildContext context, + MailService mailService, + List accounts, + Account? currentAccount, + AppLocalizations localizations, + ) => + accounts.length > 1 + ? ExpansionTile( + leading: + mailService.hasAccountsWithErrors() ? const Badge() : null, + title: Text(localizations + .drawerAccountsSectionTitle(mailService.accounts.length)), + children: [ + for (final account in accounts) + SelectablePlatformListTile( + leading: mailService.hasError(account) + ? const Icon(Icons.error_outline) + : null, + tileColor: + mailService.hasError(account) ? Colors.red : null, + title: Text(account.name), + selected: account == currentAccount, + onTap: () async { + final navService = locator(); + if (!Platform.isIOS) { + navService.pop(); + } + if (mailService.hasError(account)) { + await navService.push( + Routes.accountEdit, + arguments: account, + ); + } else { + final messageSource = + locator().getMessageSourceFor( + account, + switchToAccount: true, + ); + await navService.push( + Routes.messageSourceFuture, + arguments: messageSource, + replace: !Platform.isIOS, + fade: true, + ); + } + }, + onLongPress: () { + final navService = locator(); + if (account is UnifiedAccount) { + navService.push(Routes.settingsAccounts, fade: true); + } else { + navService.push(Routes.accountEdit, + arguments: account, fade: true); + } + }, + ), + _buildAddAccountTile(localizations), + ], + ) + : _buildAddAccountTile(localizations); - Widget _buildAddAccountTile(AppLocalizations localizations) { - return PlatformListTile( - leading: const Icon(Icons.add), - title: Text(localizations.drawerEntryAddAccount), - onTap: () { - final navService = locator(); - if (!Platform.isIOS) { - navService.pop(); - } - navService.push(Routes.accountAdd); - }, - ); - } + Widget _buildAddAccountTile(AppLocalizations localizations) => + PlatformListTile( + leading: const Icon(Icons.add), + title: Text(localizations.drawerEntryAddAccount), + onTap: () { + final navService = locator(); + if (!Platform.isIOS) { + navService.pop(); + } + navService.push(Routes.accountAdd); + }, + ); Widget _buildFolderTree(Account? account) { if (account == null) { - return Container(); + return const SizedBox.shrink(); } + return MailboxTree(account: account, onSelected: _navigateToMailbox); } - void _navigateToMailbox(Mailbox mailbox) async { + Future _navigateToMailbox(Mailbox mailbox) async { final mailService = locator(); final account = mailService.currentAccount!; final messageSourceFuture = mailService.getMessageSourceFor(account, mailbox: mailbox); - locator().push( + await locator().push( Routes.messageSourceFuture, arguments: messageSourceFuture, replace: !Platform.isIOS, diff --git a/lib/widgets/attachment_chip.dart b/lib/widgets/attachment_chip.dart index cd9176c..d975753 100644 --- a/lib/widgets/attachment_chip.dart +++ b/lib/widgets/attachment_chip.dart @@ -1,23 +1,22 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/message.dart'; -import 'package:enough_mail_app/routes.dart'; -import 'package:enough_mail_app/screens/media_screen.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/icon_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; -import 'package:enough_mail_app/widgets/ical_interactive_media.dart'; import 'package:enough_mail_flutter/enough_mail_flutter.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../l10n/extension.dart'; +import '../locator.dart'; +import '../models/message.dart'; +import '../routes.dart'; +import '../screens/media_screen.dart'; +import '../services/i18n_service.dart'; +import '../services/icon_service.dart'; +import '../services/navigation_service.dart'; import 'button_text.dart'; +import 'ical_interactive_media.dart'; class AttachmentChip extends StatefulWidget { - const AttachmentChip({Key? key, required this.info, required this.message}) - : super(key: key); + const AttachmentChip({super.key, required this.info, required this.message}); final ContentInfo info; final Message message; @@ -52,18 +51,18 @@ class _AttachmentChipState extends State { return PlatformTextButton( onPressed: _isDownloading ? null : _download, child: Padding( - padding: const EdgeInsets.all(4.0), + padding: const EdgeInsets.all(4), child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8), child: _buildPreviewWidget(true, fallbackIcon, name), ), ), ); } else { return Padding( - padding: const EdgeInsets.all(4.0), + padding: const EdgeInsets.all(4), child: ClipRRect( - borderRadius: BorderRadius.circular(8.0), + borderRadius: BorderRadius.circular(8), child: PreviewMediaWidget( mediaProvider: _mediaProvider!, width: _width, @@ -110,7 +109,7 @@ class _AttachmentChipState extends State { ), ), child: Padding( - padding: const EdgeInsets.all(4.0), + padding: const EdgeInsets.all(4), child: Text( name, overflow: TextOverflow.fade, @@ -132,7 +131,7 @@ class _AttachmentChipState extends State { ), ), child: const Padding( - padding: EdgeInsets.all(4.0), + padding: EdgeInsets.all(4), child: Icon(Icons.download_rounded, color: Colors.white), ), ), @@ -172,7 +171,7 @@ class _AttachmentChipState extends State { builder: _buildInteractiveMedia, fallbackBuilder: _buildInteractiveFallback, ); - _showAttachment(media); + await _showAttachment(media); } on MailException catch (e) { if (kDebugMode) { print( @@ -209,12 +208,12 @@ class _AttachmentChipState extends State { return Material( child: Padding( - padding: const EdgeInsets.all(32.0), + padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Icon(iconData), ), Text( @@ -223,7 +222,7 @@ class _AttachmentChipState extends State { ), if (sizeText != null) Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(sizeText), ), PlatformTextButton( diff --git a/lib/widgets/attachment_compose_bar.dart b/lib/widgets/attachment_compose_bar.dart index 42a0ac6..204dab8 100644 --- a/lib/widgets/attachment_compose_bar.dart +++ b/lib/widgets/attachment_compose_bar.dart @@ -5,6 +5,7 @@ import 'package:enough_media/enough_media.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../l10n/app_localizations.g.dart'; import '../l10n/extension.dart'; @@ -27,8 +28,12 @@ class AttachmentMediaProviderFactory { } class AttachmentComposeBar extends StatefulWidget { - const AttachmentComposeBar( - {super.key, required this.composeData, this.isDownloading = false}); + const AttachmentComposeBar({ + super.key, + required this.composeData, + this.isDownloading = false, + }); + final ComposeData composeData; final bool isDownloading; @@ -46,31 +51,21 @@ class _AttachmentComposeBarState extends State { } @override - Widget build(BuildContext context) { - // final localizations = context.text; - return Wrap( - children: [ - for (final attachment in _attachments) - ComposeAttachment( - attachment: attachment, - onRemove: removeAttachment, + Widget build(BuildContext context) => Wrap( + children: [ + for (final attachment in _attachments) + ComposeAttachment( + parentMessage: widget.composeData.originalMessage, + attachment: attachment, + onRemove: removeAttachment, + ), + if (widget.isDownloading) const PlatformProgressIndicator(), + AddAttachmentPopupButton( + composeData: widget.composeData, + update: () => setState(() {}), ), - - if (widget.isDownloading) const PlatformProgressIndicator(), - - AddAttachmentPopupButton( - composeData: widget.composeData, - update: () => setState(() {}), - ), - // ActionChip( - // avatar: Icon(Icons.add), - // visualDensity: VisualDensity.compact, - // label: Text(localizations.composeAddAttachmentAction), - // onPressed: addAttachment, - // ), - ], - ); - } + ], + ); void removeAttachment(AttachmentInfo attachment) { widget.composeData.messageBuilder.removeAttachment(attachment); @@ -81,8 +76,11 @@ class _AttachmentComposeBarState extends State { } class AddAttachmentPopupButton extends StatelessWidget { - const AddAttachmentPopupButton( - {super.key, required this.composeData, required this.update}); + const AddAttachmentPopupButton({ + super.key, + required this.composeData, + required this.update, + }); final ComposeData composeData; final Function() update; @@ -222,9 +220,11 @@ class AddAttachmentPopupButton extends StatelessWidget { final giphy = locator().giphy; if (giphy == null) { await LocalizedDialogHelper.showTextDialog( - context, - localizations.errorTitle, - 'No GIPHY API key found. Please check set up instructions.'); + context, + localizations.errorTitle, + 'No GIPHY API key found. Please check set up instructions.', + ); + return false; } @@ -275,6 +275,7 @@ class AddAttachmentPopupButton extends StatelessWidget { final finalizer = _AppointmentFinalizer(appointment, attachmentBuilder); composeData.addFinalizer(finalizer.finalize); } + return (appointment != null); } } @@ -316,15 +317,23 @@ class _AppointmentFinalizer { } } -class ComposeAttachment extends StatelessWidget { - const ComposeAttachment( - {super.key, required this.attachment, required this.onRemove}); +class ComposeAttachment extends ConsumerWidget { + const ComposeAttachment({ + super.key, + required this.parentMessage, + required this.attachment, + required this.onRemove, + }); + + final Message? parentMessage; final AttachmentInfo attachment; final void Function(AttachmentInfo attachment) onRemove; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final localizations = context.text; + final parentMessage = this.parentMessage; + return Padding( padding: const EdgeInsets.only(right: 8), child: ClipRRect( @@ -335,32 +344,42 @@ class ComposeAttachment extends StatelessWidget { width: 60, height: 60, showInteractiveDelegate: (interactiveMedia) async { - if (attachment.mediaType.sub == MediaSubtype.messageRfc822) { + if (attachment.mediaType.sub == MediaSubtype.messageRfc822 && + parentMessage != null) { final mime = MimeMessage.parseFromData(attachment.data!); - final message = Message.embedded(mime, Message.of(context)!); - return locator() - .push(Routes.mailDetails, arguments: message); + final message = Message.embedded(mime, parentMessage); + + return locator().push( + Routes.mailDetails, + arguments: message, + ); } if (attachment.mediaType.sub == MediaSubtype.applicationIcs || attachment.mediaType.sub == MediaSubtype.textCalendar) { final text = attachment.part.text!; final appointment = VComponent.parse(text) as VCalendar; - final update = await IcalComposer.createOrEditAppointment(context, - appointment: appointment); + final update = await IcalComposer.createOrEditAppointment( + context, + appointment: appointment, + ); if (update != null) { attachment.part.text = update.toString(); } + return; } - return locator() - .push(Routes.interactiveMedia, arguments: interactiveMedia); + return locator().push( + Routes.interactiveMedia, + arguments: interactiveMedia, + ); }, contextMenuEntries: [ PopupMenuItem( value: 'remove', - child: Text(localizations - .composeRemoveAttachmentAction(attachment.name!)), + child: Text( + localizations.composeRemoveAttachmentAction(attachment.name!), + ), ), ], onContextMenuSelected: (provider, value) => onRemove(attachment), diff --git a/lib/widgets/button_text.dart b/lib/widgets/button_text.dart index be1f9b2..d7b7b86 100644 --- a/lib/widgets/button_text.dart +++ b/lib/widgets/button_text.dart @@ -3,14 +3,14 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; class ButtonText extends StatelessWidget { - final String? data; - final TextStyle? style; const ButtonText( this.data, { this.style, - Key? key, - }) : super(key: key); + super.key, + }); + final String? data; + final TextStyle? style; @override Widget build(BuildContext context) { diff --git a/lib/widgets/cupertino_status_bar.dart b/lib/widgets/cupertino_status_bar.dart index 33a93ad..8c03efb 100644 --- a/lib/widgets/cupertino_status_bar.dart +++ b/lib/widgets/cupertino_status_bar.dart @@ -1,8 +1,8 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/scaffold_messenger_service.dart'; +import '../services/i18n_service.dart'; +import '../services/scaffold_messenger_service.dart'; import '../locator.dart'; @@ -11,13 +11,13 @@ import '../locator.dart'; /// Contains compose action and can display snackbar notifications on ios. class CupertinoStatusBar extends StatefulWidget { const CupertinoStatusBar({ - Key? key, + super.key, this.leftAction, this.rightAction, this.info, - }) : super(key: key); + }); - static const _statusTextStyle = TextStyle(fontSize: 10.0); + static const _statusTextStyle = TextStyle(fontSize: 10); final Widget? leftAction; final Widget? rightAction; final Widget? info; @@ -25,14 +25,12 @@ class CupertinoStatusBar extends StatefulWidget { @override CupertinoStatusBarState createState() => CupertinoStatusBarState(); - static Widget? createInfo(String? text) { - return (text == null) + static Widget? createInfo(String? text) => (text == null) ? null : Text( text, style: _statusTextStyle, ); - } } class CupertinoStatusBarState extends State { @@ -82,14 +80,13 @@ class CupertinoStatusBarState extends State { child: SafeArea( top: false, child: SizedBox( - height: 44.0, + height: 44, child: Stack( fit: StackFit.passthrough, children: [ Align( - alignment: Alignment.center, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), + padding: const EdgeInsets.symmetric(horizontal: 32), child: middle, ), ), @@ -115,17 +112,17 @@ class CupertinoStatusBarState extends State { ); } - void showTextStatus(String text, {Function()? undo}) async { + Future showTextStatus(String text, {Function()? undo}) async { final notification = Text( text, style: CupertinoStatusBar._statusTextStyle, ); if (undo != null) { _statusAction = Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), + padding: const EdgeInsets.symmetric(horizontal: 4), child: CupertinoButton.filled( - padding: const EdgeInsets.all(8.0), - minSize: 20.0, + padding: const EdgeInsets.all(8), + minSize: 20, child: Text( locator().localizations.actionUndo, style: CupertinoStatusBar._statusTextStyle, diff --git a/lib/widgets/editor_extensions.dart b/lib/widgets/editor_extensions.dart index 85335a7..f5a7b02 100644 --- a/lib/widgets/editor_extensions.dart +++ b/lib/widgets/editor_extensions.dart @@ -1,27 +1,24 @@ import 'package:community_material_icon/community_material_icon.dart'; import 'package:enough_ascii_art/enough_ascii_art.dart'; import 'package:enough_html_editor/enough_html_editor.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; -import 'package:enough_mail_app/util/localized_dialog_helper.dart'; -import 'package:enough_mail_app/widgets/button_text.dart'; +import '../l10n/extension.dart'; +import '../services/navigation_service.dart'; +import '../util/localized_dialog_helper.dart'; +import 'button_text.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import '../locator.dart'; class EditorArtExtensionButton extends StatelessWidget { - const EditorArtExtensionButton({Key? key, required this.editorApi}) - : super(key: key); + const EditorArtExtensionButton({super.key, required this.editorApi}); final HtmlEditorApi editorApi; @override - Widget build(BuildContext context) { - return PlatformIconButton( + Widget build(BuildContext context) => PlatformIconButton( icon: const Icon(CommunityMaterialIcons.format_font), onPressed: () => showArtExtensionDialog(context, editorApi), ); - } static void showArtExtensionDialog( BuildContext context, HtmlEditorApi editorApi) { @@ -35,9 +32,8 @@ class EditorArtExtensionButton extends StatelessWidget { } class EditorArtExtensionWidget extends StatefulWidget { + const EditorArtExtensionWidget({super.key, required this.editorApi}); final HtmlEditorApi editorApi; - const EditorArtExtensionWidget({Key? key, required this.editorApi}) - : super(key: key); @override State createState() => @@ -88,10 +84,10 @@ class _EditorArtExtensionWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(bottom: 8.0), + padding: const EdgeInsets.only(bottom: 8), child: DecoratedPlatformTextField( controller: _inputController, - onChanged: (value) => _updateTexts(value), + onChanged: _updateTexts, decoration: InputDecoration( labelText: localizations.editorArtInputLabel, hintText: localizations.editorArtInputHint, diff --git a/lib/widgets/empty_message.dart b/lib/widgets/empty_message.dart index 5b487c7..f25173f 100644 --- a/lib/widgets/empty_message.dart +++ b/lib/widgets/empty_message.dart @@ -2,11 +2,11 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; class EmptyMessage extends StatelessWidget { - const EmptyMessage({Key? key}) : super(key: key); + const EmptyMessage({super.key}); @override Widget build(BuildContext context) => const Padding( - padding: EdgeInsets.all(8.0), + padding: EdgeInsets.all(8), child: SelectablePlatformListTile( visualDensity: VisualDensity.compact, title: Text('...'), diff --git a/lib/widgets/expanding_wrap.dart b/lib/widgets/expanding_wrap.dart index ed5ab3e..770aaf4 100644 --- a/lib/widgets/expanding_wrap.dart +++ b/lib/widgets/expanding_wrap.dart @@ -19,7 +19,7 @@ class ExpansionWrap2 extends MultiChildRenderObjectWidget { /// disambiguate `start` or `end` values for the main or cross axis /// directions, the [textDirection] must not be null. ExpansionWrap2({ - Key? key, + super.key, this.direction = Axis.horizontal, this.alignment = WrapAlignment.start, this.spacing = 0.0, @@ -32,7 +32,7 @@ class ExpansionWrap2 extends MultiChildRenderObjectWidget { List children = const [], this.maxRuns, required this.overflow, - }) : super(key: key, children: [...children, overflow]); + }) : super(children: [...children, overflow]); final Widget overflow; @@ -176,8 +176,7 @@ class ExpansionWrap2 extends MultiChildRenderObjectWidget { final int? maxRuns; @override - RenderExpansionWrap createRenderObject(BuildContext context) { - return RenderExpansionWrap( + RenderExpansionWrap createRenderObject(BuildContext context) => RenderExpansionWrap( direction: direction, alignment: alignment, spacing: spacing, @@ -189,7 +188,6 @@ class ExpansionWrap2 extends MultiChildRenderObjectWidget { clipBehavior: clipBehavior, maxRuns: maxRuns, ); - } @override void updateRenderObject( @@ -545,7 +543,7 @@ class RenderExpansionWrap extends RenderBox double computeMinIntrinsicWidth(double height) { switch (direction) { case Axis.horizontal: - double width = 0.0; + double width = 0; RenderBox? child = firstChild; while (child != null) { width = math.max(width, child.getMinIntrinsicWidth(double.infinity)); @@ -561,7 +559,7 @@ class RenderExpansionWrap extends RenderBox double computeMaxIntrinsicWidth(double height) { switch (direction) { case Axis.horizontal: - double width = 0.0; + double width = 0; RenderBox? child = firstChild; while (child != null) { width += child.getMaxIntrinsicWidth(double.infinity); @@ -579,7 +577,7 @@ class RenderExpansionWrap extends RenderBox case Axis.horizontal: return computeDryLayout(BoxConstraints(maxWidth: width)).height; case Axis.vertical: - double height = 0.0; + double height = 0; RenderBox? child = firstChild; while (child != null) { height = @@ -596,7 +594,7 @@ class RenderExpansionWrap extends RenderBox case Axis.horizontal: return computeDryLayout(BoxConstraints(maxWidth: width)).height; case Axis.vertical: - double height = 0.0; + double height = 0; RenderBox? child = firstChild; while (child != null) { height += child.getMaxIntrinsicHeight(double.infinity); @@ -607,9 +605,7 @@ class RenderExpansionWrap extends RenderBox } @override - double? computeDistanceToActualBaseline(TextBaseline baseline) { - return defaultComputeDistanceToHighestActualBaseline(baseline); - } + double? computeDistanceToActualBaseline(TextBaseline baseline) => defaultComputeDistanceToHighestActualBaseline(baseline); double _getMainAxisExtent(Size childSize) { switch (direction) { @@ -652,14 +648,12 @@ class RenderExpansionWrap extends RenderBox } @override - Size computeDryLayout(BoxConstraints constraints) { - return _computeDryLayout(constraints); - } + Size computeDryLayout(BoxConstraints constraints) => _computeDryLayout(constraints); Size _computeDryLayout(BoxConstraints constraints, [ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild]) { final BoxConstraints childConstraints; - double mainAxisLimit = 0.0; + double mainAxisLimit = 0; switch (direction) { case Axis.horizontal: childConstraints = BoxConstraints(maxWidth: constraints.maxWidth); @@ -671,20 +665,20 @@ class RenderExpansionWrap extends RenderBox break; } - double mainAxisExtent = 0.0; - double crossAxisExtent = 0.0; - double runMainAxisExtent = 0.0; - double runCrossAxisExtent = 0.0; + double mainAxisExtent = 0; + double crossAxisExtent = 0; + double runMainAxisExtent = 0; + double runCrossAxisExtent = 0; int childCount = 0; - RenderBox overflow = lastChild!; + final RenderBox overflow = lastChild!; final overflowSize = layoutChild(overflow, childConstraints); final double overflowMainAxisExtent = _getMainAxisExtent(overflowSize); // final double overflowCrossAxisExtent = _getCrossAxisExtent(overflowSize); - int numberOfRuns = 1; + const int numberOfRuns = 1; final runsMax = _maxRuns ?? 1000; RenderBox? child = firstChild; while (child != null) { - RenderBox? nextChild = childAfter(child); + final RenderBox? nextChild = childAfter(child); if (nextChild == null) { // the last child is the overflow widget, abort: break; @@ -741,13 +735,13 @@ class RenderExpansionWrap extends RenderBox final BoxConstraints constraints = this.constraints; assert(_debugHasNecessaryDirections); RenderBox? child = firstChild; - RenderBox overflow = lastChild!; + final RenderBox overflow = lastChild!; if (child == null || child == overflow) { size = constraints.smallest; return; } final BoxConstraints childConstraints; - double mainAxisLimit = 0.0; + double mainAxisLimit = 0; bool flipMainAxis = false; bool flipCrossAxis = false; switch (direction) { @@ -772,13 +766,13 @@ class RenderExpansionWrap extends RenderBox final double spacing = this.spacing; final double runSpacing = this.runSpacing; final List<_RunMetrics> runMetrics = <_RunMetrics>[]; - double mainAxisExtent = 0.0; - double crossAxisExtent = 0.0; - double runMainAxisExtent = 0.0; - double runCrossAxisExtent = 0.0; + double mainAxisExtent = 0; + double crossAxisExtent = 0; + double runMainAxisExtent = 0; + double runCrossAxisExtent = 0; int childCount = 0; while (child != null) { - RenderBox? nextChild = childAfter(child); + final RenderBox? nextChild = childAfter(child); if (nextChild == null) { // the last child is the overflow widget, abort: break; @@ -845,8 +839,8 @@ class RenderExpansionWrap extends RenderBox final int runCount = runMetrics.length; assert(runCount > 0); - double containerMainAxisExtent = 0.0; - double containerCrossAxisExtent = 0.0; + double containerMainAxisExtent = 0; + double containerCrossAxisExtent = 0; switch (direction) { case Axis.horizontal: @@ -862,9 +856,9 @@ class RenderExpansionWrap extends RenderBox } final double crossAxisFreeSpace = - math.max(0.0, containerCrossAxisExtent - crossAxisExtent); - double runLeadingSpace = 0.0; - double runBetweenSpace = 0.0; + math.max(0, containerCrossAxisExtent - crossAxisExtent); + double runLeadingSpace = 0; + double runBetweenSpace = 0; switch (runAlignment) { case WrapAlignment.start: break; @@ -901,9 +895,9 @@ class RenderExpansionWrap extends RenderBox final int childCount = metrics.childCount; final double mainAxisFreeSpace = - math.max(0.0, containerMainAxisExtent - runMainAxisExtent); - double childLeadingSpace = 0.0; - double childBetweenSpace = 0.0; + math.max(0, containerMainAxisExtent - runMainAxisExtent); + double childLeadingSpace = 0; + double childBetweenSpace = 0; switch (alignment) { case WrapAlignment.start: @@ -963,9 +957,7 @@ class RenderExpansionWrap extends RenderBox } @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); - } + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => defaultHitTestChildren(result, position: position); @override void paint(PaintingContext context, Offset offset) { diff --git a/lib/widgets/expansion_wrap.dart b/lib/widgets/expansion_wrap.dart index 6fd4481..2c45d54 100644 --- a/lib/widgets/expansion_wrap.dart +++ b/lib/widgets/expansion_wrap.dart @@ -5,7 +5,7 @@ enum ExpansionWrapIndicatorPosition { inline, border } class ExpansionWrap extends RenderObjectWidget { const ExpansionWrap({ - Key? key, + super.key, required this.children, required this.expandIndicator, required this.maxRuns, @@ -14,7 +14,7 @@ class ExpansionWrap extends RenderObjectWidget { this.spacing = 0.0, this.runSpacing = 0.0, this.indicatorPosition = ExpansionWrapIndicatorPosition.border, - }) : super(key: key); + }); final List children; final Widget expandIndicator; @@ -57,9 +57,9 @@ class ExpansionWrap extends RenderObjectWidget { } class ExpansionWrapElement extends RenderObjectElement { + ExpansionWrapElement(ExpansionWrap super.widget); static const int _expandIndicatorSlot = -1; static const int _compressIndicatorSlot = -2; - ExpansionWrapElement(ExpansionWrap widget) : super(widget); Element? _expandIndicator; Element? _compressIndicator; @@ -412,15 +412,13 @@ class RenderExpansionWrap extends RenderBox { } @override - double computeMaxIntrinsicHeight(double width) { - return computeMinIntrinsicHeight(width); - } + double computeMaxIntrinsicHeight(double width) => computeMinIntrinsicHeight(width); @override double computeDistanceToActualBaseline(TextBaseline baseline) { assert(_wrapChildren != null); final first = _wrapChildren!.first; - final BoxParentData parentData = first.parentData as BoxParentData; + final BoxParentData parentData = first.parentData! as BoxParentData; return parentData.offset.dy + first.getDistanceToActualBaseline(baseline)!; } @@ -451,10 +449,10 @@ class RenderExpansionWrap extends RenderBox { final compressIndicator = _compressIndicator; if (expanded) { if (expandIndicator != null) { - (expandIndicator.parentData as _WrapParentData)._isVisible = false; + (expandIndicator.parentData! as _WrapParentData)._isVisible = false; } } else if (compressIndicator != null) { - (compressIndicator.parentData as _WrapParentData)._isVisible = false; + (compressIndicator.parentData! as _WrapParentData)._isVisible = false; } final spacing = _spacing; final runSpacing = _runSpacing; @@ -481,8 +479,8 @@ class RenderExpansionWrap extends RenderBox { for (var i = 0; i <= lastChildIndex; i++) { final child = children[i]; final childSize = _layoutBox(child, looseConstraints); - final parentData = child.parentData as _WrapParentData; - parentData._isVisible = (currentRun <= maxRuns); + final parentData = child.parentData! as _WrapParentData; + parentData._isVisible = currentRun <= maxRuns; if (currentRunNumberOfChildren > 0 && ((currentRunWidth + childSize.width > availableWidth) || (currentRun == maxRuns && @@ -509,7 +507,7 @@ class RenderExpansionWrap extends RenderBox { if (indicator != null) { // this is the last visible run, add indicator: final indicatorParentData = - indicator.parentData as _WrapParentData; + indicator.parentData! as _WrapParentData; indicatorParentData._isVisible = true; final dx = _indicatorPosition == ExpansionWrapIndicatorPosition.border @@ -540,7 +538,7 @@ class RenderExpansionWrap extends RenderBox { } if (expanded && currentRun >= originalMaxRuns && indicator != null) { // add compress indicator at the end: - final indicatorParentData = indicator.parentData as _WrapParentData; + final indicatorParentData = indicator.parentData! as _WrapParentData; indicatorParentData._isVisible = true; final dx = _indicatorPosition == ExpansionWrapIndicatorPosition.border ? availableWidth - indicatorWith @@ -549,7 +547,7 @@ class RenderExpansionWrap extends RenderBox { dx, currentRunY + (currentRunHeight - indicatorSize.height) / 2); } if (!expanded && currentRun <= originalMaxRuns && indicator != null) { - final indicatorParentData = indicator.parentData as _WrapParentData; + final indicatorParentData = indicator.parentData! as _WrapParentData; indicatorParentData._isVisible = false; } if (crossAxisMaxInCompressedState != null) { @@ -565,7 +563,7 @@ class RenderExpansionWrap extends RenderBox { void paint(PaintingContext context, Offset offset) { void doPaint(RenderBox? child) { if (child != null) { - final parentData = child.parentData as _WrapParentData; + final parentData = child.parentData! as _WrapParentData; if (parentData._isVisible) { context.paintChild(child, parentData.offset + offset); } @@ -586,7 +584,7 @@ class RenderExpansionWrap extends RenderBox { @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { for (final RenderBox child in _allChildren) { - final parentData = child.parentData as _WrapParentData; + final parentData = child.parentData! as _WrapParentData; final bool isHit = parentData._isVisible && result.addWithPaintOffset( offset: parentData.offset, diff --git a/lib/widgets/ical_composer.dart b/lib/widgets/ical_composer.dart index 12d23be..18191a0 100644 --- a/lib/widgets/ical_composer.dart +++ b/lib/widgets/ical_composer.dart @@ -1,18 +1,18 @@ import 'package:enough_icalendar/enough_icalendar.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/util/datetime.dart'; -import 'package:enough_mail_app/util/modal_bottom_sheet_helper.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import '../account/model.dart'; import '../l10n/app_localizations.g.dart'; +import '../l10n/extension.dart'; import '../locator.dart'; +import '../services/i18n_service.dart'; +import '../services/mail_service.dart'; +import '../util/datetime.dart'; +import '../util/modal_bottom_sheet_helper.dart'; class IcalComposer extends StatefulWidget { - const IcalComposer({Key? key, required this.appointment}) : super(key: key); + const IcalComposer({super.key, required this.appointment}); final VCalendar appointment; @override State createState() => _IcalComposerState(); @@ -108,7 +108,7 @@ class _IcalComposerState extends State { final theme = Theme.of(context); return Material( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( children: [ DecoratedPlatformTextField( @@ -136,12 +136,12 @@ class _IcalComposerState extends State { cupertinoAlignLabelOnTop: true, ), Padding( - padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0), + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), child: Text(localizations.icalendarLabelStart, style: theme.textTheme.bodySmall), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8), child: DateTimePicker( dateTime: start, onlyDate: isAllday, @@ -158,12 +158,12 @@ class _IcalComposerState extends State { ), if (!isAllday) ...[ Padding( - padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0), + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), child: Text(localizations.icalendarLabelEnd, style: theme.textTheme.bodySmall), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8), child: DateTimePicker( dateTime: end, onChanged: (dateTime) { @@ -294,15 +294,15 @@ extension _ExtensionRecurrenceFrequency on RecurrenceFrequency { } class DateTimePicker extends StatelessWidget { - final DateTime? dateTime; - final void Function(DateTime newDateTime) onChanged; - final bool onlyDate; const DateTimePicker({ - Key? key, + super.key, required this.dateTime, required this.onChanged, this.onlyDate = false, - }) : super(key: key); + }); + final DateTime? dateTime; + final void Function(DateTime newDateTime) onChanged; + final bool onlyDate; @override Widget build(BuildContext context) { @@ -368,11 +368,10 @@ class DateTimePicker extends StatelessWidget { } class RecurrenceComposer extends StatefulWidget { + const RecurrenceComposer( + {super.key, this.recurrenceRule, required this.startDate}); final Recurrence? recurrenceRule; final DateTime startDate; - const RecurrenceComposer( - {Key? key, this.recurrenceRule, required this.startDate}) - : super(key: key); @override State createState() => _RecurrenceComposerState(); @@ -422,14 +421,14 @@ class _RecurrenceComposerState extends State { final localizations = context.text; final rule = _recurrenceRule; return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( localizations.composeAppointmentRecurrenceFrequencyLabel), ), @@ -487,7 +486,7 @@ class _RecurrenceComposerState extends State { Row( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( localizations.composeAppointmentRecurrenceIntervalLabel), ), @@ -510,7 +509,7 @@ class _RecurrenceComposerState extends State { ), if (rule.frequency == RecurrenceFrequency.weekly) ...[ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.composeAppointmentRecurrenceDaysLabel), ), @@ -531,7 +530,7 @@ class _RecurrenceComposerState extends State { ), ] else if (rule.frequency == RecurrenceFrequency.monthly) ...[ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.composeAppointmentRecurrenceDaysLabel), ), @@ -574,7 +573,7 @@ class _RecurrenceComposerState extends State { ), const Divider(), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(rule.toHumanReadableText( languageCode: localizations.localeName, startDate: widget.startDate, @@ -588,15 +587,15 @@ class _RecurrenceComposerState extends State { } class WeekDaySelector extends StatefulWidget { - final Recurrence recurrence; - final DateTime startDate; - final void Function(List? rules) onChanged; const WeekDaySelector({ - Key? key, + super.key, required this.recurrence, required this.onChanged, required this.startDate, - }) : super(key: key); + }); + final Recurrence recurrence; + final DateTime startDate; + final void Function(List? rules) onChanged; @override State createState() => _WeekDaySelectorState(); @@ -612,12 +611,12 @@ class _WeekDaySelectorState extends State { _weekdays = i18nService.formatWeekDays(abbreviate: true); final byWeekDays = widget.recurrence.byWeekDay; if (byWeekDays != null) { - int firstDayOfWeek = i18nService.firstDayOfWeek; + final int firstDayOfWeek = i18nService.firstDayOfWeek; for (int i = 0; i < 7; i++) { final day = ((firstDayOfWeek + i) <= 7) ? (firstDayOfWeek + i) : ((firstDayOfWeek + i) - 7); - bool isSelected = byWeekDays.any((dayRule) => dayRule.weekday == day); + final bool isSelected = byWeekDays.any((dayRule) => dayRule.weekday == day); _selectedDays[i] = isSelected; } } @@ -659,34 +658,31 @@ class _WeekDaySelectorState extends State { } @override - Widget build(BuildContext context) { - return FittedBox( + Widget build(BuildContext context) => FittedBox( child: PlatformToggleButtons( isSelected: _selectedDays, onPressed: _toggle, children: _weekdays .map((day) => Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(day.name), )) .toList(), ), ); - } } enum _DayOfMonthOption { dayOfMonth, dayInNumberedWeek } class DayOfMonthSelector extends StatefulWidget { - final Recurrence recurrence; - final DateTime startDate; - final void Function(Recurrence recurrence) onChanged; const DayOfMonthSelector( - {Key? key, + {super.key, required this.recurrence, required this.startDate, - required this.onChanged}) - : super(key: key); + required this.onChanged}); + final Recurrence recurrence; + final DateTime startDate; + final void Function(Recurrence recurrence) onChanged; @override State createState() => _DayOfMonthSelectorState(); @@ -779,7 +775,7 @@ class _DayOfMonthSelectorState extends State { ), if (_option == _DayOfMonthOption.dayInNumberedWeek && rule != null) ...[ Padding( - padding: const EdgeInsets.fromLTRB(32.0, 8.0, 8.0, 32.0), + padding: const EdgeInsets.fromLTRB(32, 8, 8, 32), child: Row( children: [ PlatformDropdownButton( @@ -820,7 +816,7 @@ class _DayOfMonthSelectorState extends State { }, ), const Padding( - padding: EdgeInsets.all(8.0), + padding: EdgeInsets.all(8), ), PlatformDropdownButton( items: _weekdays @@ -848,12 +844,11 @@ class _DayOfMonthSelectorState extends State { } class UntilComposer extends StatefulWidget { + const UntilComposer( + {super.key, required this.start, this.until, this.recommendation}); final DateTime start; final DateTime? until; final IsoDuration? recommendation; - const UntilComposer( - {Key? key, required this.start, this.until, this.recommendation}) - : super(key: key); @override State createState() => _UntilComposerState(); @@ -906,7 +901,7 @@ class _UntilComposerState extends State { final localizations = context.text; final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -922,12 +917,12 @@ class _UntilComposerState extends State { ), if (_option == _UntilOption.date) ...[ Padding( - padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0), + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), child: Text(localizations.composeAppointmentRecurrenceUntilLabel, style: theme.textTheme.bodySmall), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), + padding: const EdgeInsets.symmetric(horizontal: 8), child: DateTimePicker( dateTime: _until, onlyDate: true, diff --git a/lib/widgets/ical_interactive_media.dart b/lib/widgets/ical_interactive_media.dart index b0812e4..f6c8b90 100644 --- a/lib/widgets/ical_interactive_media.dart +++ b/lib/widgets/ical_interactive_media.dart @@ -3,14 +3,14 @@ import 'dart:convert'; import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_icalendar/enough_icalendar.dart'; import 'package:enough_icalendar_export/enough_icalendar_export.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/message.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/scaffold_messenger_service.dart'; -import 'package:enough_mail_app/util/localized_dialog_helper.dart'; -import 'package:enough_mail_app/widgets/mail_address_chip.dart'; -import 'package:enough_mail_app/widgets/text_with_links.dart'; +import '../l10n/extension.dart'; +import '../locator.dart'; +import '../models/message.dart'; +import '../services/i18n_service.dart'; +import '../services/scaffold_messenger_service.dart'; +import '../util/localized_dialog_helper.dart'; +import 'mail_address_chip.dart'; +import 'text_with_links.dart'; import 'package:enough_mail_flutter/enough_mail_flutter.dart'; import 'package:enough_mail_icalendar/enough_mail_icalendar.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; @@ -21,11 +21,10 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.g.dart'; class IcalInteractiveMedia extends StatefulWidget { + const IcalInteractiveMedia( + {super.key, required this.mediaProvider, required this.message}); final MediaProvider mediaProvider; final Message message; - const IcalInteractiveMedia( - {Key? key, required this.mediaProvider, required this.message}) - : super(key: key); @override State createState() => _IcalInteractiveMediaState(); @@ -73,7 +72,7 @@ class _IcalInteractiveMediaState extends State { if (event == null) { if (_isPermanentError) { return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.errorTitle), ); } @@ -84,21 +83,20 @@ class _IcalInteractiveMediaState extends State { final i18nService = locator(); final userEmail = widget.message.account.email.toLowerCase(); final recurrenceRule = event.recurrenceRule; - var end = event.end; - var start = event.start; + final end = event.end; + final start = event.start; return Material( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( children: [ if (isReply) Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: _buildReply(context, localizations, event), ) else if (_canReply && _participantStatus == null) Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ PlatformTextButton( child: PlatformText(localizations.actionAccept), @@ -120,10 +118,9 @@ class _IcalInteractiveMediaState extends State { ) else if (_participantStatus != null) Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( _participantStatus?.localization(localizations) ?? ''), ), @@ -142,11 +139,11 @@ class _IcalInteractiveMediaState extends State { children: [ TableRow(children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelSummary), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: TextWithLinks( text: event.summary ?? localizations.icalendarNoSummaryInfo), @@ -156,11 +153,11 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelStart), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( i18nService.formatDateTime(start.toLocal(), alwaysUseAbsoluteFormat: true, @@ -173,11 +170,11 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelEnd), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( i18nService.formatDateTime(end.toLocal(), alwaysUseAbsoluteFormat: true, @@ -190,11 +187,11 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelDuration), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( i18nService.formatIsoDuration(event.duration!)), ) @@ -204,11 +201,11 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelRecurrenceRule), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(recurrenceRule.toHumanReadableText( languageCode: localizations.localeName, )), @@ -219,11 +216,11 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelDescription), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: TextWithLinks( text: event.description!, ), @@ -234,11 +231,11 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelLocation), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: TextWithLinks(text: event.location!), ) ], @@ -247,11 +244,11 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelTeamsUrl), ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: TextWithLinks( text: event.microsoftTeamsMeetingUrl!), ) @@ -261,7 +258,7 @@ class _IcalInteractiveMediaState extends State { TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelParticipants), ), Column( @@ -272,7 +269,7 @@ class _IcalInteractiveMediaState extends State { final address = isMe ? widget.message.account.fromAddress : attendee.mailAddress; - final participantStatus = (isMe) + final participantStatus = isMe ? _participantStatus ?? attendee.participantStatus : attendee.participantStatus; final icon = participantStatus?.icon; @@ -285,14 +282,12 @@ class _IcalInteractiveMediaState extends State { children: [ if (icon != null) Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: icon, ), - address != null - ? MailAddressChip(mailAddress: address) - : Expanded( + if (address != null) MailAddressChip(mailAddress: address) else Expanded( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -305,7 +300,7 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.symmetric( - vertical: 4.0), + vertical: 4), child: Text( attendee.email ?? attendee.uri.toString(), @@ -357,7 +352,7 @@ class _IcalInteractiveMediaState extends State { _participantStatus = status; }); try { - widget.message.mailClient.sendCalendarReply( + await widget.message.mailClient.sendCalendarReply( _calendar!, status, originatingMessage: widget.message.mimeMessage, @@ -369,12 +364,12 @@ class _IcalInteractiveMediaState extends State { if (kDebugMode) { print('Unable to send status update: $e $s'); } - LocalizedDialogHelper.showTextDialog(context, localizations.errorTitle, + await LocalizedDialogHelper.showTextDialog(context, localizations.errorTitle, localizations.icalendarParticipantStatusSentFailure(e.toString())); } } - void _queryParticipantStatus(AppLocalizations localizations) async { + Future _queryParticipantStatus(AppLocalizations localizations) async { final status = await LocalizedDialogHelper.showTextDialog( context, localizations.icalendarParticipantStatusChangeTitle, @@ -401,7 +396,7 @@ class _IcalInteractiveMediaState extends State { ), ]); if (status != null && status != _participantStatus) { - _changeParticipantStatus(status, localizations); + await _changeParticipantStatus(status, localizations); } } diff --git a/lib/widgets/icon_text.dart b/lib/widgets/icon_text.dart index b64ca37..58381cf 100644 --- a/lib/widgets/icon_text.dart +++ b/lib/widgets/icon_text.dart @@ -1,26 +1,25 @@ import 'package:flutter/material.dart'; class IconText extends StatelessWidget { + const IconText({ + super.key, + required this.icon, + required this.label, + this.padding = const EdgeInsets.all(8), + this.horizontalPadding = const EdgeInsets.only(left: 8), + this.brightness, + }); final Widget icon; final Widget label; final EdgeInsets padding; final EdgeInsets horizontalPadding; final Brightness? brightness; - const IconText({ - Key? key, - required this.icon, - required this.label, - this.padding = const EdgeInsets.all(8.0), - this.horizontalPadding = const EdgeInsets.only(left: 8.0), - this.brightness, - }) : super(key: key); @override Widget build(BuildContext context) { final content = Padding( padding: padding, child: Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ icon, Expanded( diff --git a/lib/widgets/inherited_widgets.dart b/lib/widgets/inherited_widgets.dart deleted file mode 100644 index 88f231d..0000000 --- a/lib/widgets/inherited_widgets.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/models/message.dart'; -import 'package:enough_mail_app/models/message_source.dart'; -import 'package:flutter/cupertino.dart'; - -class _InheritedMessageContainer extends InheritedWidget { - // You must pass through a child and your state. - const _InheritedMessageContainer({ - Key? key, - required this.data, - required Widget child, - }) : super(key: key, child: child); - - final MessageWidgetState data; - // This is a built in method which you can use to check if - // any state has changed. If not, no reason to rebuild all the widgets - // that rely on your state. - @override - bool updateShouldNotify(_InheritedMessageContainer old) => (old.data != data); -} - -class MessageWidget extends StatefulWidget { - // You must pass through a child. - final Widget child; - final Message? message; - - const MessageWidget({ - Key? key, - required this.child, - required this.message, - }) : super(key: key); - - // This is the secret sauce. Write your own 'of' method that will behave - // Exactly like MediaQuery.of and Theme.of - // It basically says 'get the data from the widget of this type. - static MessageWidgetState? of(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType<_InheritedMessageContainer>() - ?.data; - } - - @override - MessageWidgetState createState() => MessageWidgetState(); -} - -class MessageWidgetState extends State { - Message? get message => widget.message; - - void updateMime({required MimeMessage mime}) { - message?.updateMime(mime); - } - - @override - Widget build(BuildContext context) { - return _InheritedMessageContainer( - data: this, - child: widget.child, - ); - } -} - -class _InheritedMessageSourceContainer extends InheritedWidget { - final MessageSourceWidgetState data; - - // You must pass through a child and your state. - const _InheritedMessageSourceContainer({ - Key? key, - required this.data, - required Widget child, - }) : super(key: key, child: child); - - // This is a built in method which you can use to check if - // any state has changed. If not, no reason to rebuild all the widgets - // that rely on your state. - @override - bool updateShouldNotify(_InheritedMessageSourceContainer old) => - (old.data != data); -} - -class MessageSourceWidget extends StatefulWidget { - const MessageSourceWidget({ - Key? key, - required this.child, - required this.messageSource, - }) : super(key: key); - - // You must pass through a child. - final Widget child; - final MessageSource? messageSource; - - // This is the secret sauce. Write your own 'of' method that will behave - // Exactly like MediaQuery.of and Theme.of - // It basically says 'get the data from the widget of this type. - static MessageSourceWidgetState? of(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType<_InheritedMessageSourceContainer>() - ?.data; - } - - @override - MessageSourceWidgetState createState() => MessageSourceWidgetState(); -} - -class MessageSourceWidgetState extends State { - MessageSource? get messageSource => widget.messageSource; - - @override - Widget build(BuildContext context) { - return _InheritedMessageSourceContainer( - data: this, - child: widget.child, - ); - } -} - -class _InheritedMailServiceContainer extends InheritedWidget { - final MailServiceWidgetState data; - - // You must pass through a child and your state. - const _InheritedMailServiceContainer({ - Key? key, - required this.data, - required Widget child, - }) : super(key: key, child: child); - - @override - bool updateShouldNotify(_InheritedMailServiceContainer old) => true; - //(old.data._account != data._account); -} - -class MailServiceWidget extends StatefulWidget { - final Widget child; - final Account? account; - final List? accounts; - final MessageSource? messageSource; - - const MailServiceWidget({ - Key? key, - required this.child, - required this.account, - required this.accounts, - required this.messageSource, - }) : super(key: key); - - static MailServiceWidgetState? of(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType<_InheritedMailServiceContainer>() - ?.data; - } - - @override - MailServiceWidgetState createState() => MailServiceWidgetState(); -} - -class MailServiceWidgetState extends State { - Account? _account; - List? _accounts; - MessageSource? _messageSource; - - @override - void initState() { - super.initState(); - _account = widget.account; - _accounts = widget.accounts; - _messageSource = widget.messageSource; - } - - Account? get account => _account; - set account(Account? value) { - setState(() { - _account = value; - }); - } - - List? get accounts => _accounts; - set accounts(List? value) { - setState(() { - _accounts = value; - }); - } - - MessageSource? get messageSource => _messageSource; - set messageSource(MessageSource? value) { - setState(() { - _messageSource = value; - }); - } - - @override - Widget build(BuildContext context) { - return _InheritedMailServiceContainer( - data: this, - child: widget.child, - ); - } -} diff --git a/lib/widgets/legalese.dart b/lib/widgets/legalese.dart index b369183..2189f4a 100644 --- a/lib/widgets/legalese.dart +++ b/lib/widgets/legalese.dart @@ -1,13 +1,13 @@ -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/widgets/text_with_links.dart'; +import '../l10n/extension.dart'; +import 'text_with_links.dart'; import 'package:flutter/material.dart'; class Legalese extends StatelessWidget { + const Legalese({super.key}); static const String urlPrivacyPolicy = 'https://www.enough.de/privacypolicy/maily-pp.html'; static const String urlTermsAndConditions = 'https://github.com/Enough-Software/enough_mail_app/blob/main/LICENSE'; - const Legalese({Key? key}) : super(key: key); @override Widget build(BuildContext context) { diff --git a/lib/widgets/mail_address_chip.dart b/lib/widgets/mail_address_chip.dart index 61aa7da..1c445cc 100644 --- a/lib/widgets/mail_address_chip.dart +++ b/lib/widgets/mail_address_chip.dart @@ -1,10 +1,10 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/models/compose_data.dart'; -import 'package:enough_mail_app/routes.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; -import 'package:enough_mail_app/services/scaffold_messenger_service.dart'; +import '../l10n/extension.dart'; +import '../models/compose_data.dart'; +import '../routes.dart'; +import '../services/mail_service.dart'; +import '../services/navigation_service.dart'; +import '../services/scaffold_messenger_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -13,10 +13,9 @@ import '../locator.dart'; import 'icon_text.dart'; class MailAddressChip extends StatelessWidget { + const MailAddressChip({super.key, required this.mailAddress, this.icon}); final MailAddress mailAddress; final Widget? icon; - const MailAddressChip({Key? key, required this.mailAddress, this.icon}) - : super(key: key); String get text => (mailAddress.hasPersonalName) ? mailAddress.personalName! @@ -60,7 +59,7 @@ class MailAddressChip extends StatelessWidget { case _AddressAction.none: break; case _AddressAction.copy: - Clipboard.setData(ClipboardData(text: mailAddress.email)); + await Clipboard.setData(ClipboardData(text: mailAddress.email)); locator() .showTextSnackBar(localizations.feedbackResultInfoCopied); break; @@ -68,14 +67,14 @@ class MailAddressChip extends StatelessWidget { final messageBuilder = MessageBuilder()..to = [mailAddress]; final composeData = ComposeData(null, messageBuilder, ComposeAction.newMessage); - locator() + await locator() .push(Routes.mailCompose, arguments: composeData); break; case _AddressAction.search: final search = MailSearch(mailAddress.email, SearchQueryType.fromOrTo); final source = await locator().search(search); - locator() + await locator() .push(Routes.messageSource, arguments: source); break; } diff --git a/lib/widgets/mailbox_selector.dart b/lib/widgets/mailbox_selector.dart index 0a30da0..b2c4c13 100644 --- a/lib/widgets/mailbox_selector.dart +++ b/lib/widgets/mailbox_selector.dart @@ -1,23 +1,23 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import '../account/model.dart'; import '../locator.dart'; +import '../services/mail_service.dart'; class MailboxSelector extends StatelessWidget { - final Account account; - final bool showRoot; - final Mailbox? mailbox; - final void Function(Mailbox? mailbox) onChanged; const MailboxSelector({ - Key? key, + super.key, required this.account, this.showRoot = true, this.mailbox, required this.onChanged, - }) : super(key: key); + }); + final Account account; + final bool showRoot; + final Mailbox? mailbox; + final void Function(Mailbox? mailbox) onChanged; @override Widget build(BuildContext context) { diff --git a/lib/widgets/mailbox_tree.dart b/lib/widgets/mailbox_tree.dart index c014eed..42207bc 100644 --- a/lib/widgets/mailbox_tree.dart +++ b/lib/widgets/mailbox_tree.dart @@ -1,21 +1,22 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/models/account.dart'; -import 'package:enough_mail_app/services/icon_service.dart'; -import 'package:enough_mail_app/services/mail_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import '../account/model.dart'; import '../locator.dart'; +import '../services/icon_service.dart'; +import '../services/mail_service.dart'; class MailboxTree extends StatelessWidget { + const MailboxTree( + {super.key, + required this.account, + required this.onSelected, + this.current}); final Account account; final void Function(Mailbox mailbox) onSelected; final Mailbox? current; - const MailboxTree( - {Key? key, required this.account, required this.onSelected, this.current}) - : super(key: key); - @override Widget build(BuildContext context) { final mailboxTreeData = locator().getMailboxTreeFor(account); diff --git a/lib/widgets/menu_with_badge.dart b/lib/widgets/menu_with_badge.dart index b01fb57..7b83a51 100644 --- a/lib/widgets/menu_with_badge.dart +++ b/lib/widgets/menu_with_badge.dart @@ -1,24 +1,23 @@ import 'dart:io'; import 'package:badges/badges.dart' as badges; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; +import '../locator.dart'; +import '../services/navigation_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class MenuWithBadge extends StatelessWidget { const MenuWithBadge({ - Key? key, + super.key, this.badgeContent, this.iOSText, - }) : super(key: key); + }); final Widget? badgeContent; final String? iOSText; @override - Widget build(BuildContext context) { - return DensePlatformIconButton( + Widget build(BuildContext context) => DensePlatformIconButton( icon: badges.Badge( badgeContent: badgeContent, child: _buildIndicator(context), @@ -32,7 +31,6 @@ class MenuWithBadge extends StatelessWidget { } }, ); - } Widget _buildIndicator(BuildContext context) { if (Platform.isIOS) { diff --git a/lib/widgets/message_overview_content.dart b/lib/widgets/message_overview_content.dart index 765c27d..ccc81e4 100644 --- a/lib/widgets/message_overview_content.dart +++ b/lib/widgets/message_overview_content.dart @@ -1,22 +1,22 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/models/message.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/icon_service.dart'; +import '../l10n/extension.dart'; +import '../models/message.dart'; +import '../services/i18n_service.dart'; +import '../services/icon_service.dart'; import 'package:flutter/material.dart'; import '../l10n/app_localizations.g.dart'; import '../locator.dart'; class MessageOverviewContent extends StatelessWidget { - final Message message; - final bool isSentMessage; const MessageOverviewContent({ - Key? key, + super.key, required this.message, required this.isSentMessage, - }) : super(key: key); + }); + final Message message; + final bool isSentMessage; @override Widget build(BuildContext context) { @@ -31,7 +31,7 @@ class MessageOverviewContent extends StatelessWidget { final date = locator().formatDateTime(mime.decodeDate()); final theme = Theme.of(context); return Container( - padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 4), color: msg.isFlagged ? theme.colorScheme.secondary : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -42,7 +42,7 @@ class MessageOverviewContent extends StatelessWidget { children: [ Expanded( child: Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Text( senderOrRecipients, overflow: TextOverflow.fade, @@ -60,7 +60,7 @@ class MessageOverviewContent extends StatelessWidget { msg.isFlagged || threadLength != 0) Padding( - padding: const EdgeInsets.only(left: 8.0), + padding: const EdgeInsets.only(left: 8), child: Row( children: [ if (msg.isFlagged) @@ -71,7 +71,7 @@ class MessageOverviewContent extends StatelessWidget { if (msg.isForwarded) const Icon(Icons.forward, size: 12), if (threadLength != 0) IconService.buildNumericIcon(context, threadLength, - size: 12.0), + size: 12), ], ), ), diff --git a/lib/widgets/message_stack.dart b/lib/widgets/message_stack.dart index 2b7bf28..ad51ec8 100644 --- a/lib/widgets/message_stack.dart +++ b/lib/widgets/message_stack.dart @@ -1,19 +1,21 @@ import 'dart:math'; + import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/models/message.dart'; -import 'package:enough_mail_app/models/message_source.dart'; -import 'package:enough_mail_app/services/i18n_service.dart'; -import 'package:enough_mail_app/services/scaffold_messenger_service.dart'; -import 'package:enough_mail_app/widgets/mail_address_chip.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../locator.dart'; +import '../models/message.dart'; +import '../models/message_source.dart'; +import '../services/i18n_service.dart'; +import '../services/scaffold_messenger_service.dart'; +import 'mail_address_chip.dart'; + enum DragAction { noted, later, delete, reply } class MessageStack extends StatefulWidget { - const MessageStack({Key? key, required this.messageSource}) : super(key: key); + const MessageStack({super.key, required this.messageSource}); final MessageSource? messageSource; @override @@ -28,9 +30,7 @@ class _MessageStackState extends State { final List _nextMessages = []; final List _nextAngles = []; - double createAngle() { - return (_random.nextInt(200) - 100.0) / 4000; - } + double createAngle() => (_random.nextInt(200) - 100.0) / 4000; @override void initState() { @@ -98,7 +98,7 @@ class _MessageStackState extends State { Align( alignment: Alignment.topRight, child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( dayName, style: Theme.of(context).textTheme.bodySmall, @@ -154,7 +154,7 @@ class _MessageStackState extends State { Align( alignment: Alignment.bottomLeft, child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: PlatformIconButton( icon: const Icon(Icons.arrow_back), onPressed: @@ -229,13 +229,12 @@ class _MessageStackState extends State { class MessageDragTarget extends StatefulWidget { const MessageDragTarget( - {Key? key, + {super.key, required this.action, required this.onComplete, this.data, this.width, - this.height}) - : super(key: key); + this.height}); final DragAction action; final Object? data; final Function(Message message, DragAction action, {Object? data}) onComplete; @@ -303,11 +302,9 @@ class _MessageDragTargetState extends State { } @override - Widget build(BuildContext context) { - return DragTarget( - builder: (context, candidateData, rejectedData) { - return Padding( - padding: const EdgeInsets.all(8.0), + Widget build(BuildContext context) => DragTarget( + builder: (context, candidateData, rejectedData) => Padding( + padding: const EdgeInsets.all(8), child: AnimatedContainer( decoration: BoxDecoration( color: color, @@ -320,88 +317,86 @@ class _MessageDragTargetState extends State { curve: Curves.bounceOut, child: Center(child: Text(text)), ), - ); - }, - onWillAccept: (data) { - startAccepting(); - return true; - }, - onAccept: (data) async { - endAccepting(); - widget.onComplete(data, widget.action, data: widget.data); - }, - onLeave: (data) => endAccepting(), - ); - } + ), + onWillAccept: (data) { + startAccepting(); + return true; + }, + onAccept: (data) async { + endAccepting(); + widget.onComplete(data, widget.action, data: widget.data); + }, + onLeave: (data) => endAccepting(), + ); } class MessageDraggable extends StatefulWidget { + const MessageDraggable({super.key, this.message, this.angle}); final Message? message; final double? angle; - const MessageDraggable({Key? key, this.message, this.angle}) - : super(key: key); - @override State createState() => _MessageDraggableState(); } class _MessageDraggableState extends State with TickerProviderStateMixin { - late AnimationController animationController; - late Animation scaleAnimation; + late AnimationController _animationController; + late Animation _scaleAnimation; @override void initState() { - animationController = AnimationController( - vsync: this, duration: const Duration(milliseconds: 400)); - scaleAnimation = CurvedAnimation( - curve: Curves.easeInOut, - parent: Tween(begin: 1.0, end: 0.5).animate(animationController)); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _scaleAnimation = CurvedAnimation( + curve: Curves.easeInOut, + parent: Tween(begin: 1, end: 0.5).animate(_animationController), + ); super.initState(); } @override void dispose() { - animationController.dispose(); + _animationController.dispose(); super.dispose(); } @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Draggable( + Widget build(BuildContext context) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) => + Draggable( data: widget.message, feedback: ConstrainedBox( - constraints: constraints, - child: ScaleTransition( - scale: scaleAnimation as Animation, - child: FadeTransition( - opacity: scaleAnimation as Animation, - child: - MessageCard(message: widget.message, angle: widget.angle), + constraints: constraints, + child: ScaleTransition( + scale: _scaleAnimation, + child: FadeTransition( + opacity: _scaleAnimation, + child: MessageCard( + message: widget.message, + angle: widget.angle, ), - )), + ), + ), + ), childWhenDragging: Container(), maxSimultaneousDrags: 1, onDragStarted: () { - animationController.reset(); - animationController.forward(); + _animationController + ..reset() + ..forward(); }, - dragAnchorStrategy: childDragAnchorStrategy, child: MessageCard(message: widget.message, angle: widget.angle), - ); - }, - ); - } + ), + ); } class MessageCard extends StatefulWidget { + const MessageCard({super.key, required this.message, this.angle}); final Message? message; final double? angle; - const MessageCard({Key? key, required this.message, this.angle}) - : super(key: key); @override State createState() => _MessageCardState(); @@ -425,32 +420,28 @@ class _MessageCardState extends State { } @override - Widget build(BuildContext context) { - return Transform.rotate( - angle: widget.angle!, - child: Card( - elevation: 18, - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: widget.message!.mimeMessage.isEmpty - ? const Text('...') - : buildMessageContents(), + Widget build(BuildContext context) => Transform.rotate( + angle: widget.angle!, + child: Card( + elevation: 18, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8), + child: widget.message!.mimeMessage.isEmpty + ? const Text('...') + : buildMessageContents(), + ), ), ), - ), - ); - } + ); Widget buildMessageContents() { final mime = widget.message!.mimeMessage; return Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ Text(mime.decodeSubject()!), Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ const Text('From '), for (final address in mime.from!) @@ -544,7 +535,7 @@ class _MessageCardState extends State { // onLinkTap: (url) => urlLauncher.launch(url), // ); // } - var text = widget.message?.mimeMessage.decodeTextPlainPart(); + final text = widget.message?.mimeMessage.decodeTextPlainPart(); if (text != null) { return SelectableText(text); } diff --git a/lib/widgets/password_field.dart b/lib/widgets/password_field.dart index ab6aa82..00dce64 100644 --- a/lib/widgets/password_field.dart +++ b/lib/widgets/password_field.dart @@ -4,14 +4,14 @@ import 'package:flutter/material.dart'; class PasswordField extends StatefulWidget { const PasswordField({ - Key? key, + super.key, required this.controller, this.labelText, this.hintText, this.onChanged, this.autofocus = false, this.cupertinoShowLabel = true, - }) : super(key: key); + }); final TextEditingController? controller; final String? labelText; @@ -28,8 +28,7 @@ class _PasswordFieldState extends State { bool _obscureText = true; @override - Widget build(BuildContext context) { - return DecoratedPlatformTextField( + Widget build(BuildContext context) => DecoratedPlatformTextField( controller: widget.controller, obscureText: _obscureText, onChanged: widget.onChanged, @@ -50,11 +49,10 @@ class _PasswordFieldState extends State { icon: Icon( _obscureText ? Icons.lock_open : Icons.lock, color: CupertinoColors.secondaryLabel, - size: 20.0, + size: 20, ), ), ), ), ); - } } diff --git a/lib/widgets/recipient_input_field.dart b/lib/widgets/recipient_input_field.dart index e23454d..6222507 100644 --- a/lib/widgets/recipient_input_field.dart +++ b/lib/widgets/recipient_input_field.dart @@ -1,17 +1,18 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/models/contact.dart'; -import 'package:enough_mail_app/util/validator.dart'; -import 'package:enough_mail_app/widgets/icon_text.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttercontactpicker/fluttercontactpicker.dart'; +import '../l10n/extension.dart'; +import '../models/contact.dart'; +import '../util/validator.dart'; +import 'icon_text.dart'; + class RecipientInputField extends StatefulWidget { const RecipientInputField({ - Key? key, + super.key, this.labelText, this.hintText, this.controller, @@ -19,7 +20,7 @@ class RecipientInputField extends StatefulWidget { this.autofocus = false, required this.addresses, required this.contactManager, - }) : super(key: key); + }); final String? labelText; final String? hintText; @@ -75,7 +76,7 @@ class _RecipientInputFieldState extends State { children: [ if (widget.addresses.isNotEmpty && labelText != null) Padding( - padding: const EdgeInsets.only(top: 8.0, right: 8.0), + padding: const EdgeInsets.only(top: 8, right: 8), child: Text( labelText, style: TextStyle( @@ -96,7 +97,7 @@ class _RecipientInputFieldState extends State { ), ), ), - feedbackOffset: const Offset(10.0, 10.0), + feedbackOffset: const Offset(10, 10), childWhenDragging: Opacity( opacity: 0.6, child: _AddressChip( @@ -141,8 +142,7 @@ class _RecipientInputFieldState extends State { ); } - Widget buildInput(ThemeData theme, BuildContext context) { - return RawAutocomplete( + Widget buildInput(ThemeData theme, BuildContext context) => RawAutocomplete( focusNode: _focusNode, textEditingController: _controller, optionsBuilder: (textEditingValue) { @@ -170,8 +170,7 @@ class _RecipientInputFieldState extends State { }, displayStringForOption: (option) => option.toString(), fieldViewBuilder: - (context, textEditingController, focusNode, onFieldSubmitted) { - return DecoratedPlatformTextField( + (context, textEditingController, focusNode, onFieldSubmitted) => DecoratedPlatformTextField( controller: textEditingController, focusNode: focusNode, autofocus: widget.autofocus, @@ -199,17 +198,15 @@ class _RecipientInputFieldState extends State { ], ), ), - ); - }, - optionsViewBuilder: (context, onSelected, options) { - return Material( + ), + optionsViewBuilder: (context, onSelected, options) => Material( child: Align( alignment: Alignment.topLeft, child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: ListView.builder( shrinkWrap: true, - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), itemCount: options.length, itemBuilder: (BuildContext context, int index) { final MailAddress option = options.elementAt(index); @@ -240,10 +237,8 @@ class _RecipientInputFieldState extends State { ), ), ), - ); - }, + ), ); - } void checkEmail(String input) { if (Validator.validateEmail(input)) { @@ -254,10 +249,10 @@ class _RecipientInputFieldState extends State { } } - void _pickContact(TextEditingController controller) async { + Future _pickContact(TextEditingController controller) async { try { final contact = - await FlutterContactPicker.pickEmailContact(askForPermission: true); + await FlutterContactPicker.pickEmailContact(); widget.addresses.add( MailAddress( contact.fullName, @@ -288,12 +283,12 @@ class _RecipientInputFieldState extends State { class _AddressChip extends StatelessWidget { const _AddressChip({ - Key? key, + super.key, required this.address, this.onDeleted, this.menuItems, this.onMenuItemSelected, - }) : super(key: key); + }); final MailAddress address; final VoidCallback? onDeleted; diff --git a/lib/widgets/search_text_field.dart b/lib/widgets/search_text_field.dart index 9b97c47..9fa0f63 100644 --- a/lib/widgets/search_text_field.dart +++ b/lib/widgets/search_text_field.dart @@ -1,8 +1,8 @@ import 'package:enough_mail/enough_mail.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/models/message_source.dart'; -import 'package:enough_mail_app/routes.dart'; -import 'package:enough_mail_app/services/navigation_service.dart'; +import '../l10n/extension.dart'; +import '../models/message_source.dart'; +import '../routes.dart'; +import '../services/navigation_service.dart'; import 'package:enough_platform_widgets/cupertino.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -11,8 +11,7 @@ import '../locator.dart'; /// A dedicated search field optimized for Cupertino class CupertinoSearch extends StatelessWidget { - const CupertinoSearch({Key? key, required this.messageSource}) - : super(key: key); + const CupertinoSearch({super.key, required this.messageSource}); final MessageSource messageSource; diff --git a/lib/widgets/signature.dart b/lib/widgets/signature.dart index 6505acf..79f5e92 100644 --- a/lib/widgets/signature.dart +++ b/lib/widgets/signature.dart @@ -6,9 +6,9 @@ import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; +import '../account/model.dart'; import '../l10n/extension.dart'; import '../locator.dart'; -import '../models/account.dart'; import '../services/icon_service.dart'; import '../services/mail_service.dart'; import '../settings/provider.dart'; diff --git a/lib/widgets/text_with_links.dart b/lib/widgets/text_with_links.dart index 76873c0..233c7f4 100644 --- a/lib/widgets/text_with_links.dart +++ b/lib/widgets/text_with_links.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; class TextWithLinks extends StatelessWidget { + const TextWithLinks({super.key, required this.text, this.style}); final String text; final TextStyle? style; - const TextWithLinks({Key? key, required this.text, this.style}) - : super(key: key); - static final RegExp schemeRegEx = RegExp(r'[a-z]{3,6}://'); + static final RegExp schemeRegEx = RegExp('[a-z]{3,6}://'); // not a perfect but good enough regular expression to match URLs in text. It also matches a space at the beginning and a dot at the end, // so this is filtered out manually in the found matches static final RegExp linkRegEx = RegExp( @@ -64,12 +63,11 @@ class TextWithLinks extends StatelessWidget { } class TextWithNamedLinks extends StatelessWidget { + + const TextWithNamedLinks({super.key, required this.parts, this.style}); final List parts; final TextStyle? style; - const TextWithNamedLinks({Key? key, required this.parts, this.style}) - : super(key: key); - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -113,10 +111,10 @@ class TextWithNamedLinks extends StatelessWidget { } class TextLink { - final String text; - final String? url; - final void Function()? callback; const TextLink(this.text, [this.url]) : callback = null; const TextLink.callback(this.text, this.callback) : url = null; + final String text; + final String? url; + final void Function()? callback; } diff --git a/pubspec.yaml b/pubspec.yaml index 3c0fd8b..ff8007f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Maily aims to become a fully feature email app once it has grown up # The following line prevents the package from being accidentally published to # pub.dev using `pub publish`. This is preferred for private packages. -publish_to: "none" +publish_to: "none" # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -24,41 +24,44 @@ dependencies: background_fetch: ^1.1.0 badges: ^3.0.2 cached_network_image: ^3.2.1 - community_material_icon: ^5.9.55 collection: ^1.16.0 - cupertino_icons: ^1.0.4 + community_material_icon: ^5.9.55 crypto: ^3.0.2 + cupertino_icons: ^1.0.4 device_info_plus: ^3.2.1 + enough_ascii_art: ^1.0.0 + enough_giphy_flutter: ^0.3.0 + enough_html_editor: ^0.0.3 + enough_icalendar: ^0.5.0 + enough_icalendar_export: ^0.2.0 enough_mail: ^2.0.0 enough_mail_flutter: ^2.0.0 enough_mail_html: ^2.0.0 - enough_html_editor: ^0.0.3 - enough_text_editor: - git: - url: https://github.com/Enough-Software/enough_text_editor.git - enough_icalendar: ^0.5.0 enough_mail_icalendar: ^0.2.0 enough_media: ^2.2.0 - enough_icalendar_export: ^0.2.0 - enough_ascii_art: ^1.0.0 - enough_giphy_flutter: ^0.3.0 enough_platform_widgets: ^0.3.0 + enough_text_editor: + git: + url: https://github.com/Enough-Software/enough_text_editor.git event_bus: ^2.0.0 file_picker: ^5.3.3 flutter: sdk: flutter - fluttercontactpicker: ^4.7.0 flutter_colorpicker: ^1.0.3 + flutter_hooks: ^0.20.1 flutter_local_notifications: ^15.1.0+1 flutter_localizations: sdk: flutter flutter_secure_storage: ^9.0.0 flutter_web_auth: ^0.5.0 flutter_widget_from_html_core: ^0.10.0 - get_it: ^7.2.0 + fluttercontactpicker: ^4.7.0 + get_it: ^7.2.0 + go_router: ^11.1.2 google_fonts: ^5.1.0 hive: ^2.2.3 hive_flutter: ^1.1.0 + hooks_riverpod: ^2.4.0 http: ^1.1.0 intl: ^0.17.0 introduction_screen: ^3.0.2 @@ -66,21 +69,18 @@ dependencies: latlng: ^0.2.0 local_auth: ^2.1.0 location: ^5.0.1 + logger: ^2.0.2+1 map: ^1.0.0 modal_bottom_sheet: ^3.0.0-pre open_settings: ^2.0.2 package_info_plus: ^1.3.0 path_provider: ^2.0.8 + riverpod_annotation: ^2.1.5 share_plus: ^7.1.0 shared_preferences: ^2.0.11 shimmer_animation: ^2.1.0+1 - # snapping_sheet: ^3.1.0 url_launcher: ^6.0.17 webview_flutter: ^4.0.2 - hooks_riverpod: ^2.4.0 - flutter_hooks: ^0.20.1 - riverpod_annotation: ^2.1.5 - logger: ^2.0.2+1 dependency_overrides: collection: ^1.16.0 @@ -105,31 +105,30 @@ dependency_overrides: # enough_media: # git: # url: https://github.com/Enough-Software/enough_media.git - # enough_icalendar: + # enough_icalendar: # git: # url: https://github.com/Enough-Software/enough_icalendar.git - # enough_mail_icalendar: + # enough_mail_icalendar: # git: # url: https://github.com/Enough-Software/enough_mail_icalendar.git - # enough_icalendar_export: + # enough_icalendar_export: # git: # url: https://github.com/Enough-Software/enough_icalendar_export.git - # enough_html_editor: + # enough_html_editor: # git: # url: https://github.com/Enough-Software/enough_html_editor.git - # enough_text_editor: + # enough_text_editor: # git: # url: https://github.com/Enough-Software/enough_text_editor.git - # enough_ascii_art: + # enough_ascii_art: # git: # url: https://github.com/Enough-Software/enough_ascii_art.git # enough_giphy_flutter: # git: # url: https://github.com/Enough-Software/enough_giphy_flutter.git - # enough_platform_widgets: + # enough_platform_widgets: # git: # url: https://github.com/Enough-Software/enough_platform_widgets.git - # out-comment the following to enable local development: enough_mail: path: ../enough_mail @@ -142,14 +141,14 @@ dependency_overrides: enough_mail_icalendar: path: ../enough_mail_icalendar enough_giphy: - path: ../enough_giphy + path: ../enough_giphy enough_giphy_flutter: path: ../enough_giphy_flutter enough_icalendar_export: - path: ../enough_icalendar_export + path: ../enough_icalendar_export enough_media: path: ../enough_media - enough_html_editor: + enough_html_editor: path: ../enough_html_editor enough_text_editor: path: ../enough_text_editor @@ -172,7 +171,6 @@ dev_dependencies: # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec - # The following section is specific to Flutter. flutter: # The following line ensures that the Material Icons font is @@ -182,7 +180,7 @@ flutter: # For localization / l10n: generate: false - + # To add assets to your application, add an assets section, like this: assets: - assets/images/ diff --git a/test/model/async_mime_source_test.dart b/test/model/async_mime_source_test.dart index c8daa3b..574b384 100644 --- a/test/model/async_mime_source_test.dart +++ b/test/model/async_mime_source_test.dart @@ -248,7 +248,7 @@ void main() async { await source.onMessagesVanished(MessageSequence.fromIds([100])); expect(source.size, 100); await expectMessage( - 0, 100, 'first message\'s sequence ID should be adapted'); + 0, 100, "first message's sequence ID should be adapted"); await expectMessage(1, 99); await source.onMessagesVanished(MessageSequence.fromIds([100])); @@ -645,7 +645,7 @@ void main() async { final seenMessage = await source.getMessage(1); seenMessage.isSeen = true; final deleteMessage = await source.getMessage(2); - source.deleteMessages([deleteMessage]); + await source.deleteMessages([deleteMessage]); expect(source.size, 99); final firstMessage = await source.getMessage(0); expect(firstMessage.sequenceId, 99); diff --git a/test/model/fake_mime_source.dart b/test/model/fake_mime_source.dart index 2f21e5d..ec01e6b 100644 --- a/test/model/fake_mime_source.dart +++ b/test/model/fake_mime_source.dart @@ -2,16 +2,15 @@ import 'dart:math'; import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail_app/models/async_mime_source.dart'; -import 'package:enough_mail_app/util/indexed_cache.dart'; class FakeMimeSource extends PagedCachedMimeSource { FakeMimeSource({ required int size, - int maxCacheSize = IndexedCache.defaultMaxCacheSize, + super.maxCacheSize, this.name = '', DateTime? startDate, Duration? differencePerMessage, - }) : _startDate = startDate ?? DateTime(2022, 04, 16, 08, 00), + }) : _startDate = startDate ?? DateTime(2022, 04, 16, 08), _differencePerMessage = differencePerMessage ?? const Duration(minutes: 5), mailClient = MailClient( @@ -22,8 +21,7 @@ class FakeMimeSource extends PagedCachedMimeSource { outgoingHost: 'smtp.domain.com', password: 'password', ), - ), - super(maxCacheSize: maxCacheSize) { + ) { messages = generateMessages( size: size, name: name, @@ -48,7 +46,7 @@ class FakeMimeSource extends PagedCachedMimeSource { size - i, size, name, - startDate ?? DateTime(2022, 04, 16, 08, 00), + startDate ?? DateTime(2022, 04, 16, 08), differencePerMessage ?? const Duration(minutes: 5), ), ); diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index 2f09f73..8faa71e 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -4,9 +4,9 @@ import 'package:enough_mail_app/models/message.dart'; import 'package:enough_mail_app/models/message_source.dart'; import 'package:enough_mail_app/services/notification_service.dart'; import 'package:enough_mail_app/services/scaffold_messenger_service.dart'; -import 'package:flutter/src/widgets/framework.dart'; -import 'package:flutter/src/material/scaffold.dart'; import 'package:enough_mail_app/widgets/cupertino_status_bar.dart'; +import 'package:flutter/src/material/scaffold.dart'; +import 'package:flutter/src/widgets/framework.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; @@ -16,9 +16,9 @@ void main() async { final notificationService = TestNotificationService(); GetIt.instance.registerSingleton(notificationService); GetIt.instance.registerLazySingleton( - () => TestScaffoldMessengerService()); + TestScaffoldMessengerService.new); - final firstMimeSourceStartDate = DateTime.utc(2022, 04, 16, 09, 00); + final firstMimeSourceStartDate = DateTime.utc(2022, 04, 16, 09); const firstMimeSourceDifferencePerMessage = Duration(minutes: 5); final secondMimeSourceStartDate = DateTime.utc(2022, 04, 16, 09, 01); const secondMimeSourceDifferencePerMessage = Duration(minutes: 10); @@ -133,7 +133,7 @@ void main() async { group('incoming messages', () { test('simple onMessageArrived x 1', () async { - (firstMimeSource as FakeMimeSource).addFakeMessage(101); + await (firstMimeSource as FakeMimeSource).addFakeMessage(101); final message = await source.getMessageAt(0); expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); @@ -157,7 +157,7 @@ void main() async { expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); // add new message: - (firstMimeSource as FakeMimeSource).addFakeMessage(101); + await (firstMimeSource as FakeMimeSource).addFakeMessage(101); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 101); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); @@ -189,7 +189,7 @@ void main() async { expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); // add new message: - (firstMimeSource as FakeMimeSource).addFakeMessage(101); + await (firstMimeSource as FakeMimeSource).addFakeMessage(101); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 101); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); @@ -221,10 +221,10 @@ void main() async { expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); // add new message: - (firstMimeSource as FakeMimeSource).addFakeMessage(101); + await (firstMimeSource as FakeMimeSource).addFakeMessage(101); expect(notifyCounter, 1); // add new message: - (secondMimeSource as FakeMimeSource).addFakeMessage(21); + await (secondMimeSource as FakeMimeSource).addFakeMessage(21); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 21); expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); @@ -245,8 +245,8 @@ void main() async { expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); message = await source.getMessageAt(1); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); - (firstMimeSource as FakeMimeSource).addFakeMessage(101); - (secondMimeSource as FakeMimeSource).addFakeMessage(21); + await (firstMimeSource as FakeMimeSource).addFakeMessage(101); + await (secondMimeSource as FakeMimeSource).addFakeMessage(21); expect(notifyCounter, 2); expect(source.size, 122); message = await source.getMessageAt(0); @@ -273,8 +273,8 @@ void main() async { expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); message = await source.getMessageAt(1); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); - (secondMimeSource as FakeMimeSource).addFakeMessage(21); - (firstMimeSource as FakeMimeSource).addFakeMessage(101); + await (secondMimeSource as FakeMimeSource).addFakeMessage(21); + await (firstMimeSource as FakeMimeSource).addFakeMessage(101); expect(notifyCounter, 2); expect(source.size, 122); message = await source.getMessageAt(0); @@ -467,7 +467,7 @@ void main() async { final updatedMime = (secondMimeSource as FakeMimeSource) .createMessage(firstMime.sequenceId!); updatedMime.setFlag(MessageFlags.seen, true); - secondMimeSource.onMessageFlagsUpdated(updatedMime); + await secondMimeSource.onMessageFlagsUpdated(updatedMime); expect(notifyCounter, 1); expect(firstMessage.isSeen, isTrue); }); @@ -488,7 +488,7 @@ void main() async { final updatedMime = (secondMimeSource as FakeMimeSource) .createMessage(firstMime.sequenceId!); updatedMime.setFlag(MessageFlags.seen, false); - secondMimeSource.onMessageFlagsUpdated(updatedMime); + await secondMimeSource.onMessageFlagsUpdated(updatedMime); expect(notifyCounter, 1); expect(firstMessage.isSeen, isFalse); }); From 001ae71baeab659cbadf6172a32a0c538d6982a9 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Sun, 8 Oct 2023 19:51:25 +0200 Subject: [PATCH 02/95] chore: remove i18n service local access also move l10n to localization --- l10n.yaml | 4 +- lib/account/model.dart | 15 +- lib/extensions/extension_action_tile.dart | 11 +- lib/extensions/extensions.dart | 1 + lib/{l10n => localization}/app_de.arb | 1 + lib/{l10n => localization}/app_en.arb | 1 + .../app_localizations.g.dart | 2 +- .../app_localizations_de.g.dart | 0 .../app_localizations_en.g.dart | 0 lib/{l10n => localization}/extension.dart | 0 lib/main.dart | 2 +- lib/models/swipe.dart | 4 +- lib/screens/account_add_screen.dart | 21 ++- lib/screens/account_edit_screen.dart | 4 +- .../account_server_details_screen.dart | 2 +- lib/screens/compose_screen.dart | 41 ++++-- lib/screens/location_screen.dart | 2 +- lib/screens/lock_screen.dart | 41 +++--- lib/screens/mail_screen.dart | 2 +- lib/screens/media_screen.dart | 2 +- lib/screens/message_details_screen.dart | 4 +- lib/screens/message_source_screen.dart | 4 +- lib/screens/splash_screen.dart | 3 +- lib/screens/welcome_screen.dart | 136 +++++++++--------- lib/services/i18n_service.dart | 3 +- lib/services/mail_service.dart | 2 +- lib/services/providers.dart | 8 +- lib/settings/provider.dart | 11 +- .../view/settings_accounts_screen.dart | 4 +- .../view/settings_default_sender_screen.dart | 2 +- .../view/settings_developer_mode_screen.dart | 2 +- .../view/settings_feedback_screen.dart | 2 +- .../view/settings_folders_screen.dart | 3 +- .../view/settings_language_screen.dart | 4 +- .../view/settings_readreceipts_screen.dart | 2 +- lib/settings/view/settings_reply_screen.dart | 2 +- lib/settings/view/settings_screen.dart | 2 +- .../view/settings_security_screen.dart | 2 +- .../view/settings_signature_screen.dart | 6 +- lib/settings/view/settings_swipe_screen.dart | 2 +- lib/settings/view/settings_theme_screen.dart | 2 +- lib/util/localized_dialog_helper.dart | 2 +- lib/widgets/account_provider_selector.dart | 7 +- lib/widgets/app_drawer.dart | 4 +- lib/widgets/attachment_chip.dart | 2 +- lib/widgets/attachment_compose_bar.dart | 10 +- lib/widgets/editor_extensions.dart | 14 +- lib/widgets/ical_composer.dart | 29 ++-- lib/widgets/ical_interactive_media.dart | 72 +++++----- lib/widgets/legalese.dart | 5 +- lib/widgets/mail_address_chip.dart | 12 +- lib/widgets/message_actions.dart | 2 +- lib/widgets/message_overview_content.dart | 11 +- lib/widgets/recipient_input_field.dart | 69 ++++----- lib/widgets/search_text_field.dart | 8 +- lib/widgets/signature.dart | 10 +- 56 files changed, 325 insertions(+), 294 deletions(-) rename lib/{l10n => localization}/app_de.arb (99%) rename lib/{l10n => localization}/app_en.arb (99%) rename lib/{l10n => localization}/app_localizations.g.dart (99%) rename lib/{l10n => localization}/app_localizations_de.g.dart (100%) rename lib/{l10n => localization}/app_localizations_en.g.dart (100%) rename lib/{l10n => localization}/extension.dart (100%) diff --git a/l10n.yaml b/l10n.yaml index b5d059a..9fe1403 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,9 +1,9 @@ # Run flutter gen-l10n to generate # Run flutter gen-l10n --help to display options -arb-dir: lib/l10n +arb-dir: lib/localization template-arb-file: app_en.arb output-localization-file: app_localizations.g.dart -output-dir: lib/l10n +output-dir: lib/localization output-class: AppLocalizations synthetic-package: false untranslated-messages-file: missing-translations.txt diff --git a/lib/account/model.dart b/lib/account/model.dart index b6cbdf0..3d145b2 100644 --- a/lib/account/model.dart +++ b/lib/account/model.dart @@ -5,7 +5,6 @@ import 'package:json_annotation/json_annotation.dart'; import '../extensions/extensions.dart'; import '../locator.dart'; import '../models/contact.dart'; -import '../services/i18n_service.dart'; import '../services/mail_service.dart'; part 'model.g.dart'; @@ -17,12 +16,11 @@ abstract class Account extends ChangeNotifier { /// The name of the account String get name; + set name(String value); /// Retrieves the email or emails associated with this account String get email; - set name(String value); - /// The from address for this account MailAddress get fromAddress; } @@ -103,16 +101,13 @@ class RealAccount extends Account { /// Retrieves the account specific signature for HTML messages /// Compare [signaturePlain] - @JsonKey(includeToJson: false, includeFromJson: false) - String? get signatureHtml { + String? getSignatureHtml([String? languageCode]) { final signature = _account.attributes[attributeSignatureHtml]; if (signature == null) { final extensions = appExtensions; if (extensions != null) { - final languageCode = - locator().locale?.languageCode ?? 'en'; for (final ext in extensions) { - final signature = ext.getSignatureHtml(languageCode); + final signature = ext.getSignatureHtml(languageCode ?? 'en'); if (signature != null) { return signature; } @@ -123,19 +118,19 @@ class RealAccount extends Account { return signature; } + /// Sets the account specific signature for HTML messages + // ignore: avoid_setters_without_getters set signatureHtml(String? value) => setAttribute(attributeSignatureHtml, value); /// Account-specific signature for plain text messages /// /// Compare [signatureHtml] - @JsonKey(includeToJson: false, includeFromJson: false) String? get signaturePlain => _account.attributes[attributeSignaturePlain]; set signaturePlain(String? value) => setAttribute(attributeSignaturePlain, value); /// The name used for sending - @JsonKey(includeToJson: false, includeFromJson: false) String? get userName => _account.userName; set userName(String? value) { _account = _account.copyWith(userName: value); diff --git a/lib/extensions/extension_action_tile.dart b/lib/extensions/extension_action_tile.dart index 57cddf4..6731354 100644 --- a/lib/extensions/extension_action_tile.dart +++ b/lib/extensions/extension_action_tile.dart @@ -5,10 +5,10 @@ import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart' hide WebViewConfiguration; import '../account/model.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/models.dart'; import '../routes.dart'; -import '../services/i18n_service.dart'; import '../services/navigation_service.dart'; import 'extensions.dart'; @@ -29,8 +29,10 @@ class ExtensionActionTile extends StatelessWidget { } static List buildActionWidgets( - BuildContext context, List actions, - {bool withDivider = true}) { + BuildContext context, + List actions, { + bool withDivider = true, + }) { if (actions.isEmpty) { return []; } @@ -41,12 +43,13 @@ class ExtensionActionTile extends StatelessWidget { for (final action in actions) { widgets.add(ExtensionActionTile(actionDescription: action)); } + return widgets; } @override Widget build(BuildContext context) { - final languageCode = locator().locale!.languageCode; + final languageCode = context.text.localeName; return PlatformListTile( leading: actionDescription.icon == null diff --git a/lib/extensions/extensions.dart b/lib/extensions/extensions.dart index 1557b6f..5f0d382 100644 --- a/lib/extensions/extensions.dart +++ b/lib/extensions/extensions.dart @@ -151,6 +151,7 @@ class AppExtensionActionDescription { if (map == null) { return null; } + return map[languageCode] ?? map['en']; } diff --git a/lib/l10n/app_de.arb b/lib/localization/app_de.arb similarity index 99% rename from lib/l10n/app_de.arb rename to lib/localization/app_de.arb index 9a5be8f..0d601ef 100644 --- a/lib/l10n/app_de.arb +++ b/lib/localization/app_de.arb @@ -1,5 +1,6 @@ { "_notUsed": "cSpell:disable", + "@@locale": "de", "signature": "Mit Maily gesendet", "actionCancel": "Abbrechen", "actionOk": "OK", diff --git a/lib/l10n/app_en.arb b/lib/localization/app_en.arb similarity index 99% rename from lib/l10n/app_en.arb rename to lib/localization/app_en.arb index 5d60fe2..3983a38 100644 --- a/lib/l10n/app_en.arb +++ b/lib/localization/app_en.arb @@ -1,4 +1,5 @@ { + "@@locale": "en", "signature": "Sent with Maily", "@signature": { "description": "Default signature text" diff --git a/lib/l10n/app_localizations.g.dart b/lib/localization/app_localizations.g.dart similarity index 99% rename from lib/l10n/app_localizations.g.dart rename to lib/localization/app_localizations.g.dart index e194044..c144893 100644 --- a/lib/l10n/app_localizations.g.dart +++ b/lib/localization/app_localizations.g.dart @@ -16,7 +16,7 @@ import 'app_localizations_en.g.dart'; /// `supportedLocales` list. For example: /// /// ```dart -/// import 'l10n/app_localizations.g.dart'; +/// import 'localization/app_localizations.g.dart'; /// /// return MaterialApp( /// localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/lib/l10n/app_localizations_de.g.dart b/lib/localization/app_localizations_de.g.dart similarity index 100% rename from lib/l10n/app_localizations_de.g.dart rename to lib/localization/app_localizations_de.g.dart diff --git a/lib/l10n/app_localizations_en.g.dart b/lib/localization/app_localizations_en.g.dart similarity index 100% rename from lib/l10n/app_localizations_en.g.dart rename to lib/localization/app_localizations_en.g.dart diff --git a/lib/l10n/extension.dart b/lib/localization/extension.dart similarity index 100% rename from lib/l10n/extension.dart rename to lib/localization/extension.dart diff --git a/lib/main.dart b/lib/main.dart index 2b02349..bb8437c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'account/providers.dart'; import 'app_lifecycle/provider.dart'; -import 'l10n/app_localizations.g.dart'; +import 'localization/app_localizations.g.dart'; import 'locator.dart'; import 'logger.dart'; import 'routes.dart'; diff --git a/lib/models/swipe.dart b/lib/models/swipe.dart index 1ed236c..ef3f248 100644 --- a/lib/models/swipe.dart +++ b/lib/models/swipe.dart @@ -1,8 +1,8 @@ -import '../services/icon_service.dart'; import 'package:flutter/material.dart'; -import '../l10n/app_localizations.g.dart'; +import '../localization/app_localizations.g.dart'; import '../locator.dart'; +import '../services/icon_service.dart'; enum SwipeAction { markRead, diff --git a/lib/screens/account_add_screen.dart b/lib/screens/account_add_screen.dart index 7f6aecf..f057501 100644 --- a/lib/screens/account_add_screen.dart +++ b/lib/screens/account_add_screen.dart @@ -8,11 +8,10 @@ import 'package:url_launcher/url_launcher.dart' as launcher; import '../account/model.dart'; import '../extensions/extensions.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../routes.dart'; -import '../services/i18n_service.dart'; import '../services/mail_service.dart'; import '../services/navigation_service.dart'; import '../services/providers.dart'; @@ -478,16 +477,16 @@ class _AccountAddScreenState extends State { if (_extensionForgotPassword != null) ...[ PlatformTextButton( onPressed: () { - final languageCode = - locator().locale!.languageCode; - var url = _extensionForgotPassword!.action!.url; - url = url - ..replaceAll('{user.email}', _emailController.text) - ..replaceAll('{language}', languageCode); + final languageCode = localizations.localeName; + final url = (_extensionForgotPassword?.action?.url ?? '') + .replaceAll('{user.email}', _emailController.text) + .replaceAll('{language}', languageCode); launcher.launchUrl(Uri.parse(url)); }, - child: ButtonText(_extensionForgotPassword! - .getLabel(locator().locale!.languageCode)), + child: ButtonText( + _extensionForgotPassword! + .getLabel(localizations.localeName), + ), ), ], ], diff --git a/lib/screens/account_edit_screen.dart b/lib/screens/account_edit_screen.dart index 36a03da..79e6432 100644 --- a/lib/screens/account_edit_screen.dart +++ b/lib/screens/account_edit_screen.dart @@ -8,8 +8,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../routes.dart'; import '../services/icon_service.dart'; diff --git a/lib/screens/account_server_details_screen.dart b/lib/screens/account_server_details_screen.dart index d8ef7c2..5e3cd85 100644 --- a/lib/screens/account_server_details_screen.dart +++ b/lib/screens/account_server_details_screen.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../account/model.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../services/mail_service.dart'; import '../services/navigation_service.dart'; diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index 73f33c5..b01a4d0 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -9,8 +9,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/sender.dart'; @@ -134,9 +134,11 @@ class _ComposeScreenState extends ConsumerState { Future _loadMailTextFromComposeData() => Future.value(widget.data.resumeText); - String get _signature => ref - .read(settingsProvider.notifier) - .getSignatureHtml(_from.account, widget.data.action); + String get _signature => ref.read(settingsProvider.notifier).getSignatureHtml( + _from.account, + widget.data.action, + context.text.localeName, + ); Future _loadMailTextFromMessage() async { final signature = _signature; @@ -145,6 +147,7 @@ class _ComposeScreenState extends ConsumerState { if (mb.originalMessage == null) { if (_composeMode == ComposeMode.html) { final html = '

${mb.text ?? ' '}

$signature'; + return html; } else { return '${mb.text ?? ''}\n$signature'; @@ -157,14 +160,21 @@ class _ComposeScreenState extends ConsumerState { if (widget.data.action == ComposeAction.newMessage) { // continue with draft: if (_composeMode == ComposeMode.html) { - final args = _HtmlGenerationArguments(null, mb.originalMessage, - blockExternalImages, emptyMessageText, maxImageWidth); + final args = _HtmlGenerationArguments( + null, + mb.originalMessage, + blockExternalImages, + emptyMessageText, + maxImageWidth, + ); final html = await compute(_generateDraftHtmlImpl, args) + signature; + return html; } else { final text = '${mb.originalMessage?.decodeTextPlainPart() ?? emptyMessageText}' '\n$signature'; + return text; } } @@ -179,6 +189,7 @@ class _ComposeScreenState extends ConsumerState { final args = _HtmlGenerationArguments(quoteTemplate, mb.originalMessage, blockExternalImages, emptyMessageText, maxImageWidth); final html = await compute(_generateQuoteHtmlImpl, args) + signature; + return html; } else { final original = mb.originalMessage; @@ -186,6 +197,7 @@ class _ComposeScreenState extends ConsumerState { final header = MessageBuilder.fillTemplate(quoteTemplate, original); final plainText = original.decodeTextPlainPart() ?? emptyMessageText; final text = MessageBuilder.quotePlainText(header, plainText); + return '$text\n$signature'; } else { return '\n$signature'; @@ -201,19 +213,23 @@ class _ComposeScreenState extends ConsumerState { emptyMessageText: args.emptyMessageText, maxImageWidth: args.maxImageWidth, ); + return html; } static String _generateDraftHtmlImpl(_HtmlGenerationArguments args) { final html = args.mimeMessage!.transformToHtml( - emptyMessageText: args.emptyMessageText, - maxImageWidth: args.maxImageWidth, - blockExternalImages: args.blockExternalImages); + emptyMessageText: args.emptyMessageText, + maxImageWidth: args.maxImageWidth, + blockExternalImages: args.blockExternalImages, + ); + return html; } - Future _populateMessageBuilder( - {bool storeComposeDataForResume = false}) async { + Future _populateMessageBuilder({ + bool storeComposeDataForResume = false, + }) async { final mb = widget.data.messageBuilder; mb.to = _toRecipients; mb.cc = _ccRecipients; @@ -289,6 +305,7 @@ class _ComposeScreenState extends ConsumerState { } } final mimeMessage = mb.buildMimeMessage(); + return mimeMessage; } diff --git a/lib/screens/location_screen.dart b/lib/screens/location_screen.dart index b8b6074..d91bce4 100644 --- a/lib/screens/location_screen.dart +++ b/lib/screens/location_screen.dart @@ -9,7 +9,7 @@ import 'package:latlng/latlng.dart'; import 'package:location/location.dart'; import 'package:map/map.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../services/location_service.dart'; import '../services/navigation_service.dart'; diff --git a/lib/screens/lock_screen.dart b/lib/screens/lock_screen.dart index 76102e7..5bded46 100644 --- a/lib/screens/lock_screen.dart +++ b/lib/screens/lock_screen.dart @@ -2,8 +2,8 @@ import 'package:enough_platform_widgets/platform.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../services/biometrics_service.dart'; import '../services/navigation_service.dart'; @@ -24,25 +24,26 @@ class LockScreen extends StatelessWidget { ); } - Widget _buildContent(BuildContext context, AppLocalizations localizations) => WillPopScope( - onWillPop: () => Future.value(false), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(PlatformInfo.isCupertino ? CupertinoIcons.lock : Icons.lock), - Padding( - padding: const EdgeInsets.all(32), - child: Text(localizations.lockScreenIntro), - ), - PlatformTextButton( - child: PlatformText(localizations.lockScreenUnlockAction), - onPressed: () => _authenticate(context), - ) - ], + Widget _buildContent(BuildContext context, AppLocalizations localizations) => + WillPopScope( + onWillPop: () => Future.value(false), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(PlatformInfo.isCupertino ? CupertinoIcons.lock : Icons.lock), + Padding( + padding: const EdgeInsets.all(32), + child: Text(localizations.lockScreenIntro), + ), + PlatformTextButton( + child: PlatformText(localizations.lockScreenUnlockAction), + onPressed: () => _authenticate(context), + ) + ], + ), ), - ), - ); + ); Future _authenticate(BuildContext context) async { final didAuthencate = await locator().authenticate(); diff --git a/lib/screens/mail_screen.dart b/lib/screens/mail_screen.dart index f3ff4f3..62e4001 100644 --- a/lib/screens/mail_screen.dart +++ b/lib/screens/mail_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../mail/provider.dart'; import 'base.dart'; import 'message_source_screen.dart'; diff --git a/lib/screens/media_screen.dart b/lib/screens/media_screen.dart index 29dde86..fe7ddb8 100644 --- a/lib/screens/media_screen.dart +++ b/lib/screens/media_screen.dart @@ -12,7 +12,7 @@ import 'package:path_provider/path_provider.dart' as pathprovider; import 'package:share_plus/share_plus.dart'; import '../account/model.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index 1dfdc00..cfd2d8c 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -9,8 +9,8 @@ import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index 7c7c66d..bd001f4 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -6,8 +6,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/date_sectioned_message_source.dart'; diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index f05e30b..b1c8a44 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,9 +1,10 @@ import 'dart:math'; -import '../l10n/extension.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import '../localization/extension.dart'; + class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index a6a046e..07783eb 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,16 +1,16 @@ -import '../l10n/extension.dart'; -import '../routes.dart'; -import '../services/icon_service.dart'; -import '../services/navigation_service.dart'; -import '../widgets/button_text.dart'; -import '../widgets/legalese.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:shimmer_animation/shimmer_animation.dart'; -import '../l10n/app_localizations.g.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; +import '../routes.dart'; +import '../services/icon_service.dart'; +import '../services/navigation_service.dart'; +import '../widgets/button_text.dart'; +import '../widgets/legalese.dart'; class WelcomeScreen extends StatelessWidget { const WelcomeScreen({super.key}); @@ -41,74 +41,74 @@ class WelcomeScreen extends StatelessWidget { } List _buildPages(AppLocalizations localizations) => [ - PageViewModel( - title: localizations.welcomePanel1Title, - body: localizations.welcomePanel1Text, - image: Image.asset( - 'assets/images/maily.png', - height: 200, - fit: BoxFit.cover, + PageViewModel( + title: localizations.welcomePanel1Title, + body: localizations.welcomePanel1Text, + image: Image.asset( + 'assets/images/maily.png', + height: 200, + fit: BoxFit.cover, + ), + decoration: PageDecoration(pageColor: Colors.green[700]), + footer: _buildFooter(localizations), ), - decoration: PageDecoration(pageColor: Colors.green[700]), - footer: _buildFooter(localizations), - ), - PageViewModel( - title: localizations.welcomePanel2Title, - body: localizations.welcomePanel2Text, - image: Image.asset( - 'assets/images/mailboxes.png', - height: 200, - fit: BoxFit.cover, + PageViewModel( + title: localizations.welcomePanel2Title, + body: localizations.welcomePanel2Text, + image: Image.asset( + 'assets/images/mailboxes.png', + height: 200, + fit: BoxFit.cover, + ), + decoration: const PageDecoration(pageColor: Color(0xff543226)), + footer: _buildFooter(localizations), ), - decoration: const PageDecoration(pageColor: Color(0xff543226)), - footer: _buildFooter(localizations), - ), - PageViewModel( - title: localizations.welcomePanel3Title, - body: localizations.welcomePanel3Text, - image: Image.asset( - 'assets/images/swipe_press.png', - height: 200, - fit: BoxFit.cover, + PageViewModel( + title: localizations.welcomePanel3Title, + body: localizations.welcomePanel3Text, + image: Image.asset( + 'assets/images/swipe_press.png', + height: 200, + fit: BoxFit.cover, + ), + decoration: const PageDecoration(pageColor: Color(0xff761711)), + footer: _buildFooter(localizations), ), - decoration: const PageDecoration(pageColor: Color(0xff761711)), - footer: _buildFooter(localizations), - ), - PageViewModel( - title: localizations.welcomePanel4Title, - body: localizations.welcomePanel4Text, - image: Image.asset( - 'assets/images/drawing.jpg', - height: 200, - fit: BoxFit.cover, + PageViewModel( + title: localizations.welcomePanel4Title, + body: localizations.welcomePanel4Text, + image: Image.asset( + 'assets/images/drawing.jpg', + height: 200, + fit: BoxFit.cover, + ), + footer: _buildFooter(localizations), ), - footer: _buildFooter(localizations), - ), - ]; + ]; Widget _buildFooter(AppLocalizations localizations) => Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Shimmer( - duration: const Duration(seconds: 4), - interval: const Duration(seconds: 6), - child: PlatformFilledButtonIcon( - icon: Icon(locator().email), - label: Center( - child: PlatformText(localizations.welcomeActionSignIn), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Shimmer( + duration: const Duration(seconds: 4), + interval: const Duration(seconds: 6), + child: PlatformFilledButtonIcon( + icon: Icon(locator().email), + label: Center( + child: PlatformText(localizations.welcomeActionSignIn), + ), + onPressed: () { + locator() + .push(Routes.accountAdd, arguments: true); + }, ), - onPressed: () { - locator() - .push(Routes.accountAdd, arguments: true); - }, ), ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Legalese(), - ), - ], - ); + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Legalese(), + ), + ], + ); } diff --git a/lib/services/i18n_service.dart b/lib/services/i18n_service.dart index 2079d1d..d7e1c07 100644 --- a/lib/services/i18n_service.dart +++ b/lib/services/i18n_service.dart @@ -5,7 +5,7 @@ import 'package:intl/date_symbols.dart'; import 'package:intl/intl.dart' as intl; import 'package:intl/intl.dart'; -import '../l10n/app_localizations.g.dart'; +import '../localization/app_localizations.g.dart'; import 'date_service.dart'; class I18nService { @@ -62,7 +62,6 @@ class I18nService { }; int firstDayOfWeek = DateTime.monday; Locale? _locale; - Locale? get locale => _locale; late AppLocalizations _localizations; AppLocalizations get localizations => _localizations; diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index 7dc130d..425bede 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -7,7 +7,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../account/model.dart'; import '../events/app_event_bus.dart'; -import '../l10n/app_localizations.g.dart'; +import '../localization/app_localizations.g.dart'; import '../locator.dart'; import '../models/async_mime_source.dart'; import '../models/async_mime_source_factory.dart'; diff --git a/lib/services/providers.dart b/lib/services/providers.dart index a4cda8c..1466686 100644 --- a/lib/services/providers.dart +++ b/lib/services/providers.dart @@ -1,13 +1,13 @@ import 'package:enough_mail/discover.dart'; -import '../l10n/extension.dart'; -import '../oauth/oauth.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -class ProviderService { +import '../localization/extension.dart'; +import '../oauth/oauth.dart'; +class ProviderService { ProviderService() { addAll([ GmailProvider(), @@ -74,7 +74,6 @@ class ProviderService { } class Provider { - const Provider( this.key, this.incomingHostName, @@ -84,6 +83,7 @@ class Provider { this.manualImapAccessSetupUrl, this.domains, }); + /// The key of the provider, help to resolves image resources and possibly other settings like branding guidelines final String key; final String incomingHostName; diff --git a/lib/settings/provider.dart b/lib/settings/provider.dart index 698eb60..1e4eb93 100644 --- a/lib/settings/provider.dart +++ b/lib/settings/provider.dart @@ -47,13 +47,18 @@ class SettingsNotifier extends Notifier { } } - /// Retrieves the HTML signature for the specified [account] and [composeAction] - String getSignatureHtml(RealAccount account, ComposeAction composeAction) { + /// Retrieves the HTML signature for the specified [account] + /// and [composeAction] + String getSignatureHtml( + RealAccount account, + ComposeAction composeAction, + String? languageCode, + ) { if (!state.signatureActions.contains(composeAction)) { return ''; } - return account.signatureHtml ?? getSignatureHtmlGlobal(); + return account.getSignatureHtml(languageCode) ?? getSignatureHtmlGlobal(); } /// Retrieves the global signature diff --git a/lib/settings/view/settings_accounts_screen.dart b/lib/settings/view/settings_accounts_screen.dart index ef6255e..36597d1 100644 --- a/lib/settings/view/settings_accounts_screen.dart +++ b/lib/settings/view/settings_accounts_screen.dart @@ -7,8 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/model.dart'; import '../../account/providers.dart'; -import '../../l10n/app_localizations.g.dart'; -import '../../l10n/extension.dart'; +import '../../localization/app_localizations.g.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../routes.dart'; import '../../screens/base.dart'; diff --git a/lib/settings/view/settings_default_sender_screen.dart b/lib/settings/view/settings_default_sender_screen.dart index 80bcee6..fde5ef7 100644 --- a/lib/settings/view/settings_default_sender_screen.dart +++ b/lib/settings/view/settings_default_sender_screen.dart @@ -3,7 +3,7 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../routes.dart'; import '../../screens/base.dart'; diff --git a/lib/settings/view/settings_developer_mode_screen.dart b/lib/settings/view/settings_developer_mode_screen.dart index 7310c68..8881c07 100644 --- a/lib/settings/view/settings_developer_mode_screen.dart +++ b/lib/settings/view/settings_developer_mode_screen.dart @@ -6,7 +6,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../account/model.dart'; import '../../extensions/extensions.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../screens/base.dart'; import '../../services/mail_service.dart'; diff --git a/lib/settings/view/settings_feedback_screen.dart b/lib/settings/view/settings_feedback_screen.dart index a2683fd..f189701 100644 --- a/lib/settings/view/settings_feedback_screen.dart +++ b/lib/settings/view/settings_feedback_screen.dart @@ -8,7 +8,7 @@ import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../screens/base.dart'; import '../../services/scaffold_messenger_service.dart'; diff --git a/lib/settings/view/settings_folders_screen.dart b/lib/settings/view/settings_folders_screen.dart index 3df38fc..815d4fe 100644 --- a/lib/settings/view/settings_folders_screen.dart +++ b/lib/settings/view/settings_folders_screen.dart @@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/model.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../models/models.dart'; import '../../screens/base.dart'; @@ -289,7 +289,6 @@ class _FolderManagementState extends State { } class MailboxWidget extends StatelessWidget { - const MailboxWidget( {super.key, required this.mailbox, diff --git a/lib/settings/view/settings_language_screen.dart b/lib/settings/view/settings_language_screen.dart index 11f3c74..80a01fa 100644 --- a/lib/settings/view/settings_language_screen.dart +++ b/lib/settings/view/settings_language_screen.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../l10n/app_localizations.g.dart'; -import '../../l10n/extension.dart'; +import '../../localization/app_localizations.g.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../screens/base.dart'; import '../../services/i18n_service.dart'; diff --git a/lib/settings/view/settings_readreceipts_screen.dart b/lib/settings/view/settings_readreceipts_screen.dart index 891d426..c55b927 100644 --- a/lib/settings/view/settings_readreceipts_screen.dart +++ b/lib/settings/view/settings_readreceipts_screen.dart @@ -2,7 +2,7 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../screens/base.dart'; import '../model.dart'; import '../provider.dart'; diff --git a/lib/settings/view/settings_reply_screen.dart b/lib/settings/view/settings_reply_screen.dart index 0110b86..109c37a 100644 --- a/lib/settings/view/settings_reply_screen.dart +++ b/lib/settings/view/settings_reply_screen.dart @@ -2,7 +2,7 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../screens/base.dart'; import '../model.dart'; import '../provider.dart'; diff --git a/lib/settings/view/settings_screen.dart b/lib/settings/view/settings_screen.dart index ac16265..6fe280c 100644 --- a/lib/settings/view/settings_screen.dart +++ b/lib/settings/view/settings_screen.dart @@ -1,7 +1,7 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../routes.dart'; import '../../screens/base.dart'; diff --git a/lib/settings/view/settings_security_screen.dart b/lib/settings/view/settings_security_screen.dart index 133414c..3b9a6d5 100644 --- a/lib/settings/view/settings_security_screen.dart +++ b/lib/settings/view/settings_security_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../screens/base.dart'; import '../../services/biometrics_service.dart'; diff --git a/lib/settings/view/settings_signature_screen.dart b/lib/settings/view/settings_signature_screen.dart index 2e06837..44b2b6a 100644 --- a/lib/settings/view/settings_signature_screen.dart +++ b/lib/settings/view/settings_signature_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/model.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../models/compose_data.dart'; import '../../routes.dart'; @@ -39,7 +39,9 @@ class SettingsSignatureScreen extends HookConsumerWidget { final accounts = locator().accounts; final accountsWithSignature = List.from( accounts.where( - (account) => account is RealAccount && account.signatureHtml != null, + (account) => + account is RealAccount && + account.getSignatureHtml(localizations.localeName) != null, ), ); String getActionName(ComposeAction action) { diff --git a/lib/settings/view/settings_swipe_screen.dart b/lib/settings/view/settings_swipe_screen.dart index e365ec5..3300921 100644 --- a/lib/settings/view/settings_swipe_screen.dart +++ b/lib/settings/view/settings_swipe_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../locator.dart'; import '../../models/swipe.dart'; import '../../screens/base.dart'; diff --git a/lib/settings/view/settings_theme_screen.dart b/lib/settings/view/settings_theme_screen.dart index e0da14f..b435523 100644 --- a/lib/settings/view/settings_theme_screen.dart +++ b/lib/settings/view/settings_theme_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../l10n/extension.dart'; +import '../../localization/extension.dart'; import '../../screens/base.dart'; import '../../util/localized_dialog_helper.dart'; import '../../widgets/button_text.dart'; diff --git a/lib/util/localized_dialog_helper.dart b/lib/util/localized_dialog_helper.dart index e566bf0..8353760 100644 --- a/lib/util/localized_dialog_helper.dart +++ b/lib/util/localized_dialog_helper.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../widgets/button_text.dart'; import '../widgets/legalese.dart'; diff --git a/lib/widgets/account_provider_selector.dart b/lib/widgets/account_provider_selector.dart index 40c028a..b9e0a43 100644 --- a/lib/widgets/account_provider_selector.dart +++ b/lib/widgets/account_provider_selector.dart @@ -1,9 +1,10 @@ -import '../l10n/extension.dart'; -import '../locator.dart'; -import '../services/providers.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import '../localization/extension.dart'; +import '../locator.dart'; +import '../services/providers.dart'; + class AccountProviderSelector extends StatelessWidget { const AccountProviderSelector({super.key, required this.onSelected}); final void Function(Provider? provider) onSelected; diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 9500009..d3dd705 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -9,8 +9,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; import '../account/providers.dart'; import '../extensions/extension_action_tile.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../routes.dart'; import '../services/icon_service.dart'; diff --git a/lib/widgets/attachment_chip.dart b/lib/widgets/attachment_chip.dart index d975753..ebc013b 100644 --- a/lib/widgets/attachment_chip.dart +++ b/lib/widgets/attachment_chip.dart @@ -4,7 +4,7 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/message.dart'; import '../routes.dart'; diff --git a/lib/widgets/attachment_compose_bar.dart b/lib/widgets/attachment_compose_bar.dart index 204dab8..bab3fea 100644 --- a/lib/widgets/attachment_compose_bar.dart +++ b/lib/widgets/attachment_compose_bar.dart @@ -7,13 +7,12 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; import '../routes.dart'; -import '../services/i18n_service.dart'; import '../services/icon_service.dart'; import '../services/key_service.dart'; import '../services/navigation_service.dart'; @@ -210,6 +209,7 @@ class AddAttachmentPopupButton extends StatelessWidget { composeData.messageBuilder .addBinary(file.bytes!, mediaType, filename: file.name); } + return true; } @@ -234,7 +234,7 @@ class AddAttachmentPopupButton extends StatelessWidget { // searchLabelText: searchSticker // ? localizations.attachTypeStickerSearch // : localizations.attachTypeGifSearch, - lang: locator().locale!.languageCode, + lang: localizations.localeName, keepState: true, showPreview: true, // sticker: searchSticker, @@ -287,7 +287,7 @@ class _AppointmentFinalizer { void finalize(MessageBuilder messageBuilder) { final event = appointment.event!; - if (messageBuilder.from?.isNotEmpty == true) { + if (messageBuilder.from?.isNotEmpty ?? false) { final organizer = messageBuilder.from!.first; event.organizer = OrganizerProperty.create( email: organizer.email, diff --git a/lib/widgets/editor_extensions.dart b/lib/widgets/editor_extensions.dart index f5a7b02..6124a5f 100644 --- a/lib/widgets/editor_extensions.dart +++ b/lib/widgets/editor_extensions.dart @@ -1,14 +1,14 @@ import 'package:community_material_icon/community_material_icon.dart'; import 'package:enough_ascii_art/enough_ascii_art.dart'; import 'package:enough_html_editor/enough_html_editor.dart'; -import '../l10n/extension.dart'; -import '../services/navigation_service.dart'; -import '../util/localized_dialog_helper.dart'; -import 'button_text.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import '../localization/extension.dart'; import '../locator.dart'; +import '../services/navigation_service.dart'; +import '../util/localized_dialog_helper.dart'; +import 'button_text.dart'; class EditorArtExtensionButton extends StatelessWidget { const EditorArtExtensionButton({super.key, required this.editorApi}); @@ -16,9 +16,9 @@ class EditorArtExtensionButton extends StatelessWidget { @override Widget build(BuildContext context) => PlatformIconButton( - icon: const Icon(CommunityMaterialIcons.format_font), - onPressed: () => showArtExtensionDialog(context, editorApi), - ); + icon: const Icon(CommunityMaterialIcons.format_font), + onPressed: () => showArtExtensionDialog(context, editorApi), + ); static void showArtExtensionDialog( BuildContext context, HtmlEditorApi editorApi) { diff --git a/lib/widgets/ical_composer.dart b/lib/widgets/ical_composer.dart index 18191a0..f8f1ebf 100644 --- a/lib/widgets/ical_composer.dart +++ b/lib/widgets/ical_composer.dart @@ -3,8 +3,8 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import '../account/model.dart'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../services/i18n_service.dart'; import '../services/mail_service.dart'; @@ -616,7 +616,8 @@ class _WeekDaySelectorState extends State { final day = ((firstDayOfWeek + i) <= 7) ? (firstDayOfWeek + i) : ((firstDayOfWeek + i) - 7); - final bool isSelected = byWeekDays.any((dayRule) => dayRule.weekday == day); + final bool isSelected = + byWeekDays.any((dayRule) => dayRule.weekday == day); _selectedDays[i] = isSelected; } } @@ -659,17 +660,17 @@ class _WeekDaySelectorState extends State { @override Widget build(BuildContext context) => FittedBox( - child: PlatformToggleButtons( - isSelected: _selectedDays, - onPressed: _toggle, - children: _weekdays - .map((day) => Padding( - padding: const EdgeInsets.all(8), - child: Text(day.name), - )) - .toList(), - ), - ); + child: PlatformToggleButtons( + isSelected: _selectedDays, + onPressed: _toggle, + children: _weekdays + .map((day) => Padding( + padding: const EdgeInsets.all(8), + child: Text(day.name), + )) + .toList(), + ), + ); } enum _DayOfMonthOption { dayOfMonth, dayInNumberedWeek } diff --git a/lib/widgets/ical_interactive_media.dart b/lib/widgets/ical_interactive_media.dart index f6c8b90..be1a318 100644 --- a/lib/widgets/ical_interactive_media.dart +++ b/lib/widgets/ical_interactive_media.dart @@ -3,14 +3,6 @@ import 'dart:convert'; import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_icalendar/enough_icalendar.dart'; import 'package:enough_icalendar_export/enough_icalendar_export.dart'; -import '../l10n/extension.dart'; -import '../locator.dart'; -import '../models/message.dart'; -import '../services/i18n_service.dart'; -import '../services/scaffold_messenger_service.dart'; -import '../util/localized_dialog_helper.dart'; -import 'mail_address_chip.dart'; -import 'text_with_links.dart'; import 'package:enough_mail_flutter/enough_mail_flutter.dart'; import 'package:enough_mail_icalendar/enough_mail_icalendar.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; @@ -18,7 +10,15 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../l10n/app_localizations.g.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../locator.dart'; +import '../models/message.dart'; +import '../services/i18n_service.dart'; +import '../services/scaffold_messenger_service.dart'; +import '../util/localized_dialog_helper.dart'; +import 'mail_address_chip.dart'; +import 'text_with_links.dart'; class IcalInteractiveMedia extends StatefulWidget { const IcalInteractiveMedia( @@ -285,32 +285,34 @@ class _IcalInteractiveMediaState extends State { padding: const EdgeInsets.all(8), child: icon, ), - if (address != null) MailAddressChip(mailAddress: address) else Expanded( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (name != null) - Text( - name, - style: textStyle, - ), - Padding( - padding: - const EdgeInsets.symmetric( - vertical: 4), - child: Text( - attendee.email ?? - attendee.uri.toString(), - style: textStyle, - ), - ), - ], + if (address != null) + MailAddressChip(mailAddress: address) + else + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (name != null) + Text( + name, + style: textStyle, + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 4), + child: Text( + attendee.email ?? + attendee.uri.toString(), + style: textStyle, + ), ), - ), + ], ), + ), + ), ], ); }).toList(), @@ -364,7 +366,9 @@ class _IcalInteractiveMediaState extends State { if (kDebugMode) { print('Unable to send status update: $e $s'); } - await LocalizedDialogHelper.showTextDialog(context, localizations.errorTitle, + await LocalizedDialogHelper.showTextDialog( + context, + localizations.errorTitle, localizations.icalendarParticipantStatusSentFailure(e.toString())); } } diff --git a/lib/widgets/legalese.dart b/lib/widgets/legalese.dart index 2189f4a..2fb5ae1 100644 --- a/lib/widgets/legalese.dart +++ b/lib/widgets/legalese.dart @@ -1,7 +1,8 @@ -import '../l10n/extension.dart'; -import 'text_with_links.dart'; import 'package:flutter/material.dart'; +import '../localization/extension.dart'; +import 'text_with_links.dart'; + class Legalese extends StatelessWidget { const Legalese({super.key}); static const String urlPrivacyPolicy = diff --git a/lib/widgets/mail_address_chip.dart b/lib/widgets/mail_address_chip.dart index 1c445cc..5f46206 100644 --- a/lib/widgets/mail_address_chip.dart +++ b/lib/widgets/mail_address_chip.dart @@ -1,15 +1,15 @@ import 'package:enough_mail/enough_mail.dart'; -import '../l10n/extension.dart'; -import '../models/compose_data.dart'; -import '../routes.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; -import '../services/scaffold_messenger_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../localization/extension.dart'; import '../locator.dart'; +import '../models/compose_data.dart'; +import '../routes.dart'; +import '../services/mail_service.dart'; +import '../services/navigation_service.dart'; +import '../services/scaffold_messenger_service.dart'; import 'icon_text.dart'; class MailAddressChip extends StatelessWidget { diff --git a/lib/widgets/message_actions.dart b/lib/widgets/message_actions.dart index 4521897..e28028c 100644 --- a/lib/widgets/message_actions.dart +++ b/lib/widgets/message_actions.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; diff --git a/lib/widgets/message_overview_content.dart b/lib/widgets/message_overview_content.dart index ccc81e4..31a786a 100644 --- a/lib/widgets/message_overview_content.dart +++ b/lib/widgets/message_overview_content.dart @@ -1,15 +1,14 @@ import 'package:enough_mail/enough_mail.dart'; -import '../l10n/extension.dart'; -import '../models/message.dart'; -import '../services/i18n_service.dart'; -import '../services/icon_service.dart'; import 'package:flutter/material.dart'; -import '../l10n/app_localizations.g.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; +import '../models/message.dart'; +import '../services/i18n_service.dart'; +import '../services/icon_service.dart'; class MessageOverviewContent extends StatelessWidget { - const MessageOverviewContent({ super.key, required this.message, diff --git a/lib/widgets/recipient_input_field.dart b/lib/widgets/recipient_input_field.dart index 6222507..c6766fe 100644 --- a/lib/widgets/recipient_input_field.dart +++ b/lib/widgets/recipient_input_field.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttercontactpicker/fluttercontactpicker.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../models/contact.dart'; import '../util/validator.dart'; import 'icon_text.dart'; @@ -142,35 +142,37 @@ class _RecipientInputFieldState extends State { ); } - Widget buildInput(ThemeData theme, BuildContext context) => RawAutocomplete( - focusNode: _focusNode, - textEditingController: _controller, - optionsBuilder: (textEditingValue) { - final search = textEditingValue.text.toLowerCase(); - if (search.length < 2) { - return []; - } - if (search.endsWith(' ') || - search.endsWith(';') || - search.endsWith(';')) { - // check if this is a complete email address - final email = textEditingValue.text.substring(0, search.length - 1); - checkEmail(email); - } - final contactManager = widget.contactManager; - if (contactManager == null) { - return []; - } - final matches = contactManager.find(search).toList(); - // do not suggest recipients that are already added: - for (final existing in widget.addresses) { - matches.remove(existing); - } - return matches; - }, - displayStringForOption: (option) => option.toString(), - fieldViewBuilder: - (context, textEditingController, focusNode, onFieldSubmitted) => DecoratedPlatformTextField( + Widget buildInput(ThemeData theme, BuildContext context) => + RawAutocomplete( + focusNode: _focusNode, + textEditingController: _controller, + optionsBuilder: (textEditingValue) { + final search = textEditingValue.text.toLowerCase(); + if (search.length < 2) { + return []; + } + if (search.endsWith(' ') || + search.endsWith(';') || + search.endsWith(';')) { + // check if this is a complete email address + final email = textEditingValue.text.substring(0, search.length - 1); + checkEmail(email); + } + final contactManager = widget.contactManager; + if (contactManager == null) { + return []; + } + final matches = contactManager.find(search).toList(); + // do not suggest recipients that are already added: + for (final existing in widget.addresses) { + matches.remove(existing); + } + return matches; + }, + displayStringForOption: (option) => option.toString(), + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) => + DecoratedPlatformTextField( controller: textEditingController, focusNode: focusNode, autofocus: widget.autofocus, @@ -199,7 +201,7 @@ class _RecipientInputFieldState extends State { ), ), ), - optionsViewBuilder: (context, onSelected, options) => Material( + optionsViewBuilder: (context, onSelected, options) => Material( child: Align( alignment: Alignment.topLeft, child: ConstrainedBox( @@ -238,7 +240,7 @@ class _RecipientInputFieldState extends State { ), ), ), - ); + ); void checkEmail(String input) { if (Validator.validateEmail(input)) { @@ -251,8 +253,7 @@ class _RecipientInputFieldState extends State { Future _pickContact(TextEditingController controller) async { try { - final contact = - await FlutterContactPicker.pickEmailContact(); + final contact = await FlutterContactPicker.pickEmailContact(); widget.addresses.add( MailAddress( contact.fullName, diff --git a/lib/widgets/search_text_field.dart b/lib/widgets/search_text_field.dart index 9fa0f63..7a56669 100644 --- a/lib/widgets/search_text_field.dart +++ b/lib/widgets/search_text_field.dart @@ -1,13 +1,13 @@ import 'package:enough_mail/enough_mail.dart'; -import '../l10n/extension.dart'; -import '../models/message_source.dart'; -import '../routes.dart'; -import '../services/navigation_service.dart'; import 'package:enough_platform_widgets/cupertino.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import '../localization/extension.dart'; import '../locator.dart'; +import '../models/message_source.dart'; +import '../routes.dart'; +import '../services/navigation_service.dart'; /// A dedicated search field optimized for Cupertino class CupertinoSearch extends StatelessWidget { diff --git a/lib/widgets/signature.dart b/lib/widgets/signature.dart index 79f5e92..421a093 100644 --- a/lib/widgets/signature.dart +++ b/lib/widgets/signature.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; import '../account/model.dart'; -import '../l10n/extension.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../services/icon_service.dart'; import '../services/mail_service.dart'; @@ -22,7 +22,7 @@ class SignatureWidget extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final account = this.account; final signatureState = useState( - account?.signatureHtml ?? + account?.getSignatureHtml(context.text.localeName) ?? ref.read(settingsProvider.notifier).getSignatureHtmlGlobal(), ); final signature = signatureState.value; @@ -68,9 +68,9 @@ class SignatureWidget extends HookConsumerWidget { ), ], ); - - if (result && editorApi != null) { - final newSignature = await editorApi!.getText(); + final usedEditorApi = editorApi; + if (result && usedEditorApi != null) { + final newSignature = await usedEditorApi.getText(); signatureState.value = newSignature; if (account == null) { final settings = ref.read(settingsProvider); From dd339d65f9c9879ffceef63ec18fb747c5a5d6e2 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Sun, 8 Oct 2023 20:52:50 +0200 Subject: [PATCH 03/95] feat: access the account via the message source --- .vscode/settings.json | 1 + lib/account/model.dart | 4 +- lib/account/model.g.dart | 7 +++- lib/account/providers.dart | 14 ------- lib/account/providers.g.dart | 20 +--------- lib/mail/provider.dart | 3 +- lib/mail/provider.g.dart | 2 +- lib/main.dart | 2 - lib/models/message.dart | 5 ++- lib/models/message_source.dart | 30 ++++++++++++++- lib/screens/media_screen.dart | 5 ++- lib/screens/message_source_screen.dart | 27 +++----------- lib/services/mail_service.dart | 2 + lib/services/notification_service.dart | 16 +++++--- lib/widgets/app_drawer.dart | 39 ++++++++++++++------ lib/widgets/new_mail_message_button.dart | 32 ++++++++++++++++ lib/widgets/widgets.dart | 1 + pubspec.yaml | 1 + test/model/multiple_message_source_test.dart | 16 +++++++- 19 files changed, 142 insertions(+), 85 deletions(-) create mode 100644 lib/widgets/new_mail_message_button.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 33de042..51236ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "Imap", "LTRB", "Maily", + "mocktail", "riverpod", "unawaited" ] diff --git a/lib/account/model.dart b/lib/account/model.dart index 3d145b2..70bd1af 100644 --- a/lib/account/model.dart +++ b/lib/account/model.dart @@ -74,7 +74,7 @@ class RealAccount extends Account { /// Should this account be excluded from the unified account? bool get excludeFromUnified => - _account.hasAttribute(attributeExcludeFromUnified); + getAttribute(attributeExcludeFromUnified) ?? false; set excludeFromUnified(bool value) => setAttribute(attributeExcludeFromUnified, value); @@ -84,7 +84,7 @@ class RealAccount extends Account { set enableLogging(bool value) => setAttribute(attributeEnableLogging, value); /// Retrieves the attribute with the given [key] name - dynamic getAttribute(String key) => _account.attributes[key]; + T? getAttribute(String key) => _account.attributes[key] as T?; /// Sets the attribute [key] to [value] void setAttribute(String key, dynamic value) { diff --git a/lib/account/model.g.dart b/lib/account/model.g.dart index 806f9fc..37f357b 100644 --- a/lib/account/model.g.dart +++ b/lib/account/model.g.dart @@ -11,11 +11,16 @@ RealAccount _$RealAccountFromJson(Map json) => RealAccount( appExtensions: (json['appExtensions'] as List?) ?.map((e) => AppExtension.fromJson(e as Map)) .toList(), - )..excludeFromUnified = json['excludeFromUnified'] as bool; + ) + ..excludeFromUnified = json['excludeFromUnified'] as bool + ..signaturePlain = json['signaturePlain'] as String? + ..userName = json['userName'] as String?; Map _$RealAccountToJson(RealAccount instance) => { 'mailAccount': instance.mailAccount, 'excludeFromUnified': instance.excludeFromUnified, + 'signaturePlain': instance.signaturePlain, + 'userName': instance.userName, 'appExtensions': instance.appExtensions, }; diff --git a/lib/account/providers.dart b/lib/account/providers.dart index 0c28d6f..9650a15 100644 --- a/lib/account/providers.dart +++ b/lib/account/providers.dart @@ -5,20 +5,6 @@ import 'storage.dart'; part 'providers.g.dart'; -/// Retrieves the current account -@riverpod -class CurrentAccount extends _$CurrentAccount { - @override - Raw? build() { - final accounts = ref.watch(allAccountsProvider); - if (accounts.isEmpty) { - return null; - } - - return accounts.first; - } -} - /// Provides all real email accounts @Riverpod(keepAlive: true) class RealAccounts extends _$RealAccounts { diff --git a/lib/account/providers.g.dart b/lib/account/providers.g.dart index 3151aa8..aee8413 100644 --- a/lib/account/providers.g.dart +++ b/lib/account/providers.g.dart @@ -23,25 +23,7 @@ final unifiedAccountProvider = Provider.internal( ); typedef UnifiedAccountRef = ProviderRef; -String _$currentAccountHash() => r'247e43dde286f389677a2b1b299ddbf7099a9577'; - -/// Retrieves the current account -/// -/// Copied from [CurrentAccount]. -@ProviderFor(CurrentAccount) -final currentAccountProvider = - AutoDisposeNotifierProvider.internal( - CurrentAccount.new, - name: r'currentAccountProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$currentAccountHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef _$CurrentAccount = AutoDisposeNotifier; -String _$realAccountsHash() => r'af80cd3883b4dae338d70ab5a8b8c00b94bc6024'; +String _$realAccountsHash() => r'3ff51534497e5e36a7b0e0f19dc0e5fb09cfdcfe'; /// Provides all real email accounts /// diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index 640ab4c..dbcdf23 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -48,12 +48,13 @@ class Source extends _$Source { mailClient, mailbox, ); //..addSubscriber(this); - // TODO(RV): add subscriber + // TODO(RV): add subscriber to send notification for unseen inbox mails return MailboxMessageSource.fromMimeSource( source, mailClient.account.email, mailbox.name, + account: account, ); } diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart index de38de1..e0cfb2c 100644 --- a/lib/mail/provider.g.dart +++ b/lib/mail/provider.g.dart @@ -6,7 +6,7 @@ part of 'provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$sourceHash() => r'5ca461e75566218bc833dcfd15f48e321a35ab69'; +String _$sourceHash() => r'31611430840b7466b5536360064e85929183dfdf'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/main.dart b/lib/main.dart index bb8437c..d2be699 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -96,7 +96,6 @@ class InitializationScreen extends ConsumerStatefulWidget { class _InitializationScreen extends ConsumerState { late Future _appInitialization; - bool _isInitialized = false; @override void initState() { @@ -171,7 +170,6 @@ class _InitializationScreen extends ConsumerState { // } logger.d('App initialized'); - _isInitialized = true; } @override diff --git a/lib/models/message.dart b/lib/models/message.dart index cfbfb75..fa39d7e 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -13,7 +13,10 @@ class Message extends ChangeNotifier { Message.embedded(this.mimeMessage, Message parent) : mailClient = parent.mailClient, - source = SingleMessageSource(parent.source), + source = SingleMessageSource( + parent.source, + account: parent.source.account, + ), sourceIndex = 0 { (source as SingleMessageSource).singleMessage = this; isEmbedded = true; diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index bff5120..db1e59e 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -39,6 +39,7 @@ abstract class MessageSource extends ChangeNotifier /// the description of this source String? get description => _description; + set description(String? value) { _description = value; notifyListeners(); @@ -53,6 +54,9 @@ abstract class MessageSource extends ChangeNotifier notifyListeners(); } + /// The account associated with this source + Account get account; + bool _supportsDeleteAll = false; /// Does this source support to delete all messages? @@ -569,6 +573,7 @@ class MailboxMessageSource extends MessageSource { this._mimeSource, String description, String name, { + required this.account, super.parent, super.isSearch, }) { @@ -577,6 +582,9 @@ class MailboxMessageSource extends MessageSource { _mimeSource.addSubscriber(this); } + @override + final RealAccount account; + @override int get size => _mimeSource.size; @@ -660,6 +668,7 @@ class MailboxMessageSource extends MessageSource { searchSource, localizations.searchQueryDescription(name!), localizations.searchQueryTitle(search.query), + account: account, parent: this, isSearch: true, ); @@ -695,6 +704,7 @@ class MultipleMessageSource extends MessageSource { this.mimeSources, String name, MailboxFlag? flag, { + required this.account, super.parent, super.isSearch, }) { @@ -707,6 +717,9 @@ class MultipleMessageSource extends MessageSource { _description = mimeSources.map((s) => s.mailClient.account.name).join(', '); } + @override + final UnifiedAccount account; + @override Future init() async { final futures = mimeSources.map((source) => source.init()); @@ -880,6 +893,7 @@ class MultipleMessageSource extends MessageSource { searchMimeSources, localizations.searchQueryTitle(search.query), _flag, + account: account, parent: this, isSearch: true, ); @@ -1013,9 +1027,13 @@ class _MultipleMimeSource { } class SingleMessageSource extends MessageSource { - SingleMessageSource(MessageSource? parent) : super(parent: parent); + SingleMessageSource(MessageSource? parent, {required this.account}) + : super(parent: parent); Message? singleMessage; + @override + final Account account; + @override Future loadMessage(int index) => Future.value(singleMessage); @@ -1076,9 +1094,16 @@ class SingleMessageSource extends MessageSource { } class ListMessageSource extends MessageSource { - ListMessageSource(MessageSource parent) : super(parent: parent); + ListMessageSource( + MessageSource parent, + ) : account = parent.account, + super(parent: parent); + late List messages; + @override + final Account account; + void initWithMimeMessages( List mimeMessages, MailClient mailClient, {bool reverse = true}) { @@ -1151,6 +1176,7 @@ class ListMessageSource extends MessageSource { class ErrorMessageSource extends MessageSource { ErrorMessageSource(this.account); + @override final Account account; @override diff --git a/lib/screens/media_screen.dart b/lib/screens/media_screen.dart index fe7ddb8..32dd6a1 100644 --- a/lib/screens/media_screen.dart +++ b/lib/screens/media_screen.dart @@ -70,8 +70,9 @@ class InteractiveMediaScreen extends ConsumerWidget { final account = mailService.currentAccount; if (account is RealAccount) { final client = await mailService.getClientFor(account); - final source = - SingleMessageSource(mailService.messageSource); + final source = SingleMessageSource( + mailService.messageSource, + account: account); final message = Message(mime, client, source, 0); message.isEmbedded = true; source.singleMessage = message; diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index bd001f4..a56e280 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -24,15 +24,8 @@ import '../services/scaffold_messenger_service.dart'; import '../settings/provider.dart'; import '../util/localized_dialog_helper.dart'; import '../util/string_helper.dart'; -import '../widgets/app_drawer.dart'; -import '../widgets/cupertino_status_bar.dart'; -import '../widgets/empty_message.dart'; -import '../widgets/icon_text.dart'; -import '../widgets/mailbox_tree.dart'; -import '../widgets/menu_with_badge.dart'; -import '../widgets/message_overview_content.dart'; -import '../widgets/message_stack.dart'; import '../widgets/search_text_field.dart'; +import '../widgets/widgets.dart'; import 'base.dart'; enum _Visualization { stack, list } @@ -330,22 +323,12 @@ class _MessageSourceScreenState extends ConsumerState ) : null, material: (context, platform) => MaterialScaffoldData( - drawer: const AppDrawer(), + drawer: AppDrawer( + currentAccount: widget.messageSource.account, + ), floatingActionButton: _visualization == _Visualization.stack ? null - : FloatingActionButton( - onPressed: () => locator().push( - Routes.mailCompose, - arguments: ComposeData( - null, - MessageBuilder(), - ComposeAction.newMessage, - ), - ), - tooltip: localizations.homeFabTooltip, - elevation: 2, - child: const Icon(Icons.add), - ), + : const NewMailMessageButton(), ), // cupertino: (context, platform) => CupertinoPageScaffoldData(), appBar: (_visualization == _Visualization.stack) diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index 425bede..3960b07 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -178,6 +178,7 @@ class MailService implements MimeSourceSubscriber { if (account is UnifiedAccount) { final mimeSources = await _getUnifiedMimeSources(mailbox, account); return MultipleMessageSource( + account: account, mimeSources, mailbox == null ? _localizations.unifiedFolderInbox : mailbox.name, mailbox?.flags.first ?? MailboxFlag.inbox, @@ -197,6 +198,7 @@ class MailService implements MimeSourceSubscriber { source, mailClient.account.email, mailbox.name, + account: account, ); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 8c17818..ffd053a 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../account/model.dart'; import '../locator.dart'; import '../models/message.dart' as maily; import '../models/message_source.dart'; @@ -104,7 +105,7 @@ class NotificationService { if (kDebugMode) { print('select notification: $payloadText'); } - if (payloadText!.startsWith(_messagePayloadStart)) { + if (payloadText != null && payloadText.startsWith(_messagePayloadStart)) { try { final payload = _deserialize(payloadText); @@ -119,7 +120,11 @@ class NotificationService { ..uid = payload.uid ..size = payload.size; final currentMessageSource = locator().messageSource; - final messageSource = SingleMessageSource(currentMessageSource); + final messageSource = SingleMessageSource( + currentMessageSource, + account: + currentMessageSource?.account ?? RealAccount(mailClient.account), + ); final message = maily.Message(mimeMessage, mailClient, messageSource, 0); messageSource.singleMessage = message; @@ -133,10 +138,11 @@ class NotificationService { } } - Future sendLocalNotificationForMailLoadEvent(MailLoadEvent event) => sendLocalNotificationForMail(event.message, event.mailClient); + Future sendLocalNotificationForMailLoadEvent(MailLoadEvent event) => + sendLocalNotificationForMail(event.message, event.mailClient); - Future sendLocalNotificationForMailMessage(maily.Message message) => sendLocalNotificationForMail( - message.mimeMessage, message.mailClient); + Future sendLocalNotificationForMailMessage(maily.Message message) => + sendLocalNotificationForMail(message.mimeMessage, message.mailClient); Future sendLocalNotificationForMail( MimeMessage mimeMessage, MailClient mailClient) { diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index d3dd705..5ccd597 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -42,7 +42,10 @@ class AppDrawer extends ConsumerWidget { child: Padding( padding: const EdgeInsets.all(8), child: _buildAccountHeader( - currentAccount, mailService.accounts, theme), + currentAccount, + mailService.accounts, + theme, + ), ), ), Expanded( @@ -107,6 +110,8 @@ class AppDrawer extends ConsumerWidget { : accounts.isNotEmpty ? accounts.first as RealAccount : null; + final avatarImageUrl = avatarAccount?.imageUrlGravatar; + final userName = currentAccount is RealAccount ? currentAccount.userName : null; final accountName = Text( @@ -133,9 +138,9 @@ class AppDrawer extends ConsumerWidget { children: [ CircleAvatar( backgroundColor: theme.secondaryHeaderColor, - backgroundImage: NetworkImage( - avatarAccount.imageUrlGravatar!, - ), + backgroundImage: avatarImageUrl == null + ? null + : NetworkImage(avatarImageUrl), radius: 30, ), const Padding( @@ -151,7 +156,9 @@ class AppDrawer extends ConsumerWidget { Text( userName, style: const TextStyle( - fontStyle: FontStyle.italic, fontSize: 14), + fontStyle: FontStyle.italic, + fontSize: 14, + ), ), Text( currentAccount is UnifiedAccount @@ -160,7 +167,9 @@ class AppDrawer extends ConsumerWidget { .join(', ') : (currentAccount as RealAccount).email, style: const TextStyle( - fontStyle: FontStyle.italic, fontSize: 14), + fontStyle: FontStyle.italic, + fontSize: 14, + ), ), ], ), @@ -181,8 +190,9 @@ class AppDrawer extends ConsumerWidget { ? ExpansionTile( leading: mailService.hasAccountsWithErrors() ? const Badge() : null, - title: Text(localizations - .drawerAccountsSectionTitle(mailService.accounts.length)), + title: Text( + localizations.drawerAccountsSectionTitle(accounts.length), + ), children: [ for (final account in accounts) SelectablePlatformListTile( @@ -191,7 +201,11 @@ class AppDrawer extends ConsumerWidget { : null, tileColor: mailService.hasError(account) ? Colors.red : null, - title: Text(account.name), + title: Text( + account is UnifiedAccount + ? localizations.unifiedAccountName + : account.name, + ), selected: account == currentAccount, onTap: () async { final navService = locator(); @@ -222,8 +236,11 @@ class AppDrawer extends ConsumerWidget { if (account is UnifiedAccount) { navService.push(Routes.settingsAccounts, fade: true); } else { - navService.push(Routes.accountEdit, - arguments: account, fade: true); + navService.push( + Routes.accountEdit, + arguments: account, + fade: true, + ); } }, ), diff --git a/lib/widgets/new_mail_message_button.dart b/lib/widgets/new_mail_message_button.dart new file mode 100644 index 0000000..3e22eb1 --- /dev/null +++ b/lib/widgets/new_mail_message_button.dart @@ -0,0 +1,32 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../localization/extension.dart'; +import '../models/compose_data.dart'; +import '../routes.dart'; + +/// Visualize a button to compose a new mail message +/// +/// This is done as a [FloatingActionButton] +class NewMailMessageButton extends StatelessWidget { + /// Creates a [NewMailMessageButton] + const NewMailMessageButton({ + super.key, + }); + + @override + Widget build(BuildContext context) => FloatingActionButton( + onPressed: () => context.push( + Routes.mailCompose, + extra: ComposeData( + null, + MessageBuilder(), + ComposeAction.newMessage, + ), + ), + tooltip: context.text.homeFabTooltip, + elevation: 2, + child: const Icon(Icons.add), + ); +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index f3c626c..284e32c 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -19,6 +19,7 @@ export 'menu_with_badge.dart'; export 'message_actions.dart'; export 'message_overview_content.dart'; export 'message_stack.dart'; +export 'new_mail_message_button.dart'; export 'password_field.dart'; export 'recipient_input_field.dart'; export 'signature.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index ff8007f..477f77a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -166,6 +166,7 @@ dev_dependencies: sdk: flutter hive_generator: ^2.0.0 json_serializable: ^6.3.1 + mocktail: ^1.0.0 riverpod_generator: ^2.3.2 riverpod_lint: ^2.1.0 diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index 8faa71e..3c91cfd 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -1,4 +1,5 @@ import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_mail_app/account/model.dart'; import 'package:enough_mail_app/models/async_mime_source.dart'; import 'package:enough_mail_app/models/message.dart'; import 'package:enough_mail_app/models/message_source.dart'; @@ -9,9 +10,12 @@ import 'package:flutter/src/material/scaffold.dart'; import 'package:flutter/src/widgets/framework.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; import 'fake_mime_source.dart'; +class MockUnifiedAccount extends Mock implements UnifiedAccount {} + void main() async { final notificationService = TestNotificationService(); GetIt.instance.registerSingleton(notificationService); @@ -40,7 +44,11 @@ void main() async { differencePerMessage: secondMimeSourceDifferencePerMessage, ); source = MultipleMessageSource( - [firstMimeSource, secondMimeSource], 'multiple', MailboxFlag.inbox); + [firstMimeSource, secondMimeSource], + 'multiple', + MailboxFlag.inbox, + account: MockUnifiedAccount(), + ); }); Future _expectMessagesOrderedByDate({int numberToTest = 20}) async { @@ -927,7 +935,11 @@ void main() async { differencePerMessage: secondMimeSourceDifferencePerMessage, ); source = MultipleMessageSource( - [firstMimeSource, secondMimeSource], 'multiple', MailboxFlag.inbox); + [firstMimeSource, secondMimeSource], + 'multiple', + MailboxFlag.inbox, + account: MockUnifiedAccount(), + ); var notifyCounter = 0; source.addListener(() { From 1de7412c478b3c546b2f24e3194cabec5b64d54c Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 9 Oct 2023 17:35:30 +0200 Subject: [PATCH 04/95] refactor: remove MailClient from Message --- lib/models/async_mime_source.dart | 170 +++++++++++-- lib/models/message.dart | 89 ++++--- lib/models/message_source.dart | 164 +++++++++---- lib/models/offline_mime_source.dart | 29 +++ lib/screens/media_screen.dart | 9 +- lib/screens/message_details_screen.dart | 133 +++++++---- lib/screens/message_source_screen.dart | 36 +-- lib/services/background_service.dart | 53 ++-- lib/services/mail_service.dart | 9 +- lib/services/notification_service.dart | 46 ++-- lib/widgets/attachment_chip.dart | 193 ++++++++------- lib/widgets/ical_interactive_media.dart | 209 ++++++++++------ lib/widgets/message_actions.dart | 98 ++++---- lib/widgets/message_stack.dart | 21 +- test/model/fake_mime_source.dart | 18 ++ test/model/multiple_message_source_test.dart | 239 +++++++++---------- 16 files changed, 971 insertions(+), 545 deletions(-) diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index e894c0a..613c4bf 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -120,6 +120,15 @@ abstract class AsyncMimeSource { Duration? responseTimeout, }); + /// Fetches a message part / attachment for the partial [mimeMessage]. + /// + /// Compare [MailClient]'s `fetchMessagePart()` call. + Future fetchMessagePart( + MimeMessage message, { + required String fetchId, + Duration? responseTimeout, + }); + /// Informs this source about a new incoming [message] at the optional [index]. /// /// Note this message does not necessarily match to this sources. @@ -186,6 +195,36 @@ abstract class AsyncMimeSource { subscriber.onMailCacheInvalidated(this); } } + + /// Sends the specified [message]. + /// + /// Use [MessageBuilder] to create new messages. + /// + /// Specify [from] as the originator in case it differs from the `From` + /// header of the message. + /// + /// Optionally set [appendToSent] to `false` in case the message should NOT + /// be appended to the SENT folder. + /// By default the message is appended. Note that some mail providers + /// automatically append sent messages to + /// the SENT folder, this is not detected by this API. + /// + /// You can also specify if the message should be sent using 8 bit encoding + /// with [use8BitEncoding], which default to `false`. + /// + /// Optionally specify the [recipients], in which case the recipients + /// defined in the message are ignored. + /// + /// Optionally specify the [sentMailbox] when the mail system does not + /// support mailbox flags. + Future sendMessage( + MimeMessage message, { + MailAddress? from, + bool appendToSent = true, + Mailbox? sentMailbox, + bool use8BitEncoding = false, + List? recipients, + }); } /// Keeps messages in a temporary cache @@ -464,6 +503,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { @override Future init() { _registerEvents(); + return mailClient.startPolling(); } @@ -505,7 +545,8 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { } Future _onMailReconnected( - MailConnectionReEstablishedEvent event) async { + MailConnectionReEstablishedEvent event, + ) async { if (event.mailClient == mailClient && event.isManualSynchronizationRequired) { final messages = await event.mailClient @@ -513,10 +554,13 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { if (messages.isEmpty) { if (kDebugMode) { print( - 'MESSAGES ARE EMPTY FOR ${event.mailClient.lowLevelOutgoingMailClient.logName}'); + 'MESSAGES ARE EMPTY FOR ' + '${event.mailClient.lowLevelOutgoingMailClient.logName}', + ); } - // since this is an unlikely outcome, the assumption is that this an error - // and resync will be aborted, therefore. + // since this is an unlikely outcome, the assumption is that this + // an error and resync will be aborted, therefore. + return; } await resyncMessagesManually(messages); @@ -527,6 +571,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { Future deleteMessages(List messages) { removeFromCache(messages); final sequence = MessageSequence.fromMessages(messages); + return mailClient.deleteMessages(sequence, messages: messages); } @@ -534,6 +579,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { Future undoDeleteMessages(DeleteResult deleteResult) async { final result = await mailClient.undoDeleteMessages(deleteResult); await _reAddMessages(result); + return result; } @@ -542,6 +588,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { clear(); final result = await mailClient.deleteAllMessages(mailbox, expunge: expunge); + return [result]; } @@ -552,6 +599,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { ) { removeFromCache(messages); final sequence = MessageSequence.fromMessages(messages); + return mailClient.moveMessages(sequence, targetMailbox, messages: messages); } @@ -562,14 +610,19 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { ) { removeFromCache(messages); final sequence = MessageSequence.fromMessages(messages); - return mailClient.moveMessagesToFlag(sequence, targetMailboxFlag, - messages: messages); + + return mailClient.moveMessagesToFlag( + sequence, + targetMailboxFlag, + messages: messages, + ); } @override Future undoMoveMessages(MoveResult moveResult) async { final result = await mailClient.undoMoveMessages(moveResult); await _reAddMessages(result); + return result; } @@ -589,6 +642,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { StoreAction action = StoreAction.add, }) { final sequence = MessageSequence.fromMessages(messages); + return mailClient.store(sequence, flags, action: action); } @@ -598,6 +652,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { StoreAction action = StoreAction.add, }) { final sequence = MessageSequence.fromAll(); + return mailClient.store(sequence, flags, action: action); } @@ -658,11 +713,43 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { List? includedInlineTypes, Duration? responseTimeout, }) => - mailClient.fetchMessageContents(message, - maxSize: maxSize, - markAsSeen: markAsSeen, - includedInlineTypes: includedInlineTypes, - responseTimeout: responseTimeout); + mailClient.fetchMessageContents( + message, + maxSize: maxSize, + markAsSeen: markAsSeen, + includedInlineTypes: includedInlineTypes, + responseTimeout: responseTimeout, + ); + + @override + Future fetchMessagePart( + MimeMessage message, { + required String fetchId, + Duration? responseTimeout, + }) => + mailClient.fetchMessagePart( + message, + fetchId, + responseTimeout: responseTimeout, + ); + + @override + Future sendMessage( + MimeMessage message, { + MailAddress? from, + bool appendToSent = true, + Mailbox? sentMailbox, + bool use8BitEncoding = false, + List? recipients, + }) => + mailClient.sendMessage( + message, + from: from, + appendToSent: appendToSent, + sentMailbox: sentMailbox, + use8BitEncoding: use8BitEncoding, + recipients: recipients, + ); } /// Accesses search results @@ -713,6 +800,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { final sequence = searchResult.pagedSequence.sequence; clear(); final deleteResult = await mailClient.deleteMessages(sequence); + return [deleteResult]; } @@ -752,6 +840,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { Future deleteMessages(List messages) async { final sequence = MessageSequence.fromMessages(messages); searchResult.removeMessageSequence(sequence); + return parent.deleteMessages(messages); } @@ -760,6 +849,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { // TODO(RV): add sequence back to search result - or rather // the sequence after undoing it //searchResult.addMessageSequence(deleteResult.originalSequence); + return parent.undoDeleteMessages(deleteResult); } @@ -778,6 +868,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { searchResult.addMessage(message); notifySubscriberOnMessageArrived(message); } + return Future.value(); } @@ -790,6 +881,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { existing.flags = message.flags; notifySubscribersOnMessageFlagsUpdated(existing); } + return Future.value(); } @@ -805,13 +897,18 @@ class AsyncSearchMimeSource extends AsyncMimeSource { } @override - Future store(List messages, List flags, - {StoreAction action = StoreAction.add}) => + Future store( + List messages, + List flags, { + StoreAction action = StoreAction.add, + }) => parent.store(messages, flags, action: action); @override - Future storeAll(List flags, - {StoreAction action = StoreAction.add}) async { + Future storeAll( + List flags, { + StoreAction action = StoreAction.add, + }) async { final sequence = searchResult.pagedSequence.sequence; if (sequence.isEmpty) { return Future.value(); @@ -826,6 +923,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { Future resyncMessagesManually(List messages) { // just redo the full search for now. notifySubscribersOnCacheInvalidated(); + return init(); } @@ -862,9 +960,41 @@ class AsyncSearchMimeSource extends AsyncMimeSource { List? includedInlineTypes, Duration? responseTimeout, }) => - mailClient.fetchMessageContents(message, - maxSize: maxSize, - markAsSeen: markAsSeen, - includedInlineTypes: includedInlineTypes, - responseTimeout: responseTimeout); + mailClient.fetchMessageContents( + message, + maxSize: maxSize, + markAsSeen: markAsSeen, + includedInlineTypes: includedInlineTypes, + responseTimeout: responseTimeout, + ); + + @override + Future fetchMessagePart( + MimeMessage message, { + required String fetchId, + Duration? responseTimeout, + }) => + mailClient.fetchMessagePart( + message, + fetchId, + responseTimeout: responseTimeout, + ); + + @override + Future sendMessage( + MimeMessage message, { + MailAddress? from, + bool appendToSent = true, + Mailbox? sentMailbox, + bool use8BitEncoding = false, + List? recipients, + }) => + mailClient.sendMessage( + message, + from: from, + appendToSent: appendToSent, + sentMailbox: sentMailbox, + use8BitEncoding: use8BitEncoding, + recipients: recipients, + ); } diff --git a/lib/models/message.dart b/lib/models/message.dart index fa39d7e..52bcdc3 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,19 +1,18 @@ +import 'package:collection/collection.dart'; import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart' as url_launcher; import '../account/model.dart'; -import '../locator.dart'; -import '../services/mail_service.dart'; +import '../logger.dart'; import 'message_source.dart'; class Message extends ChangeNotifier { - Message(this.mimeMessage, this.mailClient, this.source, this.sourceIndex); + Message(this.mimeMessage, this.source, this.sourceIndex); Message.embedded(this.mimeMessage, Message parent) - : mailClient = parent.mailClient, - source = SingleMessageSource( + : source = SingleMessageSource( parent.source, account: parent.source.account, ), @@ -25,7 +24,6 @@ class Message extends ChangeNotifier { static const String keywordFlagUnsubscribed = r'$Unsubscribed'; MimeMessage mimeMessage; - final MailClient mailClient; int sourceIndex; final MessageSource source; @@ -52,8 +50,7 @@ class Message extends ChangeNotifier { return infos; } - RealAccount get account => - locator().getAccountFor(mailClient.account)!; + Account get account => source.account; set isSelected(bool value) { if (value != _isSelected) { @@ -155,7 +152,7 @@ class Message extends ChangeNotifier { } @override - String toString() => '${mailClient.account.name}[$sourceIndex]=$mimeMessage'; + String toString() => '${account.name}[$sourceIndex]=$mimeMessage'; } extension NewsLetter on MimeMessage { @@ -168,9 +165,9 @@ extension NewsLetter on MimeMessage { bool get isNewsLetterSubscribable => hasHeader('list-subscribe'); /// Retrieves the List-Unsubscribe URIs, if present - List? decodeListUnsubscribeUris() => _decodeUris('list-unsubscribe'); + List? decodeListUnsubscribeUris() => _decodeUris('list-unsubscribe'); - List? decodeListSubscribeUris() => _decodeUris('list-subscribe'); + List? decodeListSubscribeUris() => _decodeUris('list-subscribe'); String? decodeListName() { final listPost = decodeHeaderValue('list-post'); @@ -195,13 +192,13 @@ extension NewsLetter on MimeMessage { return null; } - List? _decodeUris(final String name) { + List? _decodeUris(final String name) { final value = getHeaderValue(name); if (value == null) { return null; } //TODO allow comments in / before URIs, e.g. "(send a mail to unsubscribe) " - final uris = []; + final uris = []; final parts = value.split('>'); for (var part in parts) { part = part.trimLeft(); @@ -214,14 +211,13 @@ extension NewsLetter on MimeMessage { if (part.isNotEmpty) { final uri = Uri.tryParse(part); if (uri == null) { - if (kDebugMode) { - print('Invalid $name $value: unable to pars URI $part'); - } + logger.e('Invalid $name $value: unable to pars URI $part'); } else { uris.add(uri); } } } + return uris; } @@ -232,11 +228,13 @@ extension NewsLetter on MimeMessage { if (uris == null) { return false; } - final httpUri = uris.firstWhere( - (uri) => uri!.scheme.toLowerCase() == 'https', - orElse: () => uris.firstWhere( - (uri) => uri!.scheme.toLowerCase() == 'http', - orElse: () => null)); + final httpUri = uris.firstWhereOrNull( + (uri) => uri.scheme.toLowerCase() == 'https', + ) ?? + uris.firstWhereOrNull( + (uri) => uri.scheme.toLowerCase() == 'http', + ); + // unsubscribe via one click POST request: https://tools.ietf.org/html/rfc8058 if (hasListUnsubscribePostHeader() && httpUri != null) { final response = await unsubscribeWithOneClick(httpUri); @@ -245,17 +243,20 @@ extension NewsLetter on MimeMessage { } } // unsubscribe via generated mail: - final mailtoUri = uris.firstWhere( - (uri) => uri!.scheme.toLowerCase() == 'mailto', - orElse: () => null); + final mailtoUri = uris.firstWhereOrNull( + (uri) => uri.scheme.toLowerCase() == 'mailto', + ); if (mailtoUri != null) { await sendMailto(mailtoUri, client, 'unsubscribe'); + return true; } + // manually open unsubscribe web page: if (httpUri != null) { return url_launcher.launchUrl(httpUri); } + return false; } @@ -265,42 +266,52 @@ extension NewsLetter on MimeMessage { return false; } // subscribe via generated mail: - final mailtoUri = uris.firstWhere( - (uri) => uri!.scheme.toLowerCase() == 'mailto', - orElse: () => null); + final mailtoUri = uris.firstWhereOrNull( + (uri) => uri.scheme.toLowerCase() == 'mailto', + ); if (mailtoUri != null) { await sendMailto(mailtoUri, client, 'subscribe'); + return true; } // manually open subscribe web page: - final httpUri = uris.firstWhere( - (uri) => uri!.scheme.toLowerCase() == 'https', - orElse: () => uris.firstWhere( - (uri) => uri!.scheme.toLowerCase() == 'http', - orElse: () => null)); + final httpUri = uris.firstWhereOrNull( + (uri) => uri.scheme.toLowerCase() == 'https', + ) ?? + uris.firstWhereOrNull( + (uri) => uri.scheme.toLowerCase() == 'http', + ); if (httpUri != null) { return url_launcher.launchUrl(httpUri); } + return false; } Future unsubscribeWithOneClick(Uri uri) { final request = http.MultipartRequest('POST', uri) ..fields['List-Unsubscribe'] = 'One-Click'; + return request.send(); } Future sendMailto( - Uri mailtoUri, MailClient client, String defaultSubject) { + Uri mailtoUri, + MailClient client, + String defaultSubject, + ) { final account = client.account; - var me = findRecipient(account.fromAddress, - aliases: account.aliases, - allowPlusAliases: account.supportsPlusAliases); + var me = findRecipient( + account.fromAddress, + aliases: account.aliases, + allowPlusAliases: account.supportsPlusAliases, + ); me ??= account.fromAddress; - final builder = MessageBuilder.prepareMailtoBasedMessage(mailtoUri, me); - builder.subject ??= defaultSubject; - builder.text ??= defaultSubject; + final builder = MessageBuilder.prepareMailtoBasedMessage(mailtoUri, me) + ..subject ??= defaultSubject + ..text ??= defaultSubject; final message = builder.buildMimeMessage(); + return client.sendMessage(message, appendToSent: false); } } diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index db1e59e..a5ad6ee 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../account/model.dart'; import '../locator.dart'; +import '../logger.dart'; import '../services/i18n_service.dart'; import '../services/notification_service.dart'; import '../services/scaffold_messenger_service.dart'; @@ -142,7 +143,7 @@ abstract class MessageSource extends ChangeNotifier final parent = _parentMessageSource; if (parent != null) { final mime = message.mimeMessage; - parent.removeMime(mime, message.mailClient); + parent.removeMime(mime); } if (removed && notify) { notifyListeners(); @@ -153,7 +154,7 @@ abstract class MessageSource extends ChangeNotifier @override void onMailFlagsUpdated(MimeMessage mime, AsyncMimeSource source) { - final message = cache.getWithMime(mime, source.mailClient); + final message = cache.getWithMime(mime); if (message != null) { message.updateFlags(mime.flags); } @@ -161,7 +162,7 @@ abstract class MessageSource extends ChangeNotifier @override void onMailVanished(MimeMessage mime, AsyncMimeSource source) { - final message = cache.getWithMime(mime, source.mailClient); + final message = cache.getWithMime(mime); if (message != null) { removeFromCache(message); @@ -175,7 +176,7 @@ abstract class MessageSource extends ChangeNotifier int index = 0, }) { // the source index is 0 since this is the new first message: - final message = Message(mime, source.mailClient, this, index); + final message = Message(mime, this, index); insertIntoCache(index, message); notifyListeners(); } @@ -258,7 +259,16 @@ abstract class MessageSource extends ChangeNotifier ) => moveMessage( message, - message.mailClient.getMailbox(targetMailboxFlag)!, + message.source + .getMimeSource(message) + ?.mailClient + .getMailbox(targetMailboxFlag) ?? + Mailbox( + encodedName: 'inbox', + encodedPath: 'inbox', + flags: [], + pathSeparator: '/', + ), notification, ); @@ -272,14 +282,18 @@ abstract class MessageSource extends ChangeNotifier locator(), notify: false, ); - final moveResult = await message.mailClient - .moveMessage(message.mimeMessage, targetMailbox); + final mailClient = message.source.getMimeSource(message)?.mailClient; + if (mailClient == null) { + throw Exception('Unable to retrieve mime source for $message'); + } + final moveResult = + await mailClient.moveMessage(message.mimeMessage, targetMailbox); notifyListeners(); if (moveResult.canUndo) { locator().showTextSnackBar( notification, undo: () async { - await message.mailClient.undoMoveMessages(moveResult); + await mailClient.undoMoveMessages(moveResult); insertIntoCache(message.sourceIndex, message); notifyListeners(); }, @@ -399,21 +413,23 @@ abstract class MessageSource extends ChangeNotifier } msg.isSeen = isSeen; final parent = _parentMessageSource; - final parentMsg = - parent?.cache.getWithMime(msg.mimeMessage, msg.mailClient); + final parentMsg = parent?.cache.getWithMime(msg.mimeMessage); if (parent != null && parentMsg != null) { return parent.markAsSeen(parentMsg, isSeen); } - return msg.mailClient.flagMessage(msg.mimeMessage, isSeen: isSeen); + return msg.source.storeMessageFlags( + [msg], + [MessageFlags.seen], + action: isSeen ? StoreAction.add : StoreAction.remove, + ); } void onMarkedAsSeen(Message msg, bool isSeen) { msg.isSeen = isSeen; final parent = _parentMessageSource; if (parent != null) { - final parentMsg = - parent.cache.getWithMime(msg.mimeMessage, msg.mailClient); + final parentMsg = parent.cache.getWithMime(msg.mimeMessage); if (parentMsg != null) { parent.onMarkedAsSeen(parentMsg, isSeen); } @@ -423,15 +439,18 @@ abstract class MessageSource extends ChangeNotifier Future markAsFlagged(Message msg, bool isFlagged) { onMarkedAsFlagged(msg, isFlagged); - return msg.mailClient.flagMessage(msg.mimeMessage, isFlagged: isFlagged); + return msg.source.storeMessageFlags( + [msg], + [MessageFlags.flagged], + action: isFlagged ? StoreAction.add : StoreAction.remove, + ); } void onMarkedAsFlagged(Message msg, bool isFlagged) { msg.isFlagged = isFlagged; final parent = _parentMessageSource; if (parent != null) { - final parentMsg = - parent.cache.getWithMime(msg.mimeMessage, msg.mailClient); + final parentMsg = parent.cache.getWithMime(msg.mimeMessage); if (parentMsg != null) { parent.onMarkedAsFlagged(parentMsg, isFlagged); } @@ -520,8 +539,8 @@ abstract class MessageSource extends ChangeNotifier MessageSource search(MailSearch search); - void removeMime(MimeMessage mimeMessage, MailClient mailClient) { - final existingMessage = cache.getWithMime(mimeMessage, mailClient); + void removeMime(MimeMessage mimeMessage) { + final existingMessage = cache.getWithMime(mimeMessage); if (existingMessage != null) { removeFromCache(existingMessage); } @@ -545,19 +564,42 @@ abstract class MessageSource extends ChangeNotifier bool markAsSeen = false, List? includedInlineTypes, Duration? responseTimeout, - }) { + }) async { final mimeSource = getMimeSource(message); if (mimeSource == null) { throw Exception('Unable to detect mime source from $message'); } - return mimeSource.fetchMessageContents( + final mimeMessage = await mimeSource.fetchMessageContents( message.mimeMessage, maxSize: maxSize, markAsSeen: markAsSeen, includedInlineTypes: includedInlineTypes, responseTimeout: responseTimeout, ); + message.updateMime(mimeMessage); + + return mimeMessage; + } + + /// Fetches the message contents for the partial [message]. + /// + /// Compare [MailClient]'s `fetchMessagePart()` call. + Future fetchMessagePart( + Message message, { + required String fetchId, + Duration? responseTimeout, + }) { + final mimeSource = getMimeSource(message); + if (mimeSource == null) { + throw Exception('Unable to detect mime source from $message'); + } + + return mimeSource.fetchMessagePart( + message.mimeMessage, + fetchId: fetchId, + responseTimeout: responseTimeout, + ); } // void replaceMime(Message message, MimeMessage mime) { @@ -602,7 +644,7 @@ class MailboxMessageSource extends MessageSource { //print('get uncached $index'); final mime = await _mimeSource.getMessage(index); - return Message(mime, _mimeSource.mailClient, this, index); + return Message(mime, this, index); } @override @@ -624,7 +666,7 @@ class MailboxMessageSource extends MessageSource { if (parent != null) { for (final removedMessage in removedMessages) { final mime = removedMessage.mimeMessage; - parent.removeMime(mime, removedMessage.mailClient); + parent.removeMime(mime); } } @@ -634,7 +676,11 @@ class MailboxMessageSource extends MessageSource { @override Future markAllMessagesSeen(bool seen) async { cache.markAllMessageSeen(seen); - await _mimeSource.storeAll([MessageFlags.seen]); + await _mimeSource.storeAll( + [MessageFlags.seen], + action: seen ? StoreAction.add : StoreAction.remove, + ); + return true; } @@ -727,6 +773,7 @@ class MultipleMessageSource extends MessageSource { supportsDeleteAll = mimeSources.any((s) => s.supportsDeleteAll); } + /// The integrated mime sources final List mimeSources; final _multipleMimeSources = <_MultipleMimeSource>[]; MailboxFlag? _flag; @@ -785,10 +832,20 @@ class MultipleMessageSource extends MessageSource { } newestMessage.source.pop(); // newestSource._currentIndex could have changed in the meantime - _indicesCache.add(_MultipleMessageSourceId( - newestMessage.source.mimeSource, newestMessage.index)); - final message = Message(newestMessage.mimeMessage, - newestMessage.source.mimeSource.mailClient, this, index); + _indicesCache.add( + _MultipleMessageSourceId( + newestMessage.source.mimeSource, + newestMessage.index, + ), + ); + + final message = _UnifiedMessage( + newestMessage.mimeMessage, + this, + index, + newestMessage.source.mimeSource, + ); + return message; } @@ -798,7 +855,7 @@ class MultipleMessageSource extends MessageSource { final id = _indicesCache[index]; final mime = await id.source.getMessage(id.index); - return Message(mime, id.source.mailClient, this, index); + return _UnifiedMessage(mime, this, index, id.source); } // print( // 'get uncached $index with lastUncachedIndex=$_lastUncachedIndex and size $size'); @@ -841,22 +898,26 @@ class MultipleMessageSource extends MessageSource { final parent = _parentMessageSource; if (parent != null) { for (final removedMessage in removedMessages) { - parent.removeMime( - removedMessage.mimeMessage, removedMessage.mailClient); + parent.removeMime(removedMessage.mimeMessage); } } final futureResults = await Future.wait(futures); final results = []; - for (final result in futureResults) { - results.addAll(result); - } + futureResults.forEach(results.addAll); + return results; } @override - AsyncMimeSource getMimeSource(Message message) => mimeSources - .firstWhere((source) => source.mailClient == message.mailClient); + AsyncMimeSource getMimeSource(Message message) { + if (message is _UnifiedMessage) { + return message.mimeSource; + } + logger.e('Unable to retrieve mime source for $message'); + + return mimeSources.first; + } @override bool get shouldBlockImages => @@ -967,6 +1028,17 @@ class MultipleMessageSource extends MessageSource { } } +class _UnifiedMessage extends Message { + _UnifiedMessage( + super.mimeMessage, + super.source, + super.sourceIndex, + this.mimeSource, + ); + + final AsyncMimeSource mimeSource; +} + class _MultipleMimeSourceMessage { const _MultipleMimeSourceMessage(this.index, this.source, this.mimeMessage); final int index; @@ -1105,10 +1177,11 @@ class ListMessageSource extends MessageSource { final Account account; void initWithMimeMessages( - List mimeMessages, MailClient mailClient, - {bool reverse = true}) { + List mimeMessages, { + bool reverse = true, + }) { var result = mimeMessages - .mapIndexed((index, mime) => Message(mime, mailClient, this, index)) + .mapIndexed((index, mime) => Message(mime, this, index)) .toList(); if (reverse) { result = result.reversed.toList(); @@ -1249,16 +1322,15 @@ class ErrorMessageSource extends MessageSource { // } extension _ExtensionsOnMessageIndexedCache on IndexedCache { - Message? getWithMime(MimeMessage mime, MailClient mailClient) { - final uid = mime.uid; - if (uid != null) { + Message? getWithMime(MimeMessage mime) { + final guid = mime.guid; + if (guid != null) { return firstWhereOrNull( - (msg) => msg.mailClient == mailClient && msg.mimeMessage.uid == uid); + (msg) => msg.mimeMessage.guid == guid, + ); } - final sequenceId = mime.sequenceId; - return firstWhereOrNull((msg) => - msg.mailClient == mailClient && - msg.mimeMessage.sequenceId == sequenceId); + + return null; } // Message? getWithSourceIndex(int sourceIndex, MailClient mailClient) => diff --git a/lib/models/offline_mime_source.dart b/lib/models/offline_mime_source.dart index ecfc41e..4888a5e 100644 --- a/lib/models/offline_mime_source.dart +++ b/lib/models/offline_mime_source.dart @@ -65,9 +65,37 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { responseTimeout: responseTimeout, ); await _storage.saveMessageContents(onlineContents); + return onlineContents; } + @override + Future fetchMessagePart(MimeMessage message, + {required String fetchId, Duration? responseTimeout}) => + _onlineMimeSource.fetchMessagePart( + message, + fetchId: fetchId, + responseTimeout: responseTimeout, + ); + + @override + Future sendMessage( + MimeMessage message, { + MailAddress? from, + bool appendToSent = true, + Mailbox? sentMailbox, + bool use8BitEncoding = false, + List? recipients, + }) => + _onlineMimeSource.sendMessage( + message, + from: from, + appendToSent: appendToSent, + sentMailbox: sentMailbox, + use8BitEncoding: use8BitEncoding, + recipients: recipients, + ); + @override Future handleOnMessageArrived(int index, MimeMessage message) => Future.wait([ @@ -104,6 +132,7 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { } final onlineMessages = await _onlineMimeSource.loadMessages(sequence); await _storage.saveMessageEnvelopes(onlineMessages); + return onlineMessages; } diff --git a/lib/screens/media_screen.dart b/lib/screens/media_screen.dart index 32dd6a1..2f42c44 100644 --- a/lib/screens/media_screen.dart +++ b/lib/screens/media_screen.dart @@ -37,6 +37,7 @@ class InteractiveMediaScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final localizations = context.text; final iconService = locator(); + return Base.buildAppChrome( context, title: mediaWidget.mediaProvider.name, @@ -69,11 +70,11 @@ class InteractiveMediaScreen extends ConsumerWidget { final mailService = locator(); final account = mailService.currentAccount; if (account is RealAccount) { - final client = await mailService.getClientFor(account); final source = SingleMessageSource( - mailService.messageSource, - account: account); - final message = Message(mime, client, source, 0); + mailService.messageSource, + account: account, + ); + final message = Message(mime, source, 0); message.isEmbedded = true; source.singleMessage = message; showErrorMessage = false; diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index cfd2d8c..f364922 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -382,7 +382,6 @@ class _MessageContentState extends ConsumerState<_MessageContent> { return MimeMessageDownloader( mimeMessage: message.mimeMessage, - mailClient: message.mailClient, fetchMessageContents: ( mimeMessage, { int? maxSize, @@ -420,6 +419,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { uri, mode: ref.read(settingsProvider).urlLaunchMode, ); + return Future.value(true); }, onZoomed: (controller, factor) { @@ -438,8 +438,11 @@ class _MessageContentState extends ConsumerState<_MessageContent> { if (calendarText != null) { final mediaProvider = TextMediaProvider('invite.ics', 'text/calendar', calendarText); + return IcalInteractiveMedia( - mediaProvider: mediaProvider, message: widget.message); + mediaProvider: mediaProvider, + message: widget.message, + ); } } return null; @@ -600,24 +603,37 @@ class _ThreadSequenceButtonState extends State { if (existingSource is ListMessageSource) { return existingSource.messages; } - final mailClient = widget.message.mailClient; + final threadSequence = widget.message.mimeMessage.threadSequence; + if (threadSequence == null || threadSequence.isEmpty) { + return []; + } + final mailClient = + widget.message.source.getMimeSource(widget.message)?.mailClient; + if (mailClient == null) { + return []; + } + final mimeMessages = await mailClient.fetchMessageSequence( - widget.message.mimeMessage.threadSequence!, - fetchPreference: FetchPreference.envelope); + threadSequence, + fetchPreference: FetchPreference.envelope, + ); final source = ListMessageSource(widget.message.source) - ..initWithMimeMessages(mimeMessages, mailClient); + ..initWithMimeMessages(mimeMessages); + return source.messages; } @override Widget build(BuildContext context) { final length = widget.message.mimeMessage.threadSequence?.length ?? 0; + return WillPopScope( onWillPop: () { if (_overlayEntry == null) { return Future.value(true); } _removeOverlay(); + return Future.value(false); }, child: PlatformIconButton( @@ -626,8 +642,9 @@ class _ThreadSequenceButtonState extends State { if (_overlayEntry != null) { _removeOverlay(); } else { - _overlayEntry = _buildThreadsOverlay(); - Overlay.of(context).insert(_overlayEntry!); + final overlayEntry = _buildThreadsOverlay(); + _overlayEntry = overlayEntry; + Overlay.of(context).insert(overlayEntry); } }, ), @@ -635,8 +652,11 @@ class _ThreadSequenceButtonState extends State { } void _removeOverlay() { - _overlayEntry!.remove(); - _overlayEntry = null; + final overlayEntry = _overlayEntry; + if (overlayEntry != null) { + overlayEntry.remove(); + _overlayEntry = null; + } } void _select(Message message) { @@ -645,9 +665,9 @@ class _ThreadSequenceButtonState extends State { } OverlayEntry _buildThreadsOverlay() { - final RenderBox renderBox = context.findRenderObject()! as RenderBox; - final offset = renderBox.localToGlobal(Offset.zero); - final renderSize = renderBox.size; + final renderBox = context.findRenderObject() as RenderBox?; + final offset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; + final renderSize = renderBox?.size ?? const Size(120, 400); final size = MediaQuery.of(context).size; final currentUid = widget.message.mimeMessage.uid; final top = offset.dy + renderSize.height + 5.0; @@ -668,13 +688,15 @@ class _ThreadSequenceButtonState extends State { child: FutureBuilder?>( future: _loadingFuture, builder: (context, snapshot) { - if (!snapshot.hasData) { + final data = snapshot.data; + if (data == null) { return const Center( child: PlatformProgressIndicator(), ); } - final messages = snapshot.data!; + final messages = data; final isSentFolder = widget.message.source.isSent; + return ConstrainedBox( constraints: BoxConstraints(maxHeight: height), child: ListView( @@ -738,15 +760,19 @@ class _ReadReceiptButtonState extends State { setState(() { _isSendingReadReceipt = true; }); + final mailClient = message.source.getMimeSource(message)?.mailClient; + if (mailClient == null) { + return; + } final readReceipt = MessageBuilder.buildReadReceipt( mime, message.account.fromAddress, reportingUa: 'Maily 1.0', subject: localizations.detailsReadReceiptSubject, ); - await message.mailClient - .sendMessage(readReceipt, appendToSent: false); - await message.mailClient.flagMessage(mime, isReadReceiptSent: true); + + await mailClient.sendMessage(readReceipt, appendToSent: false); + await mailClient.flagMessage(mime, isReadReceiptSent: true); setState(() { _isSendingReadReceipt = false; }); @@ -795,18 +821,20 @@ class _UnsubscribeButtonState extends State { Future _resubscribe() async { final localizations = context.text; final mime = widget.message.mimeMessage; - final listName = mime.decodeListName()!; - final confirmation = await LocalizedDialogHelper.askForConfirmation(context, - title: localizations.detailsNewsletterResubscribeDialogTitle, - action: localizations.detailsNewsletterResubscribeDialogAction, - query: - localizations.detailsNewsletterResubscribeDialogQuestion(listName)); - if (confirmation == true) { + final listName = mime.decodeListName() ?? '<>'; + final confirmation = await LocalizedDialogHelper.askForConfirmation( + context, + title: localizations.detailsNewsletterResubscribeDialogTitle, + action: localizations.detailsNewsletterResubscribeDialogAction, + query: localizations.detailsNewsletterResubscribeDialogQuestion(listName), + ); + if (confirmation ?? false) { setState(() { _isActive = true; }); - final mailClient = widget.message.mailClient; - final subscribed = await mime.subscribe(mailClient); + final mailClient = + widget.message.source.getMimeSource(widget.message)?.mailClient; + final subscribed = mailClient != null && await mime.subscribe(mailClient); setState(() { _isActive = false; }); @@ -815,11 +843,14 @@ class _UnsubscribeButtonState extends State { widget.message.isNewsletterUnsubscribed = false; }); //TODO store flag only when server/mailbox supports arbitrary flags? - await mailClient.store(MessageSequence.fromMessage(mime), - [Message.keywordFlagUnsubscribed], - action: StoreAction.remove); + await mailClient.store( + MessageSequence.fromMessage(mime), + [Message.keywordFlagUnsubscribed], + action: StoreAction.remove, + ); } - await LocalizedDialogHelper.showTextDialog( + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( context, subscribed ? localizations.detailsNewsletterResubscribeSuccessTitle @@ -828,14 +859,16 @@ class _UnsubscribeButtonState extends State { ? localizations .detailsNewsletterResubscribeSuccessMessage(listName) : localizations - .detailsNewsletterResubscribeFailureMessage(listName)); + .detailsNewsletterResubscribeFailureMessage(listName), + ); + } } } Future _unsubscribe() async { final localizations = context.text; final mime = widget.message.mimeMessage; - final listName = mime.decodeListName()!; + final listName = mime.decodeListName() ?? '<>'; final confirmation = await LocalizedDialogHelper.askForConfirmation( context, title: localizations.detailsNewsletterUnsubscribeDialogTitle, @@ -846,10 +879,11 @@ class _UnsubscribeButtonState extends State { setState(() { _isActive = true; }); - final mailClient = widget.message.mailClient; + final mailClient = + widget.message.source.getMimeSource(widget.message)?.mailClient; var unsubscribed = false; try { - unsubscribed = await mime.unsubscribe(mailClient); + unsubscribed = mailClient != null && await mime.unsubscribe(mailClient); } catch (e, s) { if (kDebugMode) { print('error during unsubscribe: $e $s'); @@ -864,24 +898,29 @@ class _UnsubscribeButtonState extends State { }); //TODO store flag only when server/mailbox supports arbitrary flags? try { - await mailClient.store(MessageSequence.fromMessage(mime), - [Message.keywordFlagUnsubscribed]); + await mailClient?.store( + MessageSequence.fromMessage(mime), + [Message.keywordFlagUnsubscribed], + ); } catch (e, s) { if (kDebugMode) { print('error during unsubscribe flag store operation: $e $s'); } } } - await LocalizedDialogHelper.showTextDialog( - context, - unsubscribed - ? localizations.detailsNewsletterUnsubscribeSuccessTitle - : localizations.detailsNewsletterUnsubscribeFailureTitle, - unsubscribed - ? localizations.detailsNewsletterUnsubscribeSuccessMessage(listName) - : localizations - .detailsNewsletterUnsubscribeFailureMessage(listName), - ); + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( + context, + unsubscribed + ? localizations.detailsNewsletterUnsubscribeSuccessTitle + : localizations.detailsNewsletterUnsubscribeFailureTitle, + unsubscribed + ? localizations + .detailsNewsletterUnsubscribeSuccessMessage(listName) + : localizations + .detailsNewsletterUnsubscribeFailureMessage(listName), + ); + } } } } diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index a56e280..2d14111 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -838,6 +838,7 @@ class _MessageSourceScreenState extends ConsumerState if (_selectedMessages.isEmpty) { locator() .showTextSnackBar(localizations.multipleSelectionNeededInfo); + return; } var endSelectionMode = true; @@ -937,15 +938,18 @@ class _MessageSourceScreenState extends ConsumerState final futures = []; for (final message in _selectedMessages) { message.isSelected = false; - final mailClient = message.mailClient; + final mailClient = message.source.getMimeSource(message)?.mailClient; + if (mailClient == null) { + continue; + } final from = mailClient.account.fromAddress; if (!fromAddresses.contains(from)) { fromAddresses.add(from); } final mime = message.mimeMessage; final subject = mime.decodeSubject(); - if (subject?.isNotEmpty ?? false) { - subjects.add(subject!.replaceAll('\r\n ', '').replaceAll('\n', '')); + if (subject != null && subject.isNotEmpty) { + subjects.add(subject.replaceAll('\r\n ', '').replaceAll('\n', '')); } final composeFuture = loader(message, builder); if (composeFuture != null) { @@ -971,8 +975,7 @@ class _MessageSourceScreenState extends ConsumerState Future? addMessageAttachment(Message message, MessageBuilder builder) { final mime = message.mimeMessage; if (mime.mimeData == null) { - return message.mailClient.fetchMessageContents(mime).then((value) { - message.updateMime(value); + return message.source.fetchMessageContents(message).then((value) { builder.addMessagePart(value); }); } else { @@ -982,12 +985,11 @@ class _MessageSourceScreenState extends ConsumerState } Future? addAttachments(Message message, MessageBuilder builder) { - final mailClient = message.mailClient; final mime = message.mimeMessage; Future? composeFuture; if (mime.mimeData == null) { - composeFuture = mailClient.fetchMessageContents(mime).then((value) { - message.updateMime(value); + composeFuture = + message.source.fetchMessageContents(message).then((value) { for (final attachment in message.attachments) { final part = value.getPart(attachment.fetchId); builder.addPart(mimePart: part); @@ -1000,8 +1002,8 @@ class _MessageSourceScreenState extends ConsumerState if (part != null) { builder.addPart(mimePart: part); } else { - futures.add(mailClient - .fetchMessagePart(mime, attachment.fetchId) + futures.add(message.source + .fetchMessagePart(message, fetchId: attachment.fetchId) .then((value) { builder.addPart(mimePart: value); })); @@ -1009,6 +1011,7 @@ class _MessageSourceScreenState extends ConsumerState composeFuture = futures.isEmpty ? null : Future.wait(futures); } } + return composeFuture; } @@ -1020,8 +1023,9 @@ class _MessageSourceScreenState extends ConsumerState // or of the real account final mailClients = []; for (final message in _selectedMessages) { - if (!mailClients.contains(message.mailClient)) { - mailClients.add(message.mailClient); + final mailClient = message.source.getMimeSource(message)?.mailClient; + if (mailClient != null && !mailClients.contains(mailClient)) { + mailClients.add(mailClient); } } if (mailClients.length == 1) { @@ -1032,7 +1036,10 @@ class _MessageSourceScreenState extends ConsumerState } final mailbox = account.isVirtual ? null // //TODO set current mailbox, e.g. current: widget.messageSource.currentMailbox, - : _selectedMessages.first.mailClient.selectedMailbox; + : _selectedMessages.first.source + .getMimeSource(_selectedMessages.first) + ?.mailClient + .selectedMailbox; LocalizedDialogHelper.showWidgetDialog( context, SingleChildScrollView( @@ -1083,8 +1090,7 @@ class _MessageSourceScreenState extends ConsumerState if (message.mimeMessage.hasFlag(MessageFlags.draft)) { // continue to edit message: // first download message: - final mime = - await message.mailClient.fetchMessageContents(message.mimeMessage); + final mime = await message.source.fetchMessageContents(message); //message.updateMime(mime); final builder = MessageBuilder.prepareFromDraft(mime); final data = ComposeData([message], builder, ComposeAction.newMessage); diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 454b5b3..c19fb82 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -3,14 +3,14 @@ import 'dart:math'; import 'package:background_fetch/background_fetch.dart'; import 'package:enough_mail/enough_mail.dart'; -import '../models/async_mime_source_factory.dart'; -import '../models/background_update_info.dart'; -import 'notification_service.dart'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../locator.dart'; +import '../models/async_mime_source_factory.dart'; +import '../models/background_update_info.dart'; import 'mail_service.dart'; +import 'notification_service.dart'; class BackgroundService { static const String _keyInboxUids = 'nextUidsInfo'; @@ -21,26 +21,29 @@ class BackgroundService { Future init() async { await BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 15, - startOnBoot: true, - stopOnTerminate: false, - enableHeadless: true, - requiresBatteryNotLow: false, - requiresCharging: false, - requiresStorageNotLow: false, - requiresDeviceIdle: false, - requiredNetworkType: NetworkType.ANY, - ), (String taskId) async { - try { - await locator().resume(); - } catch (e, s) { - if (kDebugMode) { - print('Error: Unable to finish foreground background fetch: $e $s'); + BackgroundFetchConfig( + minimumFetchInterval: 15, + startOnBoot: true, + stopOnTerminate: false, + enableHeadless: true, + requiresBatteryNotLow: false, + requiresCharging: false, + requiresStorageNotLow: false, + requiresDeviceIdle: false, + requiredNetworkType: NetworkType.ANY, + ), + (String taskId) async { + try { + await locator().resume(); + } catch (e, s) { + if (kDebugMode) { + print('Error: Unable to finish foreground background fetch: $e $s'); + } } - } - BackgroundFetch.finish(taskId); - }, BackgroundFetch.finish); + BackgroundFetch.finish(taskId); + }, + BackgroundFetch.finish, + ); await BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); } @@ -221,7 +224,7 @@ class BackgroundService { if (!mimeMessage.isSeen) { await notificationService.sendLocalNotificationForMail( mimeMessage, - mailClient, + mailClient.account.email, ); } } @@ -231,7 +234,9 @@ class BackgroundService { } catch (e, s) { if (kDebugMode) { print( - 'Unable to process background operation for ${account.name}: $e $s'); + 'Unable to process background operation ' + 'for ${account.name}: $e $s', + ); } } } diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index 3960b07..24dfb75 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -765,13 +765,16 @@ class MailService implements MimeSourceSubscriber { } @override - void onMailArrived(MimeMessage mime, AsyncMimeSource source, - {int index = 0}) { + void onMailArrived( + MimeMessage mime, + AsyncMimeSource source, { + int index = 0, + }) { source.mailClient.lowLevelIncomingMailClient .logApp('new message: ${mime.decodeSubject()}'); if (!mime.isSeen && source.isInbox) { locator() - .sendLocalNotificationForMail(mime, source.mailClient); + .sendLocalNotificationForMail(mime, source.mailClient.account.email); } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ffd053a..4b82011 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -8,6 +8,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../account/model.dart'; import '../locator.dart'; +import '../logger.dart'; import '../models/message.dart' as maily; import '../models/message_source.dart'; import '../routes.dart'; @@ -97,6 +98,7 @@ class NotificationService { MailNotificationPayload _deserialize(String payloadText) { final json = jsonDecode(payloadText.substring(_messagePayloadStart.length)); + return MailNotificationPayload.fromJson(json); } @@ -125,8 +127,7 @@ class NotificationService { account: currentMessageSource?.account ?? RealAccount(mailClient.account), ); - final message = - maily.Message(mimeMessage, mailClient, messageSource, 0); + final message = maily.Message(mimeMessage, messageSource, 0); messageSource.singleMessage = message; await locator() .push(Routes.mailDetails, arguments: message); @@ -139,16 +140,23 @@ class NotificationService { } Future sendLocalNotificationForMailLoadEvent(MailLoadEvent event) => - sendLocalNotificationForMail(event.message, event.mailClient); + sendLocalNotificationForMail( + event.message, + event.mailClient.account.email, + ); Future sendLocalNotificationForMailMessage(maily.Message message) => - sendLocalNotificationForMail(message.mimeMessage, message.mailClient); + sendLocalNotificationForMail(message.mimeMessage, message.account.email); Future sendLocalNotificationForMail( - MimeMessage mimeMessage, MailClient mailClient) { + MimeMessage mimeMessage, + String accountEmail, + ) { if (kDebugMode) { - print( - 'sending notification for mime ${mimeMessage.decodeSubject()} with GUID ${mimeMessage.guid}'); + logger.d( + 'sending notification for mime ${mimeMessage.decodeSubject()}' + ' with GUID ${mimeMessage.guid}', + ); } final notificationId = mimeMessage.guid!; var from = mimeMessage.from?.isNotEmpty ?? false @@ -160,10 +168,16 @@ class NotificationService { : mimeMessage.sender?.email; } final subject = mimeMessage.decodeSubject(); - final payload = MailNotificationPayload.fromMail(mimeMessage, mailClient); + final payload = MailNotificationPayload.fromMail(mimeMessage, accountEmail); final payloadText = _messagePayloadStart + jsonEncode(payload.toJson()); - return sendLocalNotification(notificationId, from!, subject, - payloadText: payloadText, when: mimeMessage.decodeDate()); + + return sendLocalNotification( + notificationId, + from!, + subject, + payloadText: payloadText, + when: mimeMessage.decodeDate(), + ); } // int getNotificationIdForMail(MimeMessage mimeMessage, MailClient mailClient) { @@ -247,13 +261,13 @@ class MailNotificationPayload { /// Creates a new payload from the given [mimeMessage] MailNotificationPayload.fromMail( - MimeMessage mimeMessage, MailClient mailClient) - : uid = mimeMessage.uid!, - guid = mimeMessage.guid!, - sequenceId = mimeMessage.sequenceId!, + MimeMessage mimeMessage, + this.accountEmail, + ) : uid = mimeMessage.uid ?? 0, + guid = mimeMessage.guid ?? 0, + sequenceId = mimeMessage.sequenceId ?? 0, subject = mimeMessage.decodeSubject() ?? '', - size = mimeMessage.size!, - accountEmail = mailClient.account.email; + size = mimeMessage.size ?? 0; /// Creates a new payload from the given [json] factory MailNotificationPayload.fromJson(Map json) => diff --git a/lib/widgets/attachment_chip.dart b/lib/widgets/attachment_chip.dart index ebc013b..2ee5193 100644 --- a/lib/widgets/attachment_chip.dart +++ b/lib/widgets/attachment_chip.dart @@ -1,11 +1,11 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail_flutter/enough_mail_flutter.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../localization/extension.dart'; import '../locator.dart'; +import '../logger.dart'; import '../models/message.dart'; import '../routes.dart'; import '../screens/media_screen.dart'; @@ -34,10 +34,10 @@ class _AttachmentChipState extends State { @override void initState() { final mimeMessage = widget.message.mimeMessage; - _mimePart = mimeMessage.getPart(widget.info.fetchId); - if (_mimePart != null) { - _mediaProvider = - MimeMediaProviderFactory.fromMime(mimeMessage, _mimePart!); + final mimePart = mimeMessage.getPart(widget.info.fetchId); + _mimePart = mimePart; + if (mimePart != null) { + _mediaProvider = MimeMediaProviderFactory.fromMime(mimeMessage, mimePart); } super.initState(); } @@ -46,8 +46,10 @@ class _AttachmentChipState extends State { Widget build(BuildContext context) { final mediaType = widget.info.contentType?.mediaType; final name = widget.info.fileName; - if (_mediaProvider == null) { + final mediaProvider = _mediaProvider; + if (mediaProvider == null) { final fallbackIcon = locator().getForMediaType(mediaType); + return PlatformTextButton( onPressed: _isDownloading ? null : _download, child: Padding( @@ -64,7 +66,7 @@ class _AttachmentChipState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: PreviewMediaWidget( - mediaProvider: _mediaProvider!, + mediaProvider: mediaProvider, width: _width, height: _height, showInteractiveDelegate: _showAttachment, @@ -80,79 +82,82 @@ class _AttachmentChipState extends State { Widget _buildFallbackPreview(BuildContext context, MediaProvider provider) { final fallbackIcon = locator() .getForMediaType(MediaType.fromText(provider.mediaType)); + return _buildPreviewWidget(false, fallbackIcon, provider.name); } Widget _buildPreviewWidget( - bool includeDownloadOption, IconData iconData, String? name) { - return SizedBox( - width: _width, - height: _height, - //color: Colors.yellow, - child: Stack( - children: [ - Icon( - iconData, - size: _width, - color: Colors.grey[700], - ), - if (name != null) - Align( - alignment: Alignment.bottomLeft, - child: Container( - width: _width, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0xff000000)], + bool includeDownloadOption, + IconData iconData, + String? name, + ) => + SizedBox( + width: _width, + height: _height, + //color: Colors.yellow, + child: Stack( + children: [ + Icon( + iconData, + size: _width, + color: Colors.grey[700], + ), + if (name != null) + Align( + alignment: Alignment.bottomLeft, + child: Container( + width: _width, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00000000), Color(0xff000000)], + ), ), - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: Text( - name, - overflow: TextOverflow.fade, - style: const TextStyle(fontSize: 8, color: Colors.white), + child: Padding( + padding: const EdgeInsets.all(4), + child: Text( + name, + overflow: TextOverflow.fade, + style: const TextStyle(fontSize: 8, color: Colors.white), + ), ), ), ), - ), - if (includeDownloadOption) ...[ - Align( - alignment: Alignment.topLeft, - child: Container( - width: _width, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [Color(0x00000000), Color(0xff000000)], + if (includeDownloadOption) ...[ + Align( + alignment: Alignment.topLeft, + child: Container( + width: _width, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Color(0x00000000), Color(0xff000000)], + ), + ), + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.download_rounded, color: Colors.white), ), - ), - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon(Icons.download_rounded, color: Colors.white), ), ), - ), - if (_isDownloading) - const Center(child: PlatformProgressIndicator()), + if (_isDownloading) + const Center(child: PlatformProgressIndicator()), + ], ], - ], - ), - ); - // Container( - // width: 80, - // height: 80, - // child: ActionChip( - // avatar: buildIcon(), - // visualDensity: VisualDensity.compact, - // label: Text(widget.info.fileName, style: TextStyle(fontSize: 8)), - // onPressed: download, - // ), - // ); - } + ), + ); + // Container( + // width: 80, + // height: 80, + // child: ActionChip( + // avatar: buildIcon(), + // visualDensity: VisualDensity.compact, + // label: Text(widget.info.fileName, style: TextStyle(fontSize: 8)), + // onPressed: download, + // ), + // ); Future _download() async { if (_isDownloading) { @@ -162,21 +167,27 @@ class _AttachmentChipState extends State { _isDownloading = true; }); try { - _mimePart = await widget.message.mailClient - .fetchMessagePart(widget.message.mimeMessage, widget.info.fetchId); - _mediaProvider = MimeMediaProviderFactory.fromMime( - widget.message.mimeMessage, _mimePart!); + final mimePart = await widget.message.source.fetchMessagePart( + widget.message, + fetchId: widget.info.fetchId, + ); + _mimePart = mimePart; + final mediaProvider = MimeMediaProviderFactory.fromMime( + widget.message.mimeMessage, + mimePart, + ); + _mediaProvider = mediaProvider; final media = InteractiveMediaWidget( - mediaProvider: _mediaProvider!, + mediaProvider: mediaProvider, builder: _buildInteractiveMedia, fallbackBuilder: _buildInteractiveFallback, ); await _showAttachment(media); } on MailException catch (e) { - if (kDebugMode) { - print( - 'Unable to download attachment with fetch id ${widget.info.fetchId}: $e'); - } + logger.e( + 'Unable to download attachment with ' + 'fetch id ${widget.info.fetchId}: $e', + ); } finally { if (mounted) { setState(() { @@ -187,20 +198,28 @@ class _AttachmentChipState extends State { } Future _showAttachment(InteractiveMediaWidget media) { - if (_mimePart!.mediaType.sub == MediaSubtype.messageRfc822) { - final mime = _mimePart!.decodeContentMessage(); + if (_mimePart?.mediaType.sub == MediaSubtype.messageRfc822) { + final mime = _mimePart?.decodeContentMessage(); if (mime != null) { final message = Message.embedded(mime, widget.message); - return locator() - .push(Routes.mailDetails, arguments: message); + + return locator().push( + Routes.mailDetails, + arguments: message, + ); } } - return locator() - .push(Routes.interactiveMedia, arguments: media); + + return locator().push( + Routes.interactiveMedia, + arguments: media, + ); } Widget _buildInteractiveFallback( - BuildContext context, MediaProvider mediaProvider) { + BuildContext context, + MediaProvider mediaProvider, + ) { final sizeText = locator().formatMemory(mediaProvider.size); final localizations = context.text; final iconData = locator() @@ -227,8 +246,10 @@ class _AttachmentChipState extends State { ), PlatformTextButton( child: ButtonText(localizations.attachmentActionOpen), - onPressed: () => InteractiveMediaScreen.share(mediaProvider), - ) + onPressed: () => InteractiveMediaScreen.share( + mediaProvider, + ), + ), ], ), ), diff --git a/lib/widgets/ical_interactive_media.dart b/lib/widgets/ical_interactive_media.dart index be1a318..055d9fb 100644 --- a/lib/widgets/ical_interactive_media.dart +++ b/lib/widgets/ical_interactive_media.dart @@ -10,6 +10,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../account/model.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; @@ -21,8 +22,11 @@ import 'mail_address_chip.dart'; import 'text_with_links.dart'; class IcalInteractiveMedia extends StatefulWidget { - const IcalInteractiveMedia( - {super.key, required this.mediaProvider, required this.message}); + const IcalInteractiveMedia({ + super.key, + required this.mediaProvider, + required this.message, + }); final MediaProvider mediaProvider; final Message message; @@ -76,6 +80,7 @@ class _IcalInteractiveMediaState extends State { child: Text(localizations.errorTitle), ); } + return const Center(child: PlatformProgressIndicator()); } final isReply = _calendar?.method == Method.reply; @@ -85,6 +90,11 @@ class _IcalInteractiveMediaState extends State { final recurrenceRule = event.recurrenceRule; final end = event.end; final start = event.start; + final duration = event.duration; + final description = event.description; + final location = event.location; + final microsoftTeamsMeetingUrl = event.microsoftTeamsMeetingUrl; + return Material( child: Padding( padding: const EdgeInsets.all(8), @@ -101,18 +111,24 @@ class _IcalInteractiveMediaState extends State { PlatformTextButton( child: PlatformText(localizations.actionAccept), onPressed: () => _changeParticipantStatus( - ParticipantStatus.accepted, localizations), + ParticipantStatus.accepted, + localizations, + ), ), PlatformTextButton( child: PlatformText(localizations.icalendarAcceptTentatively), onPressed: () => _changeParticipantStatus( - ParticipantStatus.tentative, localizations), + ParticipantStatus.tentative, + localizations, + ), ), PlatformTextButton( child: PlatformText(localizations.actionDecline), onPressed: () => _changeParticipantStatus( - ParticipantStatus.declined, localizations), + ParticipantStatus.declined, + localizations, + ), ), ], ) @@ -122,11 +138,13 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - _participantStatus?.localization(localizations) ?? ''), + _participantStatus?.localization(localizations) ?? '', + ), ), PlatformTextButton( child: PlatformText( - localizations.icalendarActionChangeParticipantStatus), + localizations.icalendarActionChangeParticipantStatus, + ), onPressed: () => _queryParticipantStatus(localizations), ), ], @@ -134,7 +152,7 @@ class _IcalInteractiveMediaState extends State { Table( columnWidths: const { 0: IntrinsicColumnWidth(), - 1: FlexColumnWidth() + 1: FlexColumnWidth(), }, children: [ TableRow(children: [ @@ -145,9 +163,10 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: TextWithLinks( - text: event.summary ?? - localizations.icalendarNoSummaryInfo), - ) + text: + event.summary ?? localizations.icalendarNoSummaryInfo, + ), + ), ]), if (start != null) TableRow( @@ -159,11 +178,13 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - i18nService.formatDateTime(start.toLocal(), - alwaysUseAbsoluteFormat: true, - useLongFormat: true), + i18nService.formatDateTime( + start.toLocal(), + alwaysUseAbsoluteFormat: true, + useLongFormat: true, + ), ), - ) + ), ], ), if (end != null) @@ -176,14 +197,16 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - i18nService.formatDateTime(end.toLocal(), - alwaysUseAbsoluteFormat: true, - useLongFormat: true), + i18nService.formatDateTime( + end.toLocal(), + alwaysUseAbsoluteFormat: true, + useLongFormat: true, + ), ), ), ], ) - else if (event.duration != null) + else if (duration != null) TableRow( children: [ Padding( @@ -193,8 +216,9 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - i18nService.formatIsoDuration(event.duration!)), - ) + i18nService.formatIsoDuration(duration), + ), + ), ], ), if (recurrenceRule != null) @@ -206,13 +230,15 @@ class _IcalInteractiveMediaState extends State { ), Padding( padding: const EdgeInsets.all(8), - child: Text(recurrenceRule.toHumanReadableText( - languageCode: localizations.localeName, - )), - ) + child: Text( + recurrenceRule.toHumanReadableText( + languageCode: localizations.localeName, + ), + ), + ), ], ), - if (event.description != null) + if (description != null) TableRow( children: [ Padding( @@ -222,25 +248,29 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: TextWithLinks( - text: event.description!, + text: description, ), ), ], ), - if (event.location != null) + if (location != null) TableRow( children: [ Padding( padding: const EdgeInsets.all(8), - child: Text(localizations.icalendarLabelLocation), + child: Text( + localizations.icalendarLabelLocation, + ), ), Padding( padding: const EdgeInsets.all(8), - child: TextWithLinks(text: event.location!), - ) + child: TextWithLinks( + text: location, + ), + ), ], ), - if (event.microsoftTeamsMeetingUrl != null) + if (microsoftTeamsMeetingUrl != null) TableRow( children: [ Padding( @@ -250,8 +280,9 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: TextWithLinks( - text: event.microsoftTeamsMeetingUrl!), - ) + text: microsoftTeamsMeetingUrl, + ), + ), ], ), if (attendees.isNotEmpty) @@ -273,11 +304,12 @@ class _IcalInteractiveMediaState extends State { ? _participantStatus ?? attendee.participantStatus : attendee.participantStatus; final icon = participantStatus?.icon; - final name = isMe - ? widget.message.account.userName ?? - attendee.commonName + final account = widget.message.account; + final name = isMe && account is RealAccount + ? account.userName ?? attendee.commonName : attendee.commonName; final textStyle = participantStatus?.textStyle; + return Row( children: [ if (icon != null) @@ -302,7 +334,8 @@ class _IcalInteractiveMediaState extends State { ), Padding( padding: const EdgeInsets.symmetric( - vertical: 4), + vertical: 4, + ), child: Text( attendee.email ?? attendee.uri.toString(), @@ -337,6 +370,7 @@ class _IcalInteractiveMediaState extends State { if (kDebugMode) { print('Warning: no calendar to export.'); } + return; } try { @@ -349,13 +383,32 @@ class _IcalInteractiveMediaState extends State { } Future _changeParticipantStatus( - ParticipantStatus status, AppLocalizations localizations) async { - setState(() { - _participantStatus = status; - }); + ParticipantStatus status, + AppLocalizations localizations, + ) async { + final calendar = _calendar; + if (calendar == null) { + return; + } try { - await widget.message.mailClient.sendCalendarReply( - _calendar!, + final mailClient = + widget.message.source.getMimeSource(widget.message)?.mailClient; + if (mailClient == null) { + await LocalizedDialogHelper.showTextDialog( + context, + localizations.errorTitle, + localizations.icalendarParticipantStatusSentFailure( + 'No mail client found.', + ), + ); + + return; + } + setState(() { + _participantStatus = status; + }); + await mailClient.sendCalendarReply( + calendar, status, originatingMessage: widget.message.mimeMessage, productId: 'Maily', @@ -366,46 +419,55 @@ class _IcalInteractiveMediaState extends State { if (kDebugMode) { print('Unable to send status update: $e $s'); } - await LocalizedDialogHelper.showTextDialog( + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( context, localizations.errorTitle, - localizations.icalendarParticipantStatusSentFailure(e.toString())); + localizations.icalendarParticipantStatusSentFailure( + e.toString(), + ), + ); + } } } Future _queryParticipantStatus(AppLocalizations localizations) async { final status = await LocalizedDialogHelper.showTextDialog( - context, - localizations.icalendarParticipantStatusChangeTitle, - localizations.icalendarParticipantStatusChangeText, - actions: [ - PlatformTextButton( - child: PlatformText(localizations.actionAccept), - onPressed: () => - Navigator.of(context).pop(ParticipantStatus.accepted), - ), - PlatformTextButton( - child: PlatformText(localizations.icalendarAcceptTentatively), - onPressed: () => - Navigator.of(context).pop(ParticipantStatus.tentative), - ), - PlatformTextButton( - child: PlatformText(localizations.actionDecline), - onPressed: () => - Navigator.of(context).pop(ParticipantStatus.declined), - ), - PlatformTextButton( - child: PlatformText(localizations.actionCancel), - onPressed: () => Navigator.of(context).pop(), - ), - ]); + context, + localizations.icalendarParticipantStatusChangeTitle, + localizations.icalendarParticipantStatusChangeText, + actions: [ + PlatformTextButton( + child: PlatformText(localizations.actionAccept), + onPressed: () => + Navigator.of(context).pop(ParticipantStatus.accepted), + ), + PlatformTextButton( + child: PlatformText(localizations.icalendarAcceptTentatively), + onPressed: () => + Navigator.of(context).pop(ParticipantStatus.tentative), + ), + PlatformTextButton( + child: PlatformText(localizations.actionDecline), + onPressed: () => + Navigator.of(context).pop(ParticipantStatus.declined), + ), + PlatformTextButton( + child: PlatformText(localizations.actionCancel), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); if (status != null && status != _participantStatus) { await _changeParticipantStatus(status, localizations); } } Widget _buildReply( - BuildContext context, AppLocalizations localizations, VEvent event) { + BuildContext context, + AppLocalizations localizations, + VEvent event, + ) { // This is a reply from one of the participants: var attendees = event.attendees; if (attendees.isEmpty) { @@ -425,9 +487,12 @@ class _IcalInteractiveMediaState extends State { return Text(localizations .icalendarReplyWithoutStatus(attendee.mailAddress.toString())); } + return Text( status.participantReplyText( - localizations, attendee.mailAddress.toString()), + localizations, + attendee.mailAddress.toString(), + ), style: const TextStyle(fontStyle: FontStyle.italic), ); } diff --git a/lib/widgets/message_actions.dart b/lib/widgets/message_actions.dart index e28028c..88bdb04 100644 --- a/lib/widgets/message_actions.dart +++ b/lib/widgets/message_actions.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../account/model.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; @@ -12,7 +13,6 @@ import '../routes.dart'; import '../services/contact_service.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; import '../services/navigation_service.dart'; import '../services/notification_service.dart'; import '../services/scaffold_messenger_service.dart'; @@ -309,23 +309,24 @@ class MessageActions extends HookConsumerWidget { } void _reply(WidgetRef ref, {all = false}) { - final account = message.mailClient.account; + final account = message.account; final builder = MessageBuilder.prepareReplyToMessage( message.mimeMessage, account.fromAddress, - aliases: account.aliases, - handlePlusAliases: account.supportsPlusAliases, + aliases: account is RealAccount ? account.aliases : null, + handlePlusAliases: account is RealAccount && account.supportsPlusAliases, replyAll: all, ); _navigateToCompose(ref, message, builder, ComposeAction.answer); } Future _redirectMessage(BuildContext context) async { - final mailClient = message.mailClient; - final account = locator().getAccountFor(mailClient.account)!; - if (account.contactManager == null) { - await locator().getForAccount(account); + final account = message.account; + if (account is RealAccount) { + if (account.contactManager == null) { + await locator().getForAccount(account); + } } if (!context.mounted) { @@ -343,11 +344,14 @@ class MessageActions extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(localizations.redirectInfo, - style: Theme.of(context).textTheme.bodySmall), + Text( + localizations.redirectInfo, + style: Theme.of(context).textTheme.bodySmall, + ), RecipientInputField( addresses: recipients, - contactManager: account.contactManager, + contactManager: + account is RealAccount ? account.contactManager : null, labelText: localizations.detailsHeaderTo, hintText: localizations.composeRecipientHint, controller: textEditingController, @@ -378,17 +382,23 @@ class MessageActions extends HookConsumerWidget { } if (redirect == true) { if (recipients.isEmpty) { - await LocalizedDialogHelper.showTextDialog(context, - localizations.errorTitle, localizations.redirectEmailInputRequired); + await LocalizedDialogHelper.showTextDialog( + context, + localizations.errorTitle, + localizations.redirectEmailInputRequired, + ); } else { final mime = message.mimeMessage; if (mime.mimeData == null) { // download complete message first - await mailClient.fetchMessageContents(mime); + await message.source.fetchMessageContents(message); } try { - await mailClient.sendMessage(mime, - recipients: recipients, appendToSent: false); + await message.source.getMimeSource(message)?.sendMessage( + mime, + recipients: recipients, + appendToSent: false, + ); locator() .showTextSnackBar(localizations.resultRedirectedSuccess); } on MailException catch (e, s) { @@ -424,7 +434,8 @@ class MessageActions extends HookConsumerWidget { child: MailboxTree( account: message.account, onSelected: _moveTo, - current: message.mailClient.selectedMailbox, + // TODO(RV): retrieve the current selected mailbox in a different way + // current: message.mailClient.selectedMailbox, ), ), title: localizations.moveTitle, @@ -474,7 +485,7 @@ class MessageActions extends HookConsumerWidget { } void _forward(WidgetRef ref) { - final from = message.mailClient.account.fromAddress; + final from = message.account.fromAddress; final builder = MessageBuilder.prepareForwardMessage( message.mimeMessage, from: from, @@ -493,18 +504,18 @@ class MessageActions extends HookConsumerWidget { Future _forwardAsAttachment(WidgetRef ref) async { final message = this.message; - final mailClient = message.mailClient; - final from = mailClient.account.fromAddress; + final from = message.account.fromAddress; final mime = message.mimeMessage; final builder = MessageBuilder() ..from = [from] - ..subject = MessageBuilder.createForwardSubject(mime.decodeSubject()!); + ..subject = MessageBuilder.createForwardSubject( + mime.decodeSubject() ?? '', + ); Future? composeFuture; if (mime.mimeData == null) { - composeFuture = mailClient.fetchMessageContents(mime).then((value) { - message.updateMime(value); - builder.addMessagePart(value); - }); + composeFuture = message.source.fetchMessageContents(message).then( + builder.addMessagePart, + ); } else { builder.addMessagePart(mime); } @@ -519,12 +530,13 @@ class MessageActions extends HookConsumerWidget { Future _forwardAttachments(WidgetRef ref) async { final message = this.message; - final mailClient = message.mailClient; - final from = mailClient.account.fromAddress; + final from = message.account.fromAddress; final mime = message.mimeMessage; final builder = MessageBuilder() ..from = [from] - ..subject = MessageBuilder.createForwardSubject(mime.decodeSubject()!); + ..subject = MessageBuilder.createForwardSubject( + mime.decodeSubject() ?? '', + ); final composeFuture = _addAttachments(message, builder); _navigateToCompose( ref, @@ -537,17 +549,17 @@ class MessageActions extends HookConsumerWidget { Future? _addAttachments(Message message, MessageBuilder builder) { final attachments = message.attachments; - final mailClient = message.mailClient; final mime = message.mimeMessage; Future? composeFuture; if (mime.mimeData == null && attachments.length > 1) { - composeFuture = mailClient.fetchMessageContents(mime).then((value) { - message.updateMime(value); - for (final attachment in attachments) { - final part = value.getPart(attachment.fetchId); - builder.addPart(mimePart: part); - } - }); + composeFuture = message.source.fetchMessageContents(message).then( + (value) { + for (final attachment in attachments) { + final part = value.getPart(attachment.fetchId); + builder.addPart(mimePart: part); + } + }, + ); } else { final futures = []; for (final attachment in message.attachments) { @@ -555,11 +567,15 @@ class MessageActions extends HookConsumerWidget { if (part != null) { builder.addPart(mimePart: part); } else { - futures.add(mailClient - .fetchMessagePart(mime, attachment.fetchId) - .then((value) { - builder.addPart(mimePart: value); - })); + futures.add( + message.source + .fetchMessagePart(message, fetchId: attachment.fetchId) + .then( + (value) { + builder.addPart(mimePart: value); + }, + ), + ); } composeFuture = futures.isEmpty ? null : Future.wait(futures); } diff --git a/lib/widgets/message_stack.dart b/lib/widgets/message_stack.dart index ad51ec8..f6fbc70 100644 --- a/lib/widgets/message_stack.dart +++ b/lib/widgets/message_stack.dart @@ -189,8 +189,7 @@ class _MessageStackState extends State { switch (action) { case DragAction.noted: if (!message.isSeen) { - await message.mailClient - .flagMessage(message.mimeMessage, isSeen: true); + await message.source.markAsSeen(message, true); snack = 'mark as read'; } break; @@ -199,11 +198,16 @@ class _MessageStackState extends State { break; case DragAction.delete: //TODO remove from message source - await message.mailClient - .flagMessage(message.mimeMessage, isDeleted: true); + await message.source.storeMessageFlags( + [message], + [MessageFlags.deleted], + ); snack = 'deleted'; - undo = () => message.mailClient.flagMessage(message.mimeMessage, - isDeleted: false); //TODO add re-integration into message source + undo = () => message.source.storeMessageFlags( + [message], + [MessageFlags.deleted], + action: StoreAction.remove, + ); //TODO add re-integration into message source break; case DragAction.reply: //TODO implement quick reply @@ -495,9 +499,7 @@ class _MessageCardState extends State { Future downloadMessageContents(Message message) async { try { - final mime = - await message.mailClient.fetchMessageContents(message.mimeMessage); - message.updateMime(mime); + final mime = await message.source.fetchMessageContents(message); if (mime.isNewsletter || mime.hasAttachments()) { setState(() {}); } @@ -506,6 +508,7 @@ class _MessageCardState extends State { print('unable to download message contents: $e'); } } + return message; } diff --git a/test/model/fake_mime_source.dart b/test/model/fake_mime_source.dart index ec01e6b..961e92f 100644 --- a/test/model/fake_mime_source.dart +++ b/test/model/fake_mime_source.dart @@ -250,4 +250,22 @@ class FakeMimeSource extends PagedCachedMimeSource { @override // TODO: implement isInbox bool get isInbox => throw UnimplementedError(); + + @override + Future fetchMessagePart(MimeMessage message, + {required String fetchId, Duration? responseTimeout}) { + // TODO: implement fetchMessagePart + throw UnimplementedError(); + } + + @override + Future sendMessage(MimeMessage message, + {MailAddress? from, + bool appendToSent = true, + Mailbox? sentMailbox, + bool use8BitEncoding = false, + List? recipients}) { + // TODO: implement sendMessage + throw UnimplementedError(); + } } diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index 3c91cfd..008d300 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -83,16 +83,16 @@ void main() async { test('load first message', () async { final message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); }); test('load second message', () async { final message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); @@ -101,25 +101,25 @@ void main() async { test('load third message', () async { final message = await source.getMessageAt(2); expect(message.mimeMessage.sequenceId, 99); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 99'); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .subtract(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .subtract(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); }); test('load fourth message', () async { final message = await source.getMessageAt(3); expect(message.mimeMessage.sequenceId, 19); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( - message.mimeMessage.decodeDate(), - secondMimeSourceStartDate - .subtract(secondMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .subtract(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); }); test('ensure dates are strictly ordered', () async { @@ -143,13 +143,13 @@ void main() async { test('simple onMessageArrived x 1', () async { await (firstMimeSource as FakeMimeSource).addFakeMessage(101); final message = await source.getMessageAt(0); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); await _expectMessagesOrderedByDate(); }); @@ -161,20 +161,21 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mailClient, secondMimeSource.mailClient); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); // add new message: await (firstMimeSource as FakeMimeSource).addFakeMessage(101); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 101); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); - expect(message.mailClient, firstMimeSource.mailClient); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); expect(hasBeenNotified, isTrue); }); @@ -186,35 +187,38 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mailClient, secondMimeSource.mailClient); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); - expect(message.mailClient, firstMimeSource.mailClient); expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); // add new message: await (firstMimeSource as FakeMimeSource).addFakeMessage(101); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 101); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); - expect(message.mailClient, firstMimeSource.mailClient); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); expect(hasBeenNotified, isTrue); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mailClient, secondMimeSource.mailClient); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); }); test('onMessageArrived x 2', () async { @@ -225,9 +229,10 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mailClient, secondMimeSource.mailClient); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); // add new message: await (firstMimeSource as FakeMimeSource).addFakeMessage(101); expect(notifyCounter, 1); @@ -236,12 +241,12 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 21); expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); - expect(message.mailClient, secondMimeSource.mailClient); expect( - message.mimeMessage.decodeDate(), - secondMimeSourceStartDate - .add(secondMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .add(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); expect(notifyCounter, 2); await _expectMessagesOrderedByDate(); }); @@ -260,17 +265,19 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); expect( - message.mimeMessage.decodeDate(), - secondMimeSourceStartDate - .add(secondMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .add(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); message = await source.getMessageAt(1); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); await _expectMessagesOrderedByDate(); }); @@ -288,17 +295,19 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); expect( - message.mimeMessage.decodeDate(), - secondMimeSourceStartDate - .add(secondMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .add(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); message = await source.getMessageAt(1); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); await _expectMessagesOrderedByDate(); }); }); @@ -312,18 +321,20 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mailClient, secondMimeSource.mailClient); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); // remove message: await secondMimeSource.onMessagesVanished(MessageSequence.fromIds([20])); expect(notifyCounter, 1); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); }); test('onMessagesVanished - second sequence ID', () async { @@ -367,19 +378,21 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mailClient, secondMimeSource.mailClient); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); // remove message: await secondMimeSource .onMessagesVanished(MessageSequence.fromIds([20], isUid: true)); expect(notifyCounter, 1); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); }); test('onMessagesVanished - second UID', () async { @@ -542,17 +555,17 @@ void main() async { }); var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); final messages = []; for (int i = 0; i < 20; i++) { @@ -567,21 +580,22 @@ void main() async { expect(notifyCounter, 1); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 101); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); // previous first message should now be at the second position: message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); }); test('1 removed message after resync', () async { @@ -591,17 +605,19 @@ void main() async { }); var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); final messages = []; for (int i = 1; i < 21; i++) { @@ -616,16 +632,16 @@ void main() async { expect(notifyCounter, 1); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); // previous first message should now be at the second position: message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 19); expect(message.mimeMessage.guid, 19); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( message.mimeMessage.decodeDate(), @@ -715,14 +731,12 @@ void main() async { }); var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); @@ -750,7 +764,6 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.guid, 101, reason: 'first message should be the 101'); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect(message.isSeen, isFalse); expect( @@ -762,7 +775,6 @@ void main() async { // previous first message should now be at the second position: message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); @@ -792,14 +804,12 @@ void main() async { }); var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); @@ -824,7 +834,6 @@ void main() async { expect(notifyCounter, 2); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 21); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); expect( message.mimeMessage.decodeDate(), @@ -836,7 +845,6 @@ void main() async { // previous first message should now be at the second position: message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 101); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect( message.mimeMessage.decodeDate(), @@ -859,14 +867,12 @@ void main() async { }); var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); @@ -891,7 +897,6 @@ void main() async { expect(notifyCounter, 2); message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 21); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); expect( message.mimeMessage.decodeDate(), @@ -903,7 +908,6 @@ void main() async { // previous first message should now be at the second position: message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 101); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect( message.mimeMessage.decodeDate(), @@ -1002,7 +1006,6 @@ void main() async { // previous first message should now be at the second position: message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); @@ -1027,21 +1030,18 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); message = await source.getMessageAt(2); expect(message.mimeMessage.sequenceId, 99); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 99'); expect( message.mimeMessage.decodeDate(), @@ -1051,7 +1051,6 @@ void main() async { message = await source.getMessageAt(3); expect(message.mimeMessage.sequenceId, 19); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( message.mimeMessage.decodeDate(), @@ -1069,7 +1068,6 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); @@ -1077,14 +1075,12 @@ void main() async { message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 99); expect(message.mimeMessage.guid, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); message = await source.getMessageAt(2); expect(message.mimeMessage.sequenceId, 19); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( message.mimeMessage.decodeDate(), @@ -1103,21 +1099,18 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); message = await source.getMessageAt(2); expect(message.mimeMessage.sequenceId, 99); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 99'); expect( message.mimeMessage.decodeDate(), @@ -1127,7 +1120,6 @@ void main() async { message = await source.getMessageAt(3); expect(message.mimeMessage.sequenceId, 19); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( message.mimeMessage.decodeDate(), @@ -1146,7 +1138,6 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); expect(message.mimeMessage.decodeDate(), secondMimeSourceStartDate.toLocal()); @@ -1154,14 +1145,12 @@ void main() async { message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 99); expect(message.mimeMessage.guid, 100); - expect(message.mailClient, firstMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); message = await source.getMessageAt(2); expect(message.mimeMessage.sequenceId, 19); - expect(message.mailClient, secondMimeSource.mailClient); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( message.mimeMessage.decodeDate(), @@ -1243,14 +1232,18 @@ class TestNotificationService implements NotificationService { @override Future sendLocalNotificationForMail( - MimeMessage mimeMessage, MailClient mailClient) { + MimeMessage mimeMessage, + String accountEmail, + ) { _sendNotifications++; + return Future.value(); } @override Future sendLocalNotificationForMailLoadEvent(MailLoadEvent event) => - sendLocalNotificationForMail(event.message, event.mailClient); + sendLocalNotificationForMail( + event.message, event.mailClient.account.email); @override Future sendLocalNotificationForMailMessage(Message message) { From 5bafb52390be72ec6da691f90fb904add8718122 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Fri, 13 Oct 2023 08:59:01 +0200 Subject: [PATCH 05/95] feat: add mailbox-based in-account navigation --- lib/account/model.dart | 34 +- lib/account/model.g.dart | 2 + lib/account/providers.dart | 42 ++ lib/account/providers.g.dart | 319 ++++++++++++ lib/mail/provider.dart | 163 ++++-- lib/mail/provider.g.dart | 658 ++++++++++++++++++++++++- lib/models/message_source.dart | 111 +---- lib/routes.dart | 20 +- lib/screens/account_add_screen.dart | 66 ++- lib/screens/email_screen.dart | 48 ++ lib/screens/home_screen.dart | 3 +- lib/screens/mail_screen.dart | 13 +- lib/screens/message_source_screen.dart | 112 ++--- lib/screens/screens.dart | 1 + lib/services/mail_service.dart | 26 +- lib/widgets/app_drawer.dart | 38 +- lib/widgets/mailbox_tree.dart | 70 ++- 17 files changed, 1415 insertions(+), 311 deletions(-) create mode 100644 lib/screens/email_screen.dart diff --git a/lib/account/model.dart b/lib/account/model.dart index 70bd1af..68e7ebe 100644 --- a/lib/account/model.dart +++ b/lib/account/model.dart @@ -23,6 +23,22 @@ abstract class Account extends ChangeNotifier { /// The from address for this account MailAddress get fromAddress; + + /// The key for comparing accounts + String get key { + final value = _key ?? email.toLowerCase(); + _key = value; + + return value; + } + + String? _key; + + @override + int get hashCode => key.hashCode; + + @override + bool operator ==(Object other) => other is Account && other.key == key; } /// Allows to listen to mail account changes @@ -33,8 +49,7 @@ class RealAccount extends Account { MailAccount mailAccount, { this.appExtensions, this.contactManager, - }) : _account = mailAccount, - _key = mailAccount.email.toLowerCase(); + }) : _account = mailAccount; /// Creates a new [RealAccount] from JSON factory RealAccount.fromJson(Map json) => @@ -59,6 +74,9 @@ class RealAccount extends Account { /// Retrieves the mail account MailAccount get mailAccount => _account; + /// Does this account have a login error? + bool hasError = false; + @override bool get isVirtual => false; @@ -207,23 +225,11 @@ class RealAccount extends Account { bool get addsSentMailAutomatically => _account.attributes[attributeSentMailAddedAutomatically] ?? false; - /// Retrieves the key for comparing this account - String get key => _key; - - @JsonKey(includeFromJson: false, includeToJson: false) - final String _key; - /// [AppExtension]s are account specific additional setting retrieved /// from the server during initial setup /// Retrieves the app extensions List? appExtensions; - @override - bool operator ==(Object other) => other is RealAccount && other.key == key; - - @override - int get hashCode => key.hashCode; - /// Copies this account with the given [mailAccount] RealAccount copyWith({required MailAccount mailAccount}) => RealAccount( mailAccount, diff --git a/lib/account/model.g.dart b/lib/account/model.g.dart index 37f357b..e619eff 100644 --- a/lib/account/model.g.dart +++ b/lib/account/model.g.dart @@ -12,6 +12,7 @@ RealAccount _$RealAccountFromJson(Map json) => RealAccount( ?.map((e) => AppExtension.fromJson(e as Map)) .toList(), ) + ..hasError = json['hasError'] as bool ..excludeFromUnified = json['excludeFromUnified'] as bool ..signaturePlain = json['signaturePlain'] as String? ..userName = json['userName'] as String?; @@ -19,6 +20,7 @@ RealAccount _$RealAccountFromJson(Map json) => RealAccount( Map _$RealAccountToJson(RealAccount instance) => { 'mailAccount': instance.mailAccount, + 'hasError': instance.hasError, 'excludeFromUnified': instance.excludeFromUnified, 'signaturePlain': instance.signaturePlain, 'userName': instance.userName, diff --git a/lib/account/providers.dart b/lib/account/providers.dart index 9650a15..256be48 100644 --- a/lib/account/providers.dart +++ b/lib/account/providers.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'model.dart'; @@ -73,3 +74,44 @@ class AllAccounts extends _$AllAccounts { ]; } } + +//// Finds an account by its email +@Riverpod(keepAlive: true) +Account findAccountByEmail( + FindAccountByEmailRef ref, { + required String email, +}) { + final key = email.toLowerCase(); + final realAccounts = ref.watch(realAccountsProvider); + final unifiedAccount = ref.watch(unifiedAccountProvider); + + final account = realAccounts.firstWhereOrNull((a) => a.key == key) ?? + ((unifiedAccount?.key == key) ? unifiedAccount : null); + if (account == null) { + throw StateError('account not found for $email'); + } + + return account; +} + +//// Finds a real account by its email +@Riverpod(keepAlive: true) +RealAccount findRealAccountByEmail( + FindRealAccountByEmailRef ref, { + required String email, +}) { + final key = email.toLowerCase(); + final realAccounts = ref.watch(realAccountsProvider); + + return realAccounts.firstWhere((a) => a.key == key); +} + +//// Checks if there is at least one real account with a login error +@Riverpod(keepAlive: true) +bool hasAccountWithError( + HasAccountWithErrorRef ref, +) { + final realAccounts = ref.watch(realAccountsProvider); + + return realAccounts.any((a) => a.hasError); +} diff --git a/lib/account/providers.g.dart b/lib/account/providers.g.dart index aee8413..233e047 100644 --- a/lib/account/providers.g.dart +++ b/lib/account/providers.g.dart @@ -23,6 +23,325 @@ final unifiedAccountProvider = Provider.internal( ); typedef UnifiedAccountRef = ProviderRef; +String _$findAccountByEmailHash() => + r'd098fc64ea914fb4ba974196600a1386546c4e70'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +//// Finds an account by its email +/// +/// Copied from [findAccountByEmail]. +@ProviderFor(findAccountByEmail) +const findAccountByEmailProvider = FindAccountByEmailFamily(); + +//// Finds an account by its email +/// +/// Copied from [findAccountByEmail]. +class FindAccountByEmailFamily extends Family { + //// Finds an account by its email + /// + /// Copied from [findAccountByEmail]. + const FindAccountByEmailFamily(); + + //// Finds an account by its email + /// + /// Copied from [findAccountByEmail]. + FindAccountByEmailProvider call({ + required String email, + }) { + return FindAccountByEmailProvider( + email: email, + ); + } + + @override + FindAccountByEmailProvider getProviderOverride( + covariant FindAccountByEmailProvider provider, + ) { + return call( + email: provider.email, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'findAccountByEmailProvider'; +} + +//// Finds an account by its email +/// +/// Copied from [findAccountByEmail]. +class FindAccountByEmailProvider extends Provider { + //// Finds an account by its email + /// + /// Copied from [findAccountByEmail]. + FindAccountByEmailProvider({ + required String email, + }) : this._internal( + (ref) => findAccountByEmail( + ref as FindAccountByEmailRef, + email: email, + ), + from: findAccountByEmailProvider, + name: r'findAccountByEmailProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$findAccountByEmailHash, + dependencies: FindAccountByEmailFamily._dependencies, + allTransitiveDependencies: + FindAccountByEmailFamily._allTransitiveDependencies, + email: email, + ); + + FindAccountByEmailProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.email, + }) : super.internal(); + + final String email; + + @override + Override overrideWith( + Account Function(FindAccountByEmailRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FindAccountByEmailProvider._internal( + (ref) => create(ref as FindAccountByEmailRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + email: email, + ), + ); + } + + @override + ProviderElement createElement() { + return _FindAccountByEmailProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FindAccountByEmailProvider && other.email == email; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, email.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin FindAccountByEmailRef on ProviderRef { + /// The parameter `email` of this provider. + String get email; +} + +class _FindAccountByEmailProviderElement extends ProviderElement + with FindAccountByEmailRef { + _FindAccountByEmailProviderElement(super.provider); + + @override + String get email => (origin as FindAccountByEmailProvider).email; +} + +String _$findRealAccountByEmailHash() => + r'5738473cdcb5e7c531051904276d03d20dfe7f1e'; + +//// Finds a real account by its email +/// +/// Copied from [findRealAccountByEmail]. +@ProviderFor(findRealAccountByEmail) +const findRealAccountByEmailProvider = FindRealAccountByEmailFamily(); + +//// Finds a real account by its email +/// +/// Copied from [findRealAccountByEmail]. +class FindRealAccountByEmailFamily extends Family { + //// Finds a real account by its email + /// + /// Copied from [findRealAccountByEmail]. + const FindRealAccountByEmailFamily(); + + //// Finds a real account by its email + /// + /// Copied from [findRealAccountByEmail]. + FindRealAccountByEmailProvider call({ + required String email, + }) { + return FindRealAccountByEmailProvider( + email: email, + ); + } + + @override + FindRealAccountByEmailProvider getProviderOverride( + covariant FindRealAccountByEmailProvider provider, + ) { + return call( + email: provider.email, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'findRealAccountByEmailProvider'; +} + +//// Finds a real account by its email +/// +/// Copied from [findRealAccountByEmail]. +class FindRealAccountByEmailProvider extends Provider { + //// Finds a real account by its email + /// + /// Copied from [findRealAccountByEmail]. + FindRealAccountByEmailProvider({ + required String email, + }) : this._internal( + (ref) => findRealAccountByEmail( + ref as FindRealAccountByEmailRef, + email: email, + ), + from: findRealAccountByEmailProvider, + name: r'findRealAccountByEmailProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$findRealAccountByEmailHash, + dependencies: FindRealAccountByEmailFamily._dependencies, + allTransitiveDependencies: + FindRealAccountByEmailFamily._allTransitiveDependencies, + email: email, + ); + + FindRealAccountByEmailProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.email, + }) : super.internal(); + + final String email; + + @override + Override overrideWith( + RealAccount Function(FindRealAccountByEmailRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FindRealAccountByEmailProvider._internal( + (ref) => create(ref as FindRealAccountByEmailRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + email: email, + ), + ); + } + + @override + ProviderElement createElement() { + return _FindRealAccountByEmailProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FindRealAccountByEmailProvider && other.email == email; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, email.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin FindRealAccountByEmailRef on ProviderRef { + /// The parameter `email` of this provider. + String get email; +} + +class _FindRealAccountByEmailProviderElement + extends ProviderElement with FindRealAccountByEmailRef { + _FindRealAccountByEmailProviderElement(super.provider); + + @override + String get email => (origin as FindRealAccountByEmailProvider).email; +} + +String _$hasAccountWithErrorHash() => + r'df9f05a11751823686a4b6dc985e5cae0224a07f'; //// Checks if there is at least one real account with a login error +/// +/// Copied from [hasAccountWithError]. +@ProviderFor(hasAccountWithError) +final hasAccountWithErrorProvider = Provider.internal( + hasAccountWithError, + name: r'hasAccountWithErrorProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$hasAccountWithErrorHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef HasAccountWithErrorRef = ProviderRef; String _$realAccountsHash() => r'3ff51534497e5e36a7b0e0f19dc0e5fb09cfdcfe'; /// Provides all real email accounts diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index dbcdf23..0a39f64 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -6,6 +6,7 @@ import '../account/model.dart'; import '../account/providers.dart'; import '../events/app_event_bus.dart'; import '../locator.dart'; +import '../models/async_mime_source.dart'; import '../models/async_mime_source_factory.dart'; import '../models/message_source.dart'; import '../services/providers.dart'; @@ -15,26 +16,84 @@ part 'provider.g.dart'; /// Provides the message source for the given account @Riverpod(keepAlive: true) class Source extends _$Source { + @override + Future build({ + required Account account, + Mailbox? mailbox, + }) { + if (account is RealAccount) { + return ref.watch( + realSourceProvider(account: account, mailbox: mailbox).future, + ); + } + if (account is UnifiedAccount) { + return ref.watch( + unifiedSourceProvider(account: account, mailbox: mailbox).future, + ); + } + throw UnimplementedError('for account $account'); + } +} + +/// Provides the message source for the given account +@Riverpod(keepAlive: true) +class UnifiedSource extends _$UnifiedSource { + @override + Future build({ + required UnifiedAccount account, + Mailbox? mailbox, + }) async { + Future resolve( + RealAccount realAccount, + Mailbox? mailbox, + ) async { + var usedMailbox = mailbox; + final flag = mailbox?.identityFlag; + + if (mailbox != null && mailbox.isVirtual && flag != null) { + final mailboxTree = await ref.watch( + mailboxTreeProvider(account: realAccount).future, + ); + usedMailbox = mailboxTree.firstWhereOrNull( + (m) => m?.flags.contains(flag) ?? false, + ); + } + + final source = await ref.watch( + realSourceProvider(account: realAccount, mailbox: usedMailbox).future, + ); + + return source.mimeSource; + } + + final accounts = account.accounts; + final futureSources = accounts.map( + (a) => resolve(a, mailbox), + ); + final mimeSources = await Future.wait(futureSources); + + return MultipleMessageSource( + mimeSources, + account.name, + mailbox?.identityFlag ?? MailboxFlag.inbox, + account: account, + ); + } +} + +/// Provides the message source for the given account +@Riverpod(keepAlive: true) +class RealSource extends _$RealSource { static const _clientId = Id(name: 'Maily', version: '1.0'); final _mailClientsPerAccount = {}; final _mimeSourceFactory = const AsyncMimeSourceFactory(isOfflineModeSupported: false); @override - Future build({required Account account, Mailbox? mailbox}) { - if (account is RealAccount) { - return _buildRealAccount(account, mailbox); - } else if (account is UnifiedAccount) { - return _buildUnifiedAccount(account, mailbox); - } else { - throw UnimplementedError(); - } - } - - Future _buildRealAccount( - RealAccount account, [ + Future build({ + required RealAccount account, Mailbox? mailbox, - ]) async { + }) async { final mailClient = await _getClientAndStopPolling(account); if (mailClient == null) { throw Exception('Unable to connect to server'); @@ -58,15 +117,8 @@ class Source extends _$Source { ); } - Future _buildUnifiedAccount( - UnifiedAccount account, [ - Mailbox? mailbox, - ]) { - throw UnimplementedError(); - } - Future _getClientAndStopPolling(RealAccount account) async { - final client = await getClientFor(account); + final client = await _getClientFor(account); await client.stopPollingIfNeeded(); if (!client.isConnected) { await client.connect(); @@ -75,26 +127,25 @@ class Source extends _$Source { return client; } - Future getClientFor( + Future _getClientFor( RealAccount account, ) async => - _mailClientsPerAccount[account] ?? await createClientFor(account); + _mailClientsPerAccount[account] ?? await _createClientFor(account); - Future createClientFor( + Future _createClientFor( RealAccount account, { bool store = true, }) async { - final client = createMailClient(account.mailAccount); + final client = _createMailClient(account.mailAccount); if (store) { _mailClientsPerAccount[account] = client; } await client.connect(); - await _loadMailboxesFor(client); return client; } - MailClient createMailClient(MailAccount mailAccount) { + MailClient _createMailClient(MailAccount mailAccount) { final bool isLogEnabled = kDebugMode || (mailAccount.attributes[RealAccount.attributeEnableLogging] ?? false); @@ -139,23 +190,47 @@ class Source extends _$Source { return oauthClient.refresh(expiredToken); } +} - Future _loadMailboxesFor(MailClient client) async { - //final account = getAccountFor(client.account); - // if (account == null) { - // if (kDebugMode) { - // print('Unable to find account for ${client.account}'); - // } - - // return; - // } - final mailboxTree = - await client.listMailboxesAsTree(createIntermediate: false); - // final settings = _settings; - // if (settings.folderNameSetting != FolderNameSetting.server) { - // _setMailboxNames(settings, client); - // } - - // _mailboxesPerAccount[account] = mailboxTree; +//// Loads the mailbox tree for the given account +@Riverpod(keepAlive: true) +Future> mailboxTree( + MailboxTreeRef ref, { + required Account account, +}) async { + if (account is RealAccount) { + final source = await ref.watch(realSourceProvider(account: account).future); + + return source.mimeSource.mailClient + .listMailboxesAsTree(createIntermediate: false); + } else if (account is UnifiedAccount) { + final mailboxes = [ + MailboxFlag.inbox, + MailboxFlag.drafts, + MailboxFlag.sent, + MailboxFlag.trash, + MailboxFlag.archive, + MailboxFlag.junk, + ].map((f) => Mailbox.virtual(f.name, [f])).toList(); + + return Tree(Mailbox.virtual('', [])) + ..populateFromList(mailboxes, (child) => null); + } else { + throw UnimplementedError('for account $account'); } } + +//// Loads the mailbox tree for the given account +@Riverpod(keepAlive: true) +Future findMailbox( + FindMailboxRef ref, { + required Account account, + required String encodedMailboxPath, +}) async { + final tree = await ref.watch(mailboxTreeProvider(account: account).future); + + final mailbox = + tree.firstWhereOrNull((m) => m?.encodedPath == encodedMailboxPath); + + return mailbox; +} diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart index e0cfb2c..59a9048 100644 --- a/lib/mail/provider.g.dart +++ b/lib/mail/provider.g.dart @@ -6,7 +6,7 @@ part of 'provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$sourceHash() => r'31611430840b7466b5536360064e85929183dfdf'; +String _$mailboxTreeHash() => r'b1ccb0f9abb23fa230f80618370e187d21b10fac'; /// Copied from Dart SDK class _SystemHash { @@ -29,6 +29,304 @@ class _SystemHash { } } +//// Loads the mailbox tree for the given account +/// +/// Copied from [mailboxTree]. +@ProviderFor(mailboxTree) +const mailboxTreeProvider = MailboxTreeFamily(); + +//// Loads the mailbox tree for the given account +/// +/// Copied from [mailboxTree]. +class MailboxTreeFamily extends Family>> { + //// Loads the mailbox tree for the given account + /// + /// Copied from [mailboxTree]. + const MailboxTreeFamily(); + + //// Loads the mailbox tree for the given account + /// + /// Copied from [mailboxTree]. + MailboxTreeProvider call({ + required Account account, + }) { + return MailboxTreeProvider( + account: account, + ); + } + + @override + MailboxTreeProvider getProviderOverride( + covariant MailboxTreeProvider provider, + ) { + return call( + account: provider.account, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'mailboxTreeProvider'; +} + +//// Loads the mailbox tree for the given account +/// +/// Copied from [mailboxTree]. +class MailboxTreeProvider extends FutureProvider> { + //// Loads the mailbox tree for the given account + /// + /// Copied from [mailboxTree]. + MailboxTreeProvider({ + required Account account, + }) : this._internal( + (ref) => mailboxTree( + ref as MailboxTreeRef, + account: account, + ), + from: mailboxTreeProvider, + name: r'mailboxTreeProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$mailboxTreeHash, + dependencies: MailboxTreeFamily._dependencies, + allTransitiveDependencies: + MailboxTreeFamily._allTransitiveDependencies, + account: account, + ); + + MailboxTreeProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + }) : super.internal(); + + final Account account; + + @override + Override overrideWith( + FutureOr> Function(MailboxTreeRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: MailboxTreeProvider._internal( + (ref) => create(ref as MailboxTreeRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + ), + ); + } + + @override + FutureProviderElement> createElement() { + return _MailboxTreeProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MailboxTreeProvider && other.account == account; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin MailboxTreeRef on FutureProviderRef> { + /// The parameter `account` of this provider. + Account get account; +} + +class _MailboxTreeProviderElement extends FutureProviderElement> + with MailboxTreeRef { + _MailboxTreeProviderElement(super.provider); + + @override + Account get account => (origin as MailboxTreeProvider).account; +} + +String _$findMailboxHash() => r'cf69ac27d256f03c561fcc130eacc4348974ac09'; + +//// Loads the mailbox tree for the given account +/// +/// Copied from [findMailbox]. +@ProviderFor(findMailbox) +const findMailboxProvider = FindMailboxFamily(); + +//// Loads the mailbox tree for the given account +/// +/// Copied from [findMailbox]. +class FindMailboxFamily extends Family> { + //// Loads the mailbox tree for the given account + /// + /// Copied from [findMailbox]. + const FindMailboxFamily(); + + //// Loads the mailbox tree for the given account + /// + /// Copied from [findMailbox]. + FindMailboxProvider call({ + required Account account, + required String encodedMailboxPath, + }) { + return FindMailboxProvider( + account: account, + encodedMailboxPath: encodedMailboxPath, + ); + } + + @override + FindMailboxProvider getProviderOverride( + covariant FindMailboxProvider provider, + ) { + return call( + account: provider.account, + encodedMailboxPath: provider.encodedMailboxPath, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'findMailboxProvider'; +} + +//// Loads the mailbox tree for the given account +/// +/// Copied from [findMailbox]. +class FindMailboxProvider extends FutureProvider { + //// Loads the mailbox tree for the given account + /// + /// Copied from [findMailbox]. + FindMailboxProvider({ + required Account account, + required String encodedMailboxPath, + }) : this._internal( + (ref) => findMailbox( + ref as FindMailboxRef, + account: account, + encodedMailboxPath: encodedMailboxPath, + ), + from: findMailboxProvider, + name: r'findMailboxProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$findMailboxHash, + dependencies: FindMailboxFamily._dependencies, + allTransitiveDependencies: + FindMailboxFamily._allTransitiveDependencies, + account: account, + encodedMailboxPath: encodedMailboxPath, + ); + + FindMailboxProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.encodedMailboxPath, + }) : super.internal(); + + final Account account; + final String encodedMailboxPath; + + @override + Override overrideWith( + FutureOr Function(FindMailboxRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: FindMailboxProvider._internal( + (ref) => create(ref as FindMailboxRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + encodedMailboxPath: encodedMailboxPath, + ), + ); + } + + @override + FutureProviderElement createElement() { + return _FindMailboxProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FindMailboxProvider && + other.account == account && + other.encodedMailboxPath == encodedMailboxPath; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, encodedMailboxPath.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin FindMailboxRef on FutureProviderRef { + /// The parameter `account` of this provider. + Account get account; + + /// The parameter `encodedMailboxPath` of this provider. + String get encodedMailboxPath; +} + +class _FindMailboxProviderElement extends FutureProviderElement + with FindMailboxRef { + _FindMailboxProviderElement(super.provider); + + @override + Account get account => (origin as FindMailboxProvider).account; + @override + String get encodedMailboxPath => + (origin as FindMailboxProvider).encodedMailboxPath; +} + +String _$sourceHash() => r'ed4bfa87f9547328583d2c849f27a43200a6df1f'; + abstract class _$Source extends BuildlessAsyncNotifier { late final Account account; late final Mailbox? mailbox; @@ -200,5 +498,363 @@ class _SourceProviderElement @override Mailbox? get mailbox => (origin as SourceProvider).mailbox; } + +String _$unifiedSourceHash() => r'de509cae0ff4f5d7d917b94a192edc45cd7fc398'; + +abstract class _$UnifiedSource + extends BuildlessAsyncNotifier { + late final UnifiedAccount account; + late final Mailbox? mailbox; + + Future build({ + required UnifiedAccount account, + Mailbox? mailbox, + }); +} + +/// Provides the message source for the given account +/// +/// Copied from [UnifiedSource]. +@ProviderFor(UnifiedSource) +const unifiedSourceProvider = UnifiedSourceFamily(); + +/// Provides the message source for the given account +/// +/// Copied from [UnifiedSource]. +class UnifiedSourceFamily extends Family> { + /// Provides the message source for the given account + /// + /// Copied from [UnifiedSource]. + const UnifiedSourceFamily(); + + /// Provides the message source for the given account + /// + /// Copied from [UnifiedSource]. + UnifiedSourceProvider call({ + required UnifiedAccount account, + Mailbox? mailbox, + }) { + return UnifiedSourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + UnifiedSourceProvider getProviderOverride( + covariant UnifiedSourceProvider provider, + ) { + return call( + account: provider.account, + mailbox: provider.mailbox, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'unifiedSourceProvider'; +} + +/// Provides the message source for the given account +/// +/// Copied from [UnifiedSource]. +class UnifiedSourceProvider + extends AsyncNotifierProviderImpl { + /// Provides the message source for the given account + /// + /// Copied from [UnifiedSource]. + UnifiedSourceProvider({ + required UnifiedAccount account, + Mailbox? mailbox, + }) : this._internal( + () => UnifiedSource() + ..account = account + ..mailbox = mailbox, + from: unifiedSourceProvider, + name: r'unifiedSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$unifiedSourceHash, + dependencies: UnifiedSourceFamily._dependencies, + allTransitiveDependencies: + UnifiedSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + UnifiedSourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.mailbox, + }) : super.internal(); + + final UnifiedAccount account; + final Mailbox? mailbox; + + @override + Future runNotifierBuild( + covariant UnifiedSource notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(UnifiedSource Function() create) { + return ProviderOverride( + origin: this, + override: UnifiedSourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AsyncNotifierProviderElement + createElement() { + return _UnifiedSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is UnifiedSourceProvider && + other.account == account && + other.mailbox == mailbox; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin UnifiedSourceRef on AsyncNotifierProviderRef { + /// The parameter `account` of this provider. + UnifiedAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _UnifiedSourceProviderElement + extends AsyncNotifierProviderElement + with UnifiedSourceRef { + _UnifiedSourceProviderElement(super.provider); + + @override + UnifiedAccount get account => (origin as UnifiedSourceProvider).account; + @override + Mailbox? get mailbox => (origin as UnifiedSourceProvider).mailbox; +} + +String _$realSourceHash() => r'463138cc3fd08bab9e850e7591385610bf33bfb2'; + +abstract class _$RealSource + extends BuildlessAsyncNotifier { + late final RealAccount account; + late final Mailbox? mailbox; + + Future build({ + required RealAccount account, + Mailbox? mailbox, + }); +} + +/// Provides the message source for the given account +/// +/// Copied from [RealSource]. +@ProviderFor(RealSource) +const realSourceProvider = RealSourceFamily(); + +/// Provides the message source for the given account +/// +/// Copied from [RealSource]. +class RealSourceFamily extends Family> { + /// Provides the message source for the given account + /// + /// Copied from [RealSource]. + const RealSourceFamily(); + + /// Provides the message source for the given account + /// + /// Copied from [RealSource]. + RealSourceProvider call({ + required RealAccount account, + Mailbox? mailbox, + }) { + return RealSourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + RealSourceProvider getProviderOverride( + covariant RealSourceProvider provider, + ) { + return call( + account: provider.account, + mailbox: provider.mailbox, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'realSourceProvider'; +} + +/// Provides the message source for the given account +/// +/// Copied from [RealSource]. +class RealSourceProvider + extends AsyncNotifierProviderImpl { + /// Provides the message source for the given account + /// + /// Copied from [RealSource]. + RealSourceProvider({ + required RealAccount account, + Mailbox? mailbox, + }) : this._internal( + () => RealSource() + ..account = account + ..mailbox = mailbox, + from: realSourceProvider, + name: r'realSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$realSourceHash, + dependencies: RealSourceFamily._dependencies, + allTransitiveDependencies: + RealSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + RealSourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.mailbox, + }) : super.internal(); + + final RealAccount account; + final Mailbox? mailbox; + + @override + Future runNotifierBuild( + covariant RealSource notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(RealSource Function() create) { + return ProviderOverride( + origin: this, + override: RealSourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AsyncNotifierProviderElement + createElement() { + return _RealSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is RealSourceProvider && + other.account == account && + other.mailbox == mailbox; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin RealSourceRef on AsyncNotifierProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _RealSourceProviderElement + extends AsyncNotifierProviderElement + with RealSourceRef { + _RealSourceProviderElement(super.provider); + + @override + RealAccount get account => (origin as RealSourceProvider).account; + @override + Mailbox? get mailbox => (origin as RealSourceProvider).mailbox; +} // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index a5ad6ee..09a906b 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -612,7 +612,7 @@ abstract class MessageSource extends ChangeNotifier class MailboxMessageSource extends MessageSource { MailboxMessageSource.fromMimeSource( - this._mimeSource, + this.mimeSource, String description, String name, { required this.account, @@ -621,44 +621,45 @@ class MailboxMessageSource extends MessageSource { }) { _description = description; _name = name; - _mimeSource.addSubscriber(this); + mimeSource.addSubscriber(this); } @override final RealAccount account; @override - int get size => _mimeSource.size; + int get size => mimeSource.size; - final AsyncMimeSource _mimeSource; + final AsyncMimeSource mimeSource; @override void dispose() { - _mimeSource.removeSubscriber(this); - _mimeSource.dispose(); + mimeSource + ..removeSubscriber(this) + ..dispose(); super.dispose(); } @override Future loadMessage(int index) async { //print('get uncached $index'); - final mime = await _mimeSource.getMessage(index); + final mime = await mimeSource.getMessage(index); return Message(mime, this, index); } @override Future init() async { - await _mimeSource.init(); - name ??= _mimeSource.name; - supportsDeleteAll = _mimeSource.supportsDeleteAll; + await mimeSource.init(); + name ??= mimeSource.name; + supportsDeleteAll = mimeSource.supportsDeleteAll; } @override Future> deleteAllMessages({bool expunge = false}) async { final removedMessages = cache.getAllCachedEntries(); cache.clear(); - final futureResults = _mimeSource.deleteAllMessages(expunge: expunge); + final futureResults = mimeSource.deleteAllMessages(expunge: expunge); clear(); notifyListeners(); final results = await futureResults; @@ -676,7 +677,7 @@ class MailboxMessageSource extends MessageSource { @override Future markAllMessagesSeen(bool seen) async { cache.markAllMessageSeen(seen); - await _mimeSource.storeAll( + await mimeSource.storeAll( [MessageFlags.seen], action: seen ? StoreAction.add : StoreAction.remove, ); @@ -685,29 +686,29 @@ class MailboxMessageSource extends MessageSource { } @override - bool get shouldBlockImages => _mimeSource.shouldBlockImages; + bool get shouldBlockImages => mimeSource.shouldBlockImages; @override - bool get isJunk => _mimeSource.isJunk; + bool get isJunk => mimeSource.isJunk; @override - bool get isArchive => _mimeSource.isArchive; + bool get isArchive => mimeSource.isArchive; @override - bool get isTrash => _mimeSource.isTrash; + bool get isTrash => mimeSource.isTrash; @override - bool get isSent => _mimeSource.isSent; + bool get isSent => mimeSource.isSent; @override - bool get supportsMessageFolders => _mimeSource.supportsMessageFolders; + bool get supportsMessageFolders => mimeSource.supportsMessageFolders; @override - bool get supportsSearching => _mimeSource.supportsSearching; + bool get supportsSearching => mimeSource.supportsSearching; @override MessageSource search(MailSearch search) { - final searchSource = _mimeSource.search(search); + final searchSource = mimeSource.search(search); final localizations = locator().localizations; return MailboxMessageSource.fromMimeSource( @@ -721,12 +722,12 @@ class MailboxMessageSource extends MessageSource { } @override - AsyncMimeSource? getMimeSource(Message message) => _mimeSource; + AsyncMimeSource? getMimeSource(Message message) => mimeSource; @override void clear() { cache.clear(); - _mimeSource.clear(); + mimeSource.clear(); } @override @@ -1247,72 +1248,6 @@ class ListMessageSource extends MessageSource { } } -class ErrorMessageSource extends MessageSource { - ErrorMessageSource(this.account); - @override - final Account account; - - @override - Future loadMessage(int index) { - throw UnimplementedError(); - } - - @override - void clear() {} - - @override - Future> deleteAllMessages({bool expunge = false}) { - throw UnimplementedError(); - } - - @override - AsyncMimeSource getMimeSource(Message message) { - throw UnimplementedError(); - } - - @override - Future init() => Future.value(); - - @override - bool get isArchive => false; - - @override - bool get isJunk => false; - - @override - bool get isTrash => false; - - @override - bool get isSent => false; - - @override - Future markAllMessagesSeen(bool seen) { - throw UnimplementedError(); - } - - @override - MessageSource search(MailSearch search) { - throw UnimplementedError(); - } - - @override - bool get shouldBlockImages => false; - - @override - int get size => 0; - - @override - bool get supportsMessageFolders => false; - - @override - bool get supportsSearching => false; - - @override - void onMailCacheInvalidated(AsyncMimeSource source) { - // TODO: implement onMailCacheInvalidated - } -} - // class ThreadedMailboxMessageSource extends MailboxMessageSource { // ThreadedMailboxMessageSource(Mailbox mailbox, MailClient mailClient) // : super.fromMimeSource(ThreadedMimeSource(mailbox, mailClient), diff --git a/lib/routes.dart b/lib/routes.dart index 0d38017..9b5c2ef 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -21,6 +21,7 @@ class Routes { static const String accountAdd = '/accountAdd'; static const String accountEdit = '/accountEdit'; static const String accountServerDetails = '/accountServerDetails'; + static const String mail = '/mail'; static const String settings = '/settings'; static const String settingsSecurity = '/settings/security'; static const String settingsAccounts = '/settings/accounts'; @@ -48,6 +49,12 @@ class Routes { static const String appDrawer = '/appDrawer'; static const String lockScreen = '/lock'; + /// Path parameter name for an email address + static const String pathParameterEmail = 'email'; + + /// Path parameter name for an encoded mailbox path + static const String pathParameterEncodedMailboxPath = 'mailbox'; + static final navigatorKey = GlobalKey(); /// The routing configuration @@ -62,14 +69,19 @@ class Routes { path: splash, builder: (context, state) => const SplashScreen(), ), - GoRoute( - path: splash, - builder: (context, state) => const SplashScreen(), - ), GoRoute( path: home, builder: (context, state) => const HomeScreen(), ), + GoRoute( + name: mail, + path: '$mail/:$pathParameterEmail/:$pathParameterEncodedMailboxPath', + builder: (context, state) => EMailScreen( + email: state.pathParameters[pathParameterEmail] ?? '', + encodedMailboxPath: + state.pathParameters[pathParameterEncodedMailboxPath], + ), + ), ], ); } diff --git a/lib/screens/account_add_screen.dart b/lib/screens/account_add_screen.dart index f057501..fe9a635 100644 --- a/lib/screens/account_add_screen.dart +++ b/lib/screens/account_add_screen.dart @@ -4,9 +4,12 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; import '../account/model.dart'; +import '../account/providers.dart'; import '../extensions/extensions.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; @@ -14,7 +17,7 @@ import '../locator.dart'; import '../routes.dart'; import '../services/mail_service.dart'; import '../services/navigation_service.dart'; -import '../services/providers.dart'; +import '../services/providers.dart' as mailProviders; import '../util/modal_bottom_sheet_helper.dart'; import '../util/validator.dart'; import '../widgets/account_provider_selector.dart'; @@ -22,7 +25,7 @@ import '../widgets/button_text.dart'; import '../widgets/password_field.dart'; import 'base.dart'; -class AccountAddScreen extends StatefulWidget { +class AccountAddScreen extends ConsumerStatefulWidget { const AccountAddScreen({ super.key, required this.launchedFromWelcome, @@ -30,10 +33,10 @@ class AccountAddScreen extends StatefulWidget { final bool launchedFromWelcome; @override - State createState() => _AccountAddScreenState(); + ConsumerState createState() => _AccountAddScreenState(); } -class _AccountAddScreenState extends State { +class _AccountAddScreenState extends ConsumerState { static const int _stepEmail = 0; static const int _stepPassword = 1; static const int _stepAccountSetup = 2; @@ -49,7 +52,7 @@ class _AccountAddScreenState extends State { final TextEditingController _userNameController = TextEditingController(); bool _isProviderResolving = false; - Provider? _provider; + mailProviders.Provider? _provider; final bool _isManualSettings = false; bool _isAccountVerifying = false; bool _isAccountVerified = false; @@ -59,8 +62,10 @@ class _AccountAddScreenState extends State { RealAccount? _realAccount; Future _navigateToManualSettings( - BuildContext context, AppLocalizations localizations) async { - Provider? selectedProvider; + BuildContext context, + AppLocalizations localizations, + ) async { + mailProviders.Provider? selectedProvider; final result = await ModelBottomSheetHelper.showModalBottomSheet( context, localizations.accountProviderStepTitle, @@ -113,9 +118,9 @@ class _AccountAddScreenState extends State { @override void initState() { _availableSteps = 3; - final accounts = locator().accounts; + final accounts = ref.read(realAccountsProvider); if (accounts.isNotEmpty) { - _userNameController.text = (accounts.first as RealAccount).userName ?? ''; + _userNameController.text = accounts.first.userName ?? ''; } super.initState(); } @@ -124,6 +129,7 @@ class _AccountAddScreenState extends State { Widget build(BuildContext context) { // print('build: current step=$_currentStep'); final localizations = context.text; + return Base.buildAppChrome( context, title: localizations.addAccountTitle, @@ -190,7 +196,8 @@ class _AccountAddScreenState extends State { if (kDebugMode) { print('discover settings for $email'); } - final provider = await locator().discover(email); + final provider = + await locator().discover(email); if (!mounted) { // ignore if user has cancelled operation return; @@ -216,7 +223,7 @@ class _AccountAddScreenState extends State { }); } - Future _loginWithOAuth(Provider provider, String email) async { + Future _loginWithOAuth(mailProviders.Provider provider, String email) async { setState(() { _isAccountVerifying = true; _currentStep = _stepAccountSetup; @@ -297,25 +304,30 @@ class _AccountAddScreenState extends State { if (kDebugMode) { print('No account or mail client available'); } + return; } // Account name has been specified - account.name = _accountNameController.text; - account.userName = _userNameController.text; - final service = locator(); - final added = await service.addAccount(account, mailClient); - if (added) { - if (Platform.isIOS && widget.launchedFromWelcome) { - await locator().push(Routes.appDrawer, clear: true); - } - await locator().push( - Routes.messageSource, - arguments: service.messageSource, - clear: !Platform.isIOS && widget.launchedFromWelcome, - replace: !widget.launchedFromWelcome, - fade: true, - ); + account + ..name = _accountNameController.text + ..userName = _userNameController.text; + ref.read(realAccountsProvider.notifier).addAccount(account); + final bool goToAppDrawer = Platform.isIOS && widget.launchedFromWelcome; + if (goToAppDrawer) { + context.goNamed(Routes.appDrawer); + // await locator().push(Routes.appDrawer, clear: true); } + context.goNamed( + Routes.mail, + pathParameters: {Routes.pathParameterEmail: account.key}, + ); + // locator().push( + // Routes.messageSource, + // arguments: service.messageSource, + // clear: !Platform.isIOS && widget.launchedFromWelcome, + // replace: !widget.launchedFromWelcome, + // fade: true, + // ); } Step _buildEmailStep(BuildContext context, AppLocalizations localizations) => @@ -611,7 +623,7 @@ class _AccountAddScreenState extends State { ), ); - void _onProviderChanged(Provider provider, String email) { + void _onProviderChanged(mailProviders.Provider provider, String email) { final mailAccount = MailAccount.fromDiscoveredSettings( name: _emailController.text, email: _emailController.text, diff --git a/lib/screens/email_screen.dart b/lib/screens/email_screen.dart new file mode 100644 index 0000000..0580649 --- /dev/null +++ b/lib/screens/email_screen.dart @@ -0,0 +1,48 @@ +import 'package:enough_platform_widgets/enough_platform_widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../account/providers.dart'; +import '../mail/provider.dart'; +import 'mail_screen.dart'; + +/// Displays the mail for a given account +class EMailScreen extends ConsumerWidget { + /// Creates a [EMailScreen] + const EMailScreen({super.key, required this.email, this.encodedMailboxPath}); + + /// The email of the account to display + final String email; + + /// The optional mailbox encoded path + final String? encodedMailboxPath; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final account = ref.watch(findAccountByEmailProvider(email: email)); + final encodedMailboxPath = this.encodedMailboxPath; + if (encodedMailboxPath == null) { + return MailScreen(account: account); + } + + final mailboxValue = ref.watch( + findMailboxProvider( + account: account, + encodedMailboxPath: encodedMailboxPath, + ), + ); + + return mailboxValue.when( + loading: () => const Center( + child: PlatformProgressIndicator(), + ), + error: (error, stack) => Center( + child: Text('$error'), + ), + data: (mailbox) => MailScreen( + account: account, + mailbox: mailbox, + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 60ecc8b..6c01264 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../account/model.dart'; import '../account/providers.dart'; import '../settings/provider.dart'; import 'screens.dart'; @@ -24,7 +23,7 @@ class HomeScreen extends ConsumerWidget { } return MailScreen( - account: accounts.firstWhere((a) => a is RealAccount), + account: accounts.first, ); } } diff --git a/lib/screens/mail_screen.dart b/lib/screens/mail_screen.dart index 62e4001..8719af2 100644 --- a/lib/screens/mail_screen.dart +++ b/lib/screens/mail_screen.dart @@ -1,3 +1,4 @@ +import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/platform.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -11,15 +12,23 @@ import 'message_source_screen.dart'; /// Displays the mail for a given account class MailScreen extends ConsumerWidget { /// Creates a [MailScreen] - const MailScreen({super.key, required this.account}); + const MailScreen({super.key, required this.account, this.mailbox}); /// The account to display final Account account; + /// The optional mailbox + final Mailbox? mailbox; + @override Widget build(BuildContext context, WidgetRef ref) { final text = context.text; - final sourceFuture = ref.watch(sourceProvider(account: account)); + final sourceFuture = ref.watch( + sourceProvider( + account: account, + mailbox: mailbox, + ), + ); final title = account is UnifiedAccount ? text.unifiedAccountName : account.name; final subtitle = account.fromAddress.email; diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index 2d14111..3eed841 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../account/providers.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; @@ -17,7 +18,6 @@ import '../models/swipe.dart'; import '../routes.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; import '../services/navigation_service.dart'; import '../services/notification_service.dart'; import '../services/scaffold_messenger_service.dart'; @@ -92,7 +92,6 @@ class _MessageSourceScreenState extends ConsumerState bool _isInSearchMode = false; bool _hasSearchInput = false; late TextEditingController _searchEditingController; - bool _updateMessageSource = false; @override void initState() { @@ -126,6 +125,7 @@ class _MessageSourceScreenState extends ConsumerState setState(() { _isInSearchMode = false; }); + return; } final search = MailSearch(query, SearchQueryType.allTextHeaders); @@ -143,25 +143,6 @@ class _MessageSourceScreenState extends ConsumerState final theme = Theme.of(context); final localizations = context.text; final source = _sectionedMessageSource.messageSource; - if (source is ErrorMessageSource) { - return buildForLoadingError(context, localizations, source); - } - if (source == locator().messageSource) { - // listen to changes: - _updateMessageSource = true; - } else if (_updateMessageSource) { - _updateMessageSource = false; - // final state = MailServiceWidget.of(context); - // if (state != null) { - // final source = state.messageSource; - // if (source != null) { - // _sectionedMessageSource.removeListener(_update); - // _sectionedMessageSource = DateSectionedMessageSource(source); - // _sectionedMessageSource.addListener(_update); - // _messageLoader = initMessageSource(); - // } - // } - } final searchColor = theme.brightness == Brightness.light ? theme.colorScheme.onSecondary : theme.colorScheme.onPrimary; @@ -301,6 +282,7 @@ class _MessageSourceScreenState extends ConsumerState final isSentFolder = source.isSent; final showSearchTextField = PlatformInfo.isCupertino && source.supportsSearching; + final hasAccountWithError = ref.watch(hasAccountWithErrorProvider); return PlatformPageScaffold( bottomBar: _isInSelectionMode @@ -335,9 +317,7 @@ class _MessageSourceScreenState extends ConsumerState ? PlatformAppBar( title: appBarTitle, trailingActions: appBarActions, - leading: (locator().hasAccountsWithErrors()) - ? const MenuWithBadge() - : null, + leading: hasAccountWithError ? const MenuWithBadge() : null, ) : null, body: FutureBuilder( @@ -398,13 +378,12 @@ class _MessageSourceScreenState extends ConsumerState PlatformSliverAppBar( stretch: true, title: appBarTitle, - leading: - (locator().hasAccountsWithErrors()) - ? MenuWithBadge( - iOSText: - '\u2329 ${localizations.accountsTitle}', - ) - : null, + leading: hasAccountWithError + ? MenuWithBadge( + iOSText: + '\u2329 ${localizations.accountsTitle}', + ) + : null, previousPageTitle: source.parentName ?? localizations.accountsTitle, floating: !_isInSearchMode, @@ -1017,9 +996,10 @@ class _MessageSourceScreenState extends ConsumerState void move() { final localizations = locator().localizations; - var account = locator().currentAccount!; + var account = widget.messageSource.account; if (account.isVirtual) { - // check how many mailclient are involved in the current selection to either show the mailboxes of the unified account + // check how many mail-clients are involved in the current selection + // to either show the mailboxes of the unified account // or of the real account final mailClients = []; for (final message in _selectedMessages) { @@ -1030,8 +1010,11 @@ class _MessageSourceScreenState extends ConsumerState } if (mailClients.length == 1) { // ok, all messages belong to one account: - account = - locator().getAccountFor(mailClients.first.account)!; + account = ref.read( + findRealAccountByEmailProvider( + email: mailClients.first.account.email, + ), + ); } } final mailbox = account.isVirtual @@ -1061,13 +1044,19 @@ class _MessageSourceScreenState extends ConsumerState locator().pop(); // alert final source = _sectionedMessageSource.messageSource; final localizations = locator().localizations; - final account = locator().currentAccount!; + final account = widget.messageSource.account; if (account.isVirtual) { - await source.moveMessagesToFlag(_selectedMessages, mailbox.flags.first, - localizations.moveSuccess(mailbox.name)); + await source.moveMessagesToFlag( + _selectedMessages, + mailbox.flags.first, + localizations.moveSuccess(mailbox.name), + ); } else { await source.moveMessages( - _selectedMessages, mailbox, localizations.moveSuccess(mailbox.name)); + _selectedMessages, + mailbox, + localizations.moveSuccess(mailbox.name), + ); } } @@ -1146,39 +1135,6 @@ class _MessageSourceScreenState extends ConsumerState } } - Widget buildForLoadingError(BuildContext context, - AppLocalizations localizations, ErrorMessageSource errorSource) { - final account = errorSource.account; - return Base.buildAppChrome( - context, - title: localizations.errorTitle, - content: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Text(localizations.accountLoadError(account.name)), - ), - PlatformTextButton( - child: Text(localizations.accountLoadErrorEditAction), - onPressed: () => locator() - .push(Routes.accountEdit, arguments: account), - ), - // this does not currently work, as no new login is done - // PlatformTextButton( - // child: Text(localizations.detailsErrorDownloadRetry), - // onPressed: () async { - // final messageSource = await locator() - // .getMessageSourceFor(account, switchToAccount: true); - // locator().push(Routes.messageSource, - // arguments: messageSource, replace: true, fade: true); - // }, - // ), - ], - ), - ); - } - Future _deleteAllMessages() async { final localizations = context.text; bool expunge = false; @@ -1233,11 +1189,13 @@ class _MessageSourceScreenState extends ConsumerState } class CheckboxText extends StatefulWidget { - const CheckboxText( - {super.key, - required this.initialValue, - required this.onChanged, - required this.text}); + const CheckboxText({ + super.key, + required this.initialValue, + required this.onChanged, + required this.text, + }); + final bool initialValue; final Function(bool value) onChanged; final String text; diff --git a/lib/screens/screens.dart b/lib/screens/screens.dart index 53dfa93..1a0e01b 100644 --- a/lib/screens/screens.dart +++ b/lib/screens/screens.dart @@ -2,6 +2,7 @@ export 'account_add_screen.dart'; export 'account_edit_screen.dart'; export 'account_server_details_screen.dart'; export 'compose_screen.dart'; +export 'email_screen.dart'; export 'home_screen.dart'; export 'location_screen.dart'; export 'lock_screen.dart'; diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index 24dfb75..663c99a 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -177,6 +177,7 @@ class MailService implements MimeSourceSubscriber { ) async { if (account is UnifiedAccount) { final mimeSources = await _getUnifiedMimeSources(mailbox, account); + return MultipleMessageSource( account: account, mimeSources, @@ -192,8 +193,10 @@ class MailService implements MimeSourceSubscriber { await mailClient.selectMailbox(mailbox); } final source = _mimeSourceFactory.createMailboxMimeSource( - mailClient, mailbox) - ..addSubscriber(this); + mailClient, + mailbox, + )..addSubscriber(this); + return MailboxMessageSource.fromMimeSource( source, mailClient.account.email, @@ -201,13 +204,10 @@ class MailService implements MimeSourceSubscriber { account: account, ); } + throw StateError('Unable to login for : ${account.key}'); + } else { + throw StateError('Unknown account type: ${account.runtimeType}'); } - final accountWithErrors = _accountsWithErrors ?? []; - if (!accountWithErrors.contains(account)) { - accountWithErrors.add(account); - _accountsWithErrors ??= accountsWithErrors; - } - return ErrorMessageSource(account); } Future> _getUnifiedMimeSources( @@ -628,17 +628,19 @@ class MailService implements MimeSourceSubscriber { Future reconnect(RealAccount account) async { _mailClientsPerAccount.remove(account); - final source = await getMessageSourceFor(account); - final connected = source is! ErrorMessageSource; - if (connected) { + try { + final source = await getMessageSourceFor(account); final accountsWithErrors = _accountsWithErrors; if (accountsWithErrors != null) { accountsWithErrors.remove(account); } accountsWithoutErrors.add(account); //TODO update unified account message source after connecting account + + return true; + } catch (e) { + return false; } - return connected; } /// Disconnects the mail client belonging to [account]. diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 5ccd597..85df56f 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -4,6 +4,7 @@ import 'package:badges/badges.dart' as badges; import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; @@ -61,7 +62,7 @@ class AppDrawer extends ConsumerWidget { currentAccount, localizations, ), - _buildFolderTree(currentAccount), + _buildFolderTree(context, currentAccount), if (currentAccount is RealAccount) ExtensionActionTile.buildSideMenuForAccount( context, @@ -128,8 +129,11 @@ class AppDrawer extends ConsumerWidget { if (currentAccount is UnifiedAccount) { navService.push(Routes.settingsAccounts, fade: true); } else { - navService.push(Routes.accountEdit, - arguments: currentAccount, fade: true); + navService.push( + Routes.accountEdit, + arguments: currentAccount, + fade: true, + ); } }, title: avatarAccount == null @@ -262,24 +266,28 @@ class AppDrawer extends ConsumerWidget { }, ); - Widget _buildFolderTree(Account? account) { + Widget _buildFolderTree(BuildContext context, Account? account) { if (account == null) { return const SizedBox.shrink(); } - return MailboxTree(account: account, onSelected: _navigateToMailbox); + return MailboxTree( + account: account, + onSelected: (mailbox) => _navigateToMailbox(context, mailbox), + ); } - Future _navigateToMailbox(Mailbox mailbox) async { - final mailService = locator(); - final account = mailService.currentAccount!; - final messageSourceFuture = - mailService.getMessageSourceFor(account, mailbox: mailbox); - await locator().push( - Routes.messageSourceFuture, - arguments: messageSourceFuture, - replace: !Platform.isIOS, - fade: true, + Future _navigateToMailbox(BuildContext context, Mailbox mailbox) async { + final account = currentAccount; + if (account == null) { + return; + } + await context.pushNamed( + Routes.mail, + pathParameters: { + Routes.pathParameterEmail: account.email, + Routes.pathParameterEncodedMailboxPath: mailbox.encodedPath, + }, ); } } diff --git a/lib/widgets/mailbox_tree.dart b/lib/widgets/mailbox_tree.dart index 42207bc..ad5eea2 100644 --- a/lib/widgets/mailbox_tree.dart +++ b/lib/widgets/mailbox_tree.dart @@ -1,47 +1,66 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; import '../locator.dart'; +import '../mail/provider.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; - -class MailboxTree extends StatelessWidget { - const MailboxTree( - {super.key, - required this.account, - required this.onSelected, - this.current}); + +class MailboxTree extends ConsumerWidget { + const MailboxTree({ + super.key, + required this.account, + required this.onSelected, + this.current, + }); + final Account account; final void Function(Mailbox mailbox) onSelected; final Mailbox? current; @override - Widget build(BuildContext context) { - final mailboxTreeData = locator().getMailboxTreeFor(account); - if (mailboxTreeData == null) { - return Container(); - } - final mailboxTreeElements = mailboxTreeData.root.children!; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final element in mailboxTreeElements) - buildMailboxElement(element, 0), - ], + Widget build(BuildContext context, WidgetRef ref) { + final mailboxTreeValue = ref.watch(mailboxTreeProvider(account: account)); + + return mailboxTreeValue.when( + loading: () => Center( + child: PlatformCircularProgressIndicator(), + ), + error: (error, stacktrace) => Center(child: Text('$error')), + data: (tree) { + final mailboxTreeElements = tree.root.children; + if (mailboxTreeElements == null) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final element in mailboxTreeElements) + _buildMailboxElement(element, 0), + ], + ); + }, ); } - Widget buildMailboxElement(TreeElement element, final int level) { - final mailbox = element.value!; + Widget _buildMailboxElement(TreeElement element, final int level) { + final mailbox = element.value; + if (mailbox == null) { + return const SizedBox.shrink(); + } + final title = Padding( padding: EdgeInsets.only(left: level * 8.0), child: Text(mailbox.name), ); - if (element.children == null) { + final children = element.children; + if (children == null) { final isCurrent = (mailbox == current); final iconData = locator().getForMailbox(mailbox); + return SelectablePlatformListTile( leading: Icon(iconData), title: title, @@ -49,12 +68,13 @@ class MailboxTree extends StatelessWidget { selected: isCurrent, ); } + return Material( child: ExpansionTile( title: title, children: [ - for (final childElement in element.children!) - buildMailboxElement(childElement, level + 1), + for (final childElement in children) + _buildMailboxElement(childElement, level + 1), ], ), ); From 8e7155ec2b9c8c3ee78f9373f729ae69481b5260 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Fri, 13 Oct 2023 10:52:19 +0200 Subject: [PATCH 06/95] feat: add navigation to settings, allow navigation to emails of account without mailbox --- lib/models/date_sectioned_message_source.dart | 15 ++-- lib/routes.dart | 26 ++++++- lib/screens/account_edit_screen.dart | 19 ++++- lib/widgets/app_drawer.dart | 75 ++++++++++--------- 4 files changed, 84 insertions(+), 51 deletions(-) diff --git a/lib/models/date_sectioned_message_source.dart b/lib/models/date_sectioned_message_source.dart index a1f33d2..f796bf9 100644 --- a/lib/models/date_sectioned_message_source.dart +++ b/lib/models/date_sectioned_message_source.dart @@ -1,16 +1,15 @@ import 'dart:math'; -import '../locator.dart'; -import 'message_source.dart'; -import '../services/date_service.dart'; import 'package:flutter/foundation.dart'; +import '../locator.dart'; +import '../services/date_service.dart'; import '../services/i18n_service.dart'; import 'message.dart'; import 'message_date_section.dart'; +import 'message_source.dart'; class DateSectionedMessageSource extends ChangeNotifier { - DateSectionedMessageSource(this.messageSource) { messageSource.addListener(_update); } @@ -59,12 +58,12 @@ class DateSectionedMessageSource extends ChangeNotifier { @override void dispose() { messageSource.removeListener(_update); - messageSource.dispose(); super.dispose(); } - Future> downloadDateSections( - {int numberOfMessagesToBeConsidered = 40}) async { + Future> downloadDateSections({ + int numberOfMessagesToBeConsidered = 40, + }) async { final max = messageSource.size; if (numberOfMessagesToBeConsidered > max) { numberOfMessagesToBeConsidered = max; @@ -74,6 +73,7 @@ class DateSectionedMessageSource extends ChangeNotifier { final message = await messageSource.getMessageAt(i); messages.add(message); } + return getDateSections(messages); } @@ -182,7 +182,6 @@ class DateSectionedMessageSource extends ChangeNotifier { } class SectionElement { - SectionElement(this.section, this.message); final MessageDateSection? section; final Message? message; diff --git a/lib/routes.dart b/lib/routes.dart index 9b5c2ef..3b6b348 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -53,7 +53,7 @@ class Routes { static const String pathParameterEmail = 'email'; /// Path parameter name for an encoded mailbox path - static const String pathParameterEncodedMailboxPath = 'mailbox'; + static const String queryParameterEncodedMailboxPath = 'mailbox'; static final navigatorKey = GlobalKey(); @@ -75,13 +75,30 @@ class Routes { ), GoRoute( name: mail, - path: '$mail/:$pathParameterEmail/:$pathParameterEncodedMailboxPath', + path: '$mail/:$pathParameterEmail', builder: (context, state) => EMailScreen( email: state.pathParameters[pathParameterEmail] ?? '', encodedMailboxPath: - state.pathParameters[pathParameterEncodedMailboxPath], + state.uri.queryParameters[queryParameterEncodedMailboxPath], ), ), + GoRoute( + name: accountEdit, + path: '$accountEdit/:$pathParameterEmail', + builder: (context, state) => AccountEditScreen( + accountEmail: state.pathParameters[pathParameterEmail] ?? '', + ), + ), + GoRoute( + path: settings, + name: settings, + builder: (context, state) => const SettingsScreen(), + ), + GoRoute( + name: settingsAccounts, + path: settingsAccounts, + builder: (context, state) => const SettingsAccountsScreen(), + ), ], ); } @@ -99,7 +116,8 @@ class AppRouter { page = AccountServerDetailsScreen(account: arguments! as RealAccount); break; case Routes.accountEdit: - page = AccountEditScreen(account: arguments! as RealAccount); + page = + AccountEditScreen(accountEmail: (arguments! as RealAccount).email); break; case Routes.settings: page = const SettingsScreen(); diff --git a/lib/screens/account_edit_screen.dart b/lib/screens/account_edit_screen.dart index 79e6432..08afadc 100644 --- a/lib/screens/account_edit_screen.dart +++ b/lib/screens/account_edit_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; +import '../account/providers.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; @@ -28,13 +29,16 @@ import 'base.dart'; /// The account edit screen class AccountEditScreen extends HookConsumerWidget { /// Creates a new account edit screen - const AccountEditScreen({super.key, required this.account}); + const AccountEditScreen({super.key, required this.accountEmail}); /// The account to edit - final RealAccount account; + final String accountEmail; @override Widget build(BuildContext context, WidgetRef ref) { + final account = ref.watch( + findRealAccountByEmailProvider(email: accountEmail), + ); final localizations = context.text; final accountNameController = useTextEditingController(text: account.name); final userNameController = useTextEditingController(text: account.userName); @@ -78,6 +82,7 @@ class AccountEditScreen extends HookConsumerWidget { child: PlatformTextButtonIcon( onPressed: () => _reconnect( context, + account, account.mailAccount, isRetryingToConnectState, ), @@ -92,6 +97,7 @@ class AccountEditScreen extends HookConsumerWidget { child: PlatformTextButton( onPressed: () => _updateAuthentication( context, + account, isRetryingToConnectState, ), child: PlatformText( @@ -354,6 +360,7 @@ class AccountEditScreen extends HookConsumerWidget { Future _updateAuthentication( BuildContext context, + RealAccount account, ValueNotifier isRetryingToConnectState, ) async { final mailService = locator(); @@ -394,6 +401,7 @@ class AccountEditScreen extends HookConsumerWidget { } final result = await _reconnect( context, + account, updatedMailAccount, isRetryingToConnectState, ); @@ -431,6 +439,7 @@ class AccountEditScreen extends HookConsumerWidget { ); await _reconnect( context, + account, updatedMailAccount, isRetryingToConnectState, ); @@ -442,13 +451,14 @@ class AccountEditScreen extends HookConsumerWidget { Future _reconnect( BuildContext context, + RealAccount account, MailAccount mailAccount, ValueNotifier isRetryingToConnectState, ) async { isRetryingToConnectState.value = true; - final account = this.account.copyWith(mailAccount: mailAccount); + final accountCopy = account.copyWith(mailAccount: mailAccount); final mailService = locator(); - final result = await mailService.reconnect(account); + final result = await mailService.reconnect(accountCopy); isRetryingToConnectState.value = false; if (context.mounted) { if (result) { @@ -460,6 +470,7 @@ class AccountEditScreen extends HookConsumerWidget { ); } } + return result; } } diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 85df56f..6a432a2 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -16,7 +16,6 @@ import '../locator.dart'; import '../routes.dart'; import '../services/icon_service.dart'; import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; import '../util/localized_dialog_helper.dart'; import 'mailbox_tree.dart'; @@ -43,6 +42,7 @@ class AppDrawer extends ConsumerWidget { child: Padding( padding: const EdgeInsets.all(8), child: _buildAccountHeader( + context, currentAccount, mailService.accounts, theme, @@ -87,8 +87,7 @@ class AppDrawer extends ConsumerWidget { leading: Icon(iconService.settings), title: Text(localizations.drawerEntrySettings), onTap: () { - final navService = locator(); - navService.push(Routes.settings); + context.pushNamed(Routes.settings); }, ), ), @@ -99,6 +98,7 @@ class AppDrawer extends ConsumerWidget { } Widget _buildAccountHeader( + BuildContext context, Account? currentAccount, List accounts, ThemeData theme, @@ -125,14 +125,14 @@ class AppDrawer extends ConsumerWidget { return PlatformListTile( onTap: () { - final NavigationService navService = locator(); if (currentAccount is UnifiedAccount) { - navService.push(Routes.settingsAccounts, fade: true); + context.pushNamed(Routes.settingsAccounts); } else { - navService.push( + context.pushNamed( Routes.accountEdit, - arguments: currentAccount, - fade: true, + pathParameters: { + Routes.pathParameterEmail: currentAccount.email, + }, ); } }, @@ -211,58 +211,61 @@ class AppDrawer extends ConsumerWidget { : account.name, ), selected: account == currentAccount, - onTap: () async { - final navService = locator(); + onTap: () { if (!Platform.isIOS) { - navService.pop(); + context.pop(); } if (mailService.hasError(account)) { - await navService.push( + context.pushNamed( Routes.accountEdit, - arguments: account, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, ); } else { - final messageSource = - locator().getMessageSourceFor( - account, - switchToAccount: true, - ); - await navService.push( - Routes.messageSourceFuture, - arguments: messageSource, - replace: !Platform.isIOS, - fade: true, + context.pushNamed( + Routes.mail, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, ); } }, onLongPress: () { - final navService = locator(); if (account is UnifiedAccount) { - navService.push(Routes.settingsAccounts, fade: true); + context.pushNamed( + Routes.settingsAccounts, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, + ); } else { - navService.push( + context.pushNamed( Routes.accountEdit, - arguments: account, - fade: true, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, ); } }, ), - _buildAddAccountTile(localizations), + _buildAddAccountTile(context, localizations), ], ) - : _buildAddAccountTile(localizations); + : _buildAddAccountTile(context, localizations); - Widget _buildAddAccountTile(AppLocalizations localizations) => + Widget _buildAddAccountTile( + BuildContext context, + AppLocalizations localizations, + ) => PlatformListTile( leading: const Icon(Icons.add), title: Text(localizations.drawerEntryAddAccount), onTap: () { - final navService = locator(); if (!Platform.isIOS) { - navService.pop(); + context.pop(); } - navService.push(Routes.accountAdd); + context.pushNamed(Routes.accountAdd); }, ); @@ -286,7 +289,9 @@ class AppDrawer extends ConsumerWidget { Routes.mail, pathParameters: { Routes.pathParameterEmail: account.email, - Routes.pathParameterEncodedMailboxPath: mailbox.encodedPath, + }, + queryParameters: { + Routes.queryParameterEncodedMailboxPath: mailbox.encodedPath, }, ); } From d4defe7a875ced9e4111450e8226328a9b9c5f33 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Fri, 13 Oct 2023 11:49:44 +0200 Subject: [PATCH 07/95] fix: handle incoming message on universal message source correctly --- lib/models/message_source.dart | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 09a906b..26ada3d 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -176,7 +176,7 @@ abstract class MessageSource extends ChangeNotifier int index = 0, }) { // the source index is 0 since this is the new first message: - final message = Message(mime, this, index); + final message = createMessage(mime, source, index); insertIntoCache(index, message); notifyListeners(); } @@ -602,6 +602,16 @@ abstract class MessageSource extends ChangeNotifier ); } + /// Creates a new message + /// + /// Can be overridden by subclasses to create a custom message type + Message createMessage( + MimeMessage mime, + AsyncMimeSource mimeSource, + int index, + ) => + Message(mime, this, index); + // void replaceMime(Message message, MimeMessage mime) { // final mimeSource = getMimeSource(message); // remove(message); @@ -915,11 +925,21 @@ class MultipleMessageSource extends MessageSource { if (message is _UnifiedMessage) { return message.mimeSource; } - logger.e('Unable to retrieve mime source for $message'); + logger.e( + 'Unable to retrieve mime source for ${message.runtimeType} / $message', + ); return mimeSources.first; } + @override + Message createMessage( + MimeMessage mime, + AsyncMimeSource mimeSource, + int index, + ) => + _UnifiedMessage(mime, this, index, mimeSource); + @override bool get shouldBlockImages => mimeSources.any((source) => source.shouldBlockImages); @@ -958,9 +978,7 @@ class MultipleMessageSource extends MessageSource { account: account, parent: this, isSearch: true, - ); - searchMessageSource._description = - localizations.searchQueryDescription(name ?? ''); + ).._description = localizations.searchQueryDescription(name ?? ''); return searchMessageSource; } From 64e0218567e0b83cdf0318f27c39e2f744e5a10d Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Sun, 15 Oct 2023 15:47:05 +0200 Subject: [PATCH 08/95] feat: improve architecture further --- .vscode/settings.json | 3 +- lib/account/model.dart | 33 +- lib/account/{providers.dart => provider.dart} | 47 +- .../{providers.g.dart => provider.g.dart} | 39 +- lib/contact/model.dart | 18 + lib/contact/provider.dart | 71 + lib/contact/provider.g.dart | 170 +++ lib/locator.dart | 2 - lib/mail/provider.dart | 243 +-- lib/mail/provider.g.dart | 1301 +++++++++++++++-- lib/mail/service.dart | 137 ++ lib/main.dart | 2 +- lib/models/contact.dart | 9 - lib/models/message_source.dart | 31 +- lib/routes.dart | 259 +++- lib/screens/account_add_screen.dart | 73 +- lib/screens/account_edit_screen.dart | 251 ++-- .../account_server_details_screen.dart | 58 +- lib/screens/base.dart | 290 ++-- lib/screens/compose_screen.dart | 213 +-- lib/screens/email_screen.dart | 2 +- lib/screens/home_screen.dart | 2 +- lib/screens/location_screen.dart | 11 +- lib/screens/lock_screen.dart | 5 +- lib/screens/mail_screen.dart | 31 +- lib/screens/mail_search_screen.dart | 45 + lib/screens/media_screen.dart | 51 +- lib/screens/message_details_screen.dart | 89 +- ...ssage_details_screen_for_notification.dart | 38 + lib/screens/message_source_screen.dart | 21 +- lib/screens/screens.dart | 2 + lib/screens/sourcecode_screen.dart | 29 +- lib/screens/webview_screen.dart | 21 +- lib/screens/welcome_screen.dart | 54 +- lib/services/app_service.dart | 11 +- lib/services/contact_service.dart | 65 - lib/services/mail_service.dart | 3 + lib/services/notification_service.dart | 60 +- .../view/settings_accounts_screen.dart | 2 +- .../view/settings_default_sender_screen.dart | 22 +- .../view/settings_developer_mode_screen.dart | 139 +- .../view/settings_feedback_screen.dart | 21 +- .../view/settings_folders_screen.dart | 127 +- .../view/settings_language_screen.dart | 47 +- .../view/settings_readreceipts_screen.dart | 9 +- lib/settings/view/settings_reply_screen.dart | 3 +- lib/settings/view/settings_screen.dart | 3 +- .../view/settings_security_screen.dart | 3 +- .../view/settings_signature_screen.dart | 17 +- lib/settings/view/settings_swipe_screen.dart | 16 +- lib/settings/view/settings_theme_screen.dart | 20 +- lib/util/modal_bottom_sheet_helper.dart | 44 +- lib/widgets/account_selector.dart | 27 +- lib/widgets/app_drawer.dart | 171 ++- lib/widgets/attachment_compose_bar.dart | 52 +- lib/widgets/ical_composer.dart | 50 +- lib/widgets/mail_address_chip.dart | 36 +- lib/widgets/mailbox_selector.dart | 54 +- lib/widgets/mailbox_tree.dart | 13 +- lib/widgets/message_actions.dart | 11 +- lib/widgets/recipient_input_field.dart | 2 +- lib/widgets/signature.dart | 11 +- 62 files changed, 3344 insertions(+), 1346 deletions(-) rename lib/account/{providers.dart => provider.dart} (70%) rename lib/account/{providers.g.dart => provider.g.dart} (89%) create mode 100644 lib/contact/model.dart create mode 100644 lib/contact/provider.dart create mode 100644 lib/contact/provider.g.dart create mode 100644 lib/mail/service.dart create mode 100644 lib/screens/mail_search_screen.dart create mode 100644 lib/screens/message_details_screen_for_notification.dart delete mode 100644 lib/services/contact_service.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 51236ae..4485618 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "Maily", "mocktail", "riverpod", - "unawaited" + "unawaited", + "unfocus" ] } \ No newline at end of file diff --git a/lib/account/model.dart b/lib/account/model.dart index 68e7ebe..0401423 100644 --- a/lib/account/model.dart +++ b/lib/account/model.dart @@ -2,10 +2,8 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/cupertino.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../contact/model.dart'; import '../extensions/extensions.dart'; -import '../locator.dart'; -import '../models/contact.dart'; -import '../services/mail_service.dart'; part 'model.g.dart'; @@ -74,6 +72,12 @@ class RealAccount extends Account { /// Retrieves the mail account MailAccount get mailAccount => _account; + /// Updates the account with the given [mailAccount] + set mailAccount(MailAccount mailAccount) { + _account = mailAccount; + notifyListeners(); + } + /// Does this account have a login error? bool hasError = false; @@ -192,19 +196,15 @@ class RealAccount extends Account { ContactManager? contactManager; /// Adds the [alias] - Future addAlias(MailAddress alias) { + void addAlias(MailAddress alias) { _account = _account.copyWithAlias(alias); notifyListeners(); - - return locator().saveAccount(_account); } /// Removes the [alias] - Future removeAlias(MailAddress alias) { + void removeAlias(MailAddress alias) { _account.aliases.remove(alias); notifyListeners(); - - return locator().saveAccount(_account); } /// Retrieves the known alias addresses @@ -230,11 +230,16 @@ class RealAccount extends Account { /// Retrieves the app extensions List? appExtensions; - /// Copies this account with the given [mailAccount] - RealAccount copyWith({required MailAccount mailAccount}) => RealAccount( - mailAccount, - appExtensions: appExtensions, - contactManager: contactManager, + /// Copies this account with the given data + RealAccount copyWith({ + MailAccount? mailAccount, + List? appExtensions, + ContactManager? contactManager, + }) => + RealAccount( + mailAccount ?? _account, + appExtensions: appExtensions ?? this.appExtensions, + contactManager: contactManager ?? this.contactManager, ); } diff --git a/lib/account/providers.dart b/lib/account/provider.dart similarity index 70% rename from lib/account/providers.dart rename to lib/account/provider.dart index 256be48..fe116d9 100644 --- a/lib/account/providers.dart +++ b/lib/account/provider.dart @@ -1,10 +1,12 @@ import 'package:collection/collection.dart'; +import 'package:enough_mail/enough_mail.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../models/sender.dart'; import 'model.dart'; import 'storage.dart'; -part 'providers.g.dart'; +part 'provider.g.dart'; /// Provides all real email accounts @Riverpod(keepAlive: true) @@ -34,12 +36,36 @@ class RealAccounts extends _$RealAccounts { _saveAccounts(); } + /// Updates the given [oldAccount] with the given [newAccount] + void replaceAccount({ + required RealAccount oldAccount, + required RealAccount newAccount, + bool save = true, + }) { + final index = state.indexWhere((a) => a.key == oldAccount.key); + if (index == -1) { + throw StateError('account not found for ${oldAccount.key}'); + } + final newState = state.toList()..[index] = newAccount; + state = newState; + if (save) { + _saveAccounts(); + } + } + /// Changes the order of the accounts void reorderAccounts(List accounts) { state = accounts; _saveAccounts(); } + /// Saves all data + Future updateMailAccount(RealAccount account, MailAccount mailAccount) { + account.mailAccount = mailAccount; + + return _saveAccounts(); + } + /// Saves all data Future save() => _saveAccounts(); @@ -48,6 +74,21 @@ class RealAccounts extends _$RealAccounts { } } +/// Generates a list of senders for composing a new message +@riverpod +List Senders(SendersRef ref) { + final accounts = ref.watch(realAccountsProvider); + final senders = []; + for (final account in accounts) { + senders.add(Sender(account.fromAddress, account)); + for (final alias in account.aliases) { + senders.add(Sender(alias, account)); + } + } + + return senders; +} + /// Provides the unified account, if any @Riverpod(keepAlive: true) UnifiedAccount? unifiedAccount(UnifiedAccountRef ref) { @@ -115,3 +156,7 @@ bool hasAccountWithError( return realAccounts.any((a) => a.hasError); } + +/// Provides the locally current active account +@riverpod +Account? currentAccount(CurrentAccountRef ref) => null; diff --git a/lib/account/providers.g.dart b/lib/account/provider.g.dart similarity index 89% rename from lib/account/providers.g.dart rename to lib/account/provider.g.dart index 233e047..733e78b 100644 --- a/lib/account/providers.g.dart +++ b/lib/account/provider.g.dart @@ -1,11 +1,27 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'providers.dart'; +part of 'provider.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** +String _$sendersHash() => r'ac054c05e85b5665da4f1dc8671418c211154bd1'; + +/// Generates a list of senders for composing a new message +/// +/// Copied from [Senders]. +@ProviderFor(Senders) +final sendersProvider = AutoDisposeProvider>.internal( + Senders, + name: r'sendersProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$sendersHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SendersRef = AutoDisposeProviderRef>; String _$unifiedAccountHash() => r'e59dc865d2ef074d5da9cdc4d228551153ef0a53'; /// Provides the unified account, if any @@ -24,7 +40,7 @@ final unifiedAccountProvider = Provider.internal( typedef UnifiedAccountRef = ProviderRef; String _$findAccountByEmailHash() => - r'd098fc64ea914fb4ba974196600a1386546c4e70'; + r'951fd91a8a1e1722378dcc2542e0857486f9b5cd'; /// Copied from Dart SDK class _SystemHash { @@ -342,7 +358,24 @@ final hasAccountWithErrorProvider = Provider.internal( ); typedef HasAccountWithErrorRef = ProviderRef; -String _$realAccountsHash() => r'3ff51534497e5e36a7b0e0f19dc0e5fb09cfdcfe'; +String _$currentAccountHash() => r'1a478a132562c199f6e6f9a8e23d485b2d93e2bc'; + +/// Provides the locally current active account +/// +/// Copied from [currentAccount]. +@ProviderFor(currentAccount) +final currentAccountProvider = AutoDisposeProvider.internal( + currentAccount, + name: r'currentAccountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentAccountHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CurrentAccountRef = AutoDisposeProviderRef; +String _$realAccountsHash() => r'665041d146a86069048493163e33d76a4896d3cb'; /// Provides all real email accounts /// diff --git a/lib/contact/model.dart b/lib/contact/model.dart new file mode 100644 index 0000000..8675d91 --- /dev/null +++ b/lib/contact/model.dart @@ -0,0 +1,18 @@ +import 'package:enough_mail/enough_mail.dart'; + +/// Contains a list of a contacts for a given account +class ContactManager { + /// Creates a new [ContactManager] with the given [addresses + ContactManager(this.addresses); + + /// The list of addresses + final List addresses; + + /// Finds the addresses matching the given [search] + Iterable find(String search) => addresses.where( + (address) => + address.email.contains(search) || + (address.hasPersonalName && + (address.personalName ?? '').contains(search)), + ); +} diff --git a/lib/contact/provider.dart b/lib/contact/provider.dart new file mode 100644 index 0000000..6961af1 --- /dev/null +++ b/lib/contact/provider.dart @@ -0,0 +1,71 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../account/model.dart'; +import '../account/provider.dart'; +import '../logger.dart'; +import '../mail/service.dart'; +import 'model.dart'; + +part 'provider.g.dart'; + +/// Loads the contacts for the given [account] +@Riverpod(keepAlive: true) +Future contactsLoader( + ContactsLoaderRef ref, { + required RealAccount account, +}) async { + final mailClient = + EmailService.instance.createMailClient(account.mailAccount, null); + try { + await mailClient.connect(); + final mailbox = await mailClient.selectMailboxByFlag(MailboxFlag.sent); + if (mailbox.messagesExists > 0) { + var startId = mailbox.messagesExists - 100; + if (startId < 1) { + startId = 1; + } + final sentMessages = await mailClient.fetchMessageSequence( + MessageSequence.fromRangeToLast(startId), + fetchPreference: FetchPreference.envelope, + ); + final addressesByEmail = {}; + for (final message in sentMessages) { + _addAddresses(message.to, addressesByEmail); + _addAddresses(message.cc, addressesByEmail); + _addAddresses(message.bcc, addressesByEmail); + } + final manager = ContactManager(addressesByEmail.values.toList()); + final updatedAccount = account.copyWith(contactManager: manager); + ref.read(realAccountsProvider.notifier).replaceAccount( + oldAccount: account, + newAccount: updatedAccount, + save: false, + ); + + return manager; + } + } catch (e, s) { + logger.e('unable to load sent messages: $e', error: e, stackTrace: s); + } finally { + await mailClient.disconnect(); + } + + return ContactManager([]); +} + +void _addAddresses( + List? addresses, + Map addressesByEmail, +) { + if (addresses == null) { + return; + } + for (final address in addresses) { + final email = address.email.toLowerCase(); + final existing = addressesByEmail[email]; + if (existing == null || !existing.hasPersonalName) { + addressesByEmail[email] = address; + } + } +} diff --git a/lib/contact/provider.g.dart b/lib/contact/provider.g.dart new file mode 100644 index 0000000..d98eabf --- /dev/null +++ b/lib/contact/provider.g.dart @@ -0,0 +1,170 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$contactsLoaderHash() => r'e72c5505e7614547d8219280e09a20b2886f3568'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// Loads the contacts for the given [account] +/// +/// Copied from [contactsLoader]. +@ProviderFor(contactsLoader) +const contactsLoaderProvider = ContactsLoaderFamily(); + +/// Loads the contacts for the given [account] +/// +/// Copied from [contactsLoader]. +class ContactsLoaderFamily extends Family> { + /// Loads the contacts for the given [account] + /// + /// Copied from [contactsLoader]. + const ContactsLoaderFamily(); + + /// Loads the contacts for the given [account] + /// + /// Copied from [contactsLoader]. + ContactsLoaderProvider call({ + required RealAccount account, + }) { + return ContactsLoaderProvider( + account: account, + ); + } + + @override + ContactsLoaderProvider getProviderOverride( + covariant ContactsLoaderProvider provider, + ) { + return call( + account: provider.account, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'contactsLoaderProvider'; +} + +/// Loads the contacts for the given [account] +/// +/// Copied from [contactsLoader]. +class ContactsLoaderProvider extends FutureProvider { + /// Loads the contacts for the given [account] + /// + /// Copied from [contactsLoader]. + ContactsLoaderProvider({ + required RealAccount account, + }) : this._internal( + (ref) => contactsLoader( + ref as ContactsLoaderRef, + account: account, + ), + from: contactsLoaderProvider, + name: r'contactsLoaderProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$contactsLoaderHash, + dependencies: ContactsLoaderFamily._dependencies, + allTransitiveDependencies: + ContactsLoaderFamily._allTransitiveDependencies, + account: account, + ); + + ContactsLoaderProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + }) : super.internal(); + + final RealAccount account; + + @override + Override overrideWith( + FutureOr Function(ContactsLoaderRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ContactsLoaderProvider._internal( + (ref) => create(ref as ContactsLoaderRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + ), + ); + } + + @override + FutureProviderElement createElement() { + return _ContactsLoaderProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ContactsLoaderProvider && other.account == account; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin ContactsLoaderRef on FutureProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; +} + +class _ContactsLoaderProviderElement + extends FutureProviderElement with ContactsLoaderRef { + _ContactsLoaderProviderElement(super.provider); + + @override + RealAccount get account => (origin as ContactsLoaderProvider).account; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/locator.dart b/lib/locator.dart index b0bb048..7b8f76e 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -4,7 +4,6 @@ import 'models/async_mime_source_factory.dart'; import 'services/app_service.dart'; import 'services/background_service.dart'; import 'services/biometrics_service.dart'; -import 'services/contact_service.dart'; import 'services/date_service.dart'; import 'services/i18n_service.dart'; import 'services/icon_service.dart'; @@ -35,7 +34,6 @@ void setupLocator() { ..registerLazySingleton(BackgroundService.new) ..registerLazySingleton(AppService.new) ..registerLazySingleton(LocationService.new) - ..registerLazySingleton(ContactService.new) ..registerLazySingleton(KeyService.new) ..registerLazySingleton(ProviderService.new) ..registerLazySingleton(BiometricsService.new); diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index 0a39f64..13fc8b9 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -1,15 +1,15 @@ +import 'package:collection/collection.dart'; import 'package:enough_mail/enough_mail.dart'; -import 'package:flutter/foundation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../account/model.dart'; -import '../account/providers.dart'; -import '../events/app_event_bus.dart'; -import '../locator.dart'; +import '../account/provider.dart'; import '../models/async_mime_source.dart'; -import '../models/async_mime_source_factory.dart'; +import '../models/message.dart'; import '../models/message_source.dart'; -import '../services/providers.dart'; +import '../services/notification_service.dart'; +import '../settings/provider.dart'; +import 'service.dart'; part 'provider.g.dart'; @@ -60,10 +60,13 @@ class UnifiedSource extends _$UnifiedSource { } final source = await ref.watch( - realSourceProvider(account: realAccount, mailbox: usedMailbox).future, + realMimeSourceProvider( + account: realAccount, + mailbox: usedMailbox, + ).future, ); - return source.mimeSource; + return source; } final accounts = account.accounts; @@ -84,112 +87,23 @@ class UnifiedSource extends _$UnifiedSource { /// Provides the message source for the given account @Riverpod(keepAlive: true) class RealSource extends _$RealSource { - static const _clientId = Id(name: 'Maily', version: '1.0'); - final _mailClientsPerAccount = {}; - final _mimeSourceFactory = - const AsyncMimeSourceFactory(isOfflineModeSupported: false); - @override Future build({ required RealAccount account, Mailbox? mailbox, }) async { - final mailClient = await _getClientAndStopPolling(account); - if (mailClient == null) { - throw Exception('Unable to connect to server'); - } - if (mailbox == null) { - mailbox = await mailClient.selectInbox(); - } else { - await mailClient.selectMailbox(mailbox); - } - final source = _mimeSourceFactory.createMailboxMimeSource( - mailClient, - mailbox, + final source = await ref.watch( + realMimeSourceProvider(account: account, mailbox: mailbox).future, ); //..addSubscriber(this); // TODO(RV): add subscriber to send notification for unseen inbox mails return MailboxMessageSource.fromMimeSource( source, - mailClient.account.email, - mailbox.name, + account.email, + mailbox?.name ?? '', account: account, ); } - - Future _getClientAndStopPolling(RealAccount account) async { - final client = await _getClientFor(account); - await client.stopPollingIfNeeded(); - if (!client.isConnected) { - await client.connect(); - } - - return client; - } - - Future _getClientFor( - RealAccount account, - ) async => - _mailClientsPerAccount[account] ?? await _createClientFor(account); - - Future _createClientFor( - RealAccount account, { - bool store = true, - }) async { - final client = _createMailClient(account.mailAccount); - if (store) { - _mailClientsPerAccount[account] = client; - } - await client.connect(); - - return client; - } - - MailClient _createMailClient(MailAccount mailAccount) { - final bool isLogEnabled = kDebugMode || - (mailAccount.attributes[RealAccount.attributeEnableLogging] ?? false); - - return MailClient( - mailAccount, - isLogEnabled: isLogEnabled, - logName: mailAccount.name, - eventBus: AppEventBus.eventBus, - clientId: _clientId, - refresh: _refreshToken, - onConfigChanged: (account) => - ref.read(realAccountsProvider.notifier).save(), - downloadSizeLimit: 32 * 1024, - ); - } - - Future _refreshToken( - MailClient mailClient, - OauthToken expiredToken, - ) { - final providerId = expiredToken.provider; - if (providerId == null) { - throw MailException( - mailClient, - 'no provider registered for token $expiredToken', - ); - } - final provider = locator()[providerId]; - if (provider == null) { - throw MailException( - mailClient, - 'no provider "$providerId" found - token: $expiredToken', - ); - } - final oauthClient = provider.oauthClient; - if (oauthClient == null || !oauthClient.isEnabled) { - throw MailException( - mailClient, - 'provider $providerId has no valid OAuth configuration', - ); - } - - return oauthClient.refresh(expiredToken); - } } //// Loads the mailbox tree for the given account @@ -234,3 +148,130 @@ Future findMailbox( return mailbox; } + +/// Provides the message source for the given account +@Riverpod(keepAlive: true) +class RealMimeSource extends _$RealMimeSource { + @override + Future build({ + required RealAccount account, + Mailbox? mailbox, + }) async { + final mailClient = ref.watch( + mailClientSourceProvider(account: account, mailbox: mailbox), + ); + + return EmailService.instance.createMimeSource( + mailClient: mailClient, + mailbox: mailbox, + ); + } +} + +/// Provides mail clients +@Riverpod(keepAlive: true) +class MailClientSource extends _$MailClientSource { + @override + MailClient build({ + required RealAccount account, + Mailbox? mailbox, + }) => + EmailService.instance.createMailClient( + account.mailAccount, + (mailAccount) => ref + .watch(realAccountsProvider.notifier) + .updateMailAccount(account, mailAccount), + ); + + /// Creates a new mailbox with the given [mailboxName] + Future createMailbox( + String mailboxName, + Mailbox? parentMailbox, + ) async { + final mailClient = state; + await mailClient.createMailbox(mailboxName, parentMailbox: parentMailbox); + + return ref.refresh(mailboxTreeProvider(account: account)); + } + + /// Deletes the given [mailbox] + Future deleteMailbox(Mailbox mailbox) async { + final mailClient = state; + await mailClient.deleteMailbox(mailbox); + + return ref.refresh(mailboxTreeProvider(account: account)); + } +} + +/// Carries out a search for mail messages +@riverpod +Future mailSearch( + MailSearchRef ref, { + required MailSearch search, +}) async { + final account = + ref.watch(currentAccountProvider) ?? ref.watch(allAccountsProvider).first; + final source = await ref.watch(sourceProvider(account: account).future); + + return source.search(search); +} + +/// Loads the message source for the given payload +@riverpod +Future singleMessageLoader( + SingleMessageLoaderRef ref, { + required MailNotificationPayload payload, +}) async { + final account = ref.watch( + findAccountByEmailProvider(email: payload.accountEmail), + ); + final source = await ref.watch(sourceProvider(account: account).future); + + return source.loadSingleMessage(payload); +} + +/// Provides mail clients +@Riverpod(keepAlive: false) +class FirstTimeMailClientSource extends _$FirstTimeMailClientSource { + @override + Future build({ + required RealAccount account, + Mailbox? mailbox, + }) => + EmailService.instance.connectFirstTime( + account.mailAccount, + (mailAccount) => ref + .watch(realAccountsProvider.notifier) + .updateMailAccount(account, mailAccount), + ); +} + +/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +@riverpod +MessageBuilder mailto( + MailtoRef ref, { + required Uri mailtoUri, + required MimeMessage originatingMessage, +}) { + final settings = ref.watch(settingsProvider); + final senders = ref.watch(sendersProvider); + final searchFor = senders.map((s) => s.address).toList(); + final searchIn = originatingMessage.recipientAddresses + .map((email) => MailAddress('', email)) + .toList(); + var fromAddress = MailAddress.getMatch(searchFor, searchIn); + if (fromAddress == null) { + if (settings.preferredComposeMailAddress != null) { + fromAddress = searchFor.firstWhereOrNull( + (address) => address.email == settings.preferredComposeMailAddress, + ); + } + fromAddress ??= searchFor.first; + } + + return MessageBuilder.prepareMailtoBasedMessage(mailtoUri, fromAddress); +} + +/// Provides the locally current active mailbox +@riverpod +Mailbox? currentMailbox(CurrentMailboxRef ref) => null; diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart index 59a9048..0cd8b49 100644 --- a/lib/mail/provider.g.dart +++ b/lib/mail/provider.g.dart @@ -167,7 +167,7 @@ class _MailboxTreeProviderElement extends FutureProviderElement> Account get account => (origin as MailboxTreeProvider).account; } -String _$findMailboxHash() => r'cf69ac27d256f03c561fcc130eacc4348974ac09'; +String _$findMailboxHash() => r'af999b6d27a50cab3b97f91bc09b8a5641deea76'; //// Loads the mailbox tree for the given account /// @@ -325,49 +325,1039 @@ class _FindMailboxProviderElement extends FutureProviderElement (origin as FindMailboxProvider).encodedMailboxPath; } +String _$mailSearchHash() => r'166028850f57246adf47921d461b1ff3b5bc3230'; + +/// Carries out a search for mail messages +/// +/// Copied from [mailSearch]. +@ProviderFor(mailSearch) +const mailSearchProvider = MailSearchFamily(); + +/// Carries out a search for mail messages +/// +/// Copied from [mailSearch]. +class MailSearchFamily extends Family> { + /// Carries out a search for mail messages + /// + /// Copied from [mailSearch]. + const MailSearchFamily(); + + /// Carries out a search for mail messages + /// + /// Copied from [mailSearch]. + MailSearchProvider call({ + required MailSearch search, + }) { + return MailSearchProvider( + search: search, + ); + } + + @override + MailSearchProvider getProviderOverride( + covariant MailSearchProvider provider, + ) { + return call( + search: provider.search, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'mailSearchProvider'; +} + +/// Carries out a search for mail messages +/// +/// Copied from [mailSearch]. +class MailSearchProvider extends AutoDisposeFutureProvider { + /// Carries out a search for mail messages + /// + /// Copied from [mailSearch]. + MailSearchProvider({ + required MailSearch search, + }) : this._internal( + (ref) => mailSearch( + ref as MailSearchRef, + search: search, + ), + from: mailSearchProvider, + name: r'mailSearchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$mailSearchHash, + dependencies: MailSearchFamily._dependencies, + allTransitiveDependencies: + MailSearchFamily._allTransitiveDependencies, + search: search, + ); + + MailSearchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.search, + }) : super.internal(); + + final MailSearch search; + + @override + Override overrideWith( + FutureOr Function(MailSearchRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: MailSearchProvider._internal( + (ref) => create(ref as MailSearchRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + search: search, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _MailSearchProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MailSearchProvider && other.search == search; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, search.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin MailSearchRef on AutoDisposeFutureProviderRef { + /// The parameter `search` of this provider. + MailSearch get search; +} + +class _MailSearchProviderElement + extends AutoDisposeFutureProviderElement with MailSearchRef { + _MailSearchProviderElement(super.provider); + + @override + MailSearch get search => (origin as MailSearchProvider).search; +} + +String _$singleMessageLoaderHash() => + r'2f1eccab1f4f817417d5036ec0bef4bd393e02b3'; + +/// Loads the message source for the given payload +/// +/// Copied from [singleMessageLoader]. +@ProviderFor(singleMessageLoader) +const singleMessageLoaderProvider = SingleMessageLoaderFamily(); + +/// Loads the message source for the given payload +/// +/// Copied from [singleMessageLoader]. +class SingleMessageLoaderFamily extends Family> { + /// Loads the message source for the given payload + /// + /// Copied from [singleMessageLoader]. + const SingleMessageLoaderFamily(); + + /// Loads the message source for the given payload + /// + /// Copied from [singleMessageLoader]. + SingleMessageLoaderProvider call({ + required MailNotificationPayload payload, + }) { + return SingleMessageLoaderProvider( + payload: payload, + ); + } + + @override + SingleMessageLoaderProvider getProviderOverride( + covariant SingleMessageLoaderProvider provider, + ) { + return call( + payload: provider.payload, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'singleMessageLoaderProvider'; +} + +/// Loads the message source for the given payload +/// +/// Copied from [singleMessageLoader]. +class SingleMessageLoaderProvider extends AutoDisposeFutureProvider { + /// Loads the message source for the given payload + /// + /// Copied from [singleMessageLoader]. + SingleMessageLoaderProvider({ + required MailNotificationPayload payload, + }) : this._internal( + (ref) => singleMessageLoader( + ref as SingleMessageLoaderRef, + payload: payload, + ), + from: singleMessageLoaderProvider, + name: r'singleMessageLoaderProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$singleMessageLoaderHash, + dependencies: SingleMessageLoaderFamily._dependencies, + allTransitiveDependencies: + SingleMessageLoaderFamily._allTransitiveDependencies, + payload: payload, + ); + + SingleMessageLoaderProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.payload, + }) : super.internal(); + + final MailNotificationPayload payload; + + @override + Override overrideWith( + FutureOr Function(SingleMessageLoaderRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: SingleMessageLoaderProvider._internal( + (ref) => create(ref as SingleMessageLoaderRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + payload: payload, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _SingleMessageLoaderProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SingleMessageLoaderProvider && other.payload == payload; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, payload.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SingleMessageLoaderRef on AutoDisposeFutureProviderRef { + /// The parameter `payload` of this provider. + MailNotificationPayload get payload; +} + +class _SingleMessageLoaderProviderElement + extends AutoDisposeFutureProviderElement + with SingleMessageLoaderRef { + _SingleMessageLoaderProviderElement(super.provider); + + @override + MailNotificationPayload get payload => + (origin as SingleMessageLoaderProvider).payload; +} + +String _$mailtoHash() => r'392c1cf4d13bff03113b564193f1f1b21099cdac'; + +/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// +/// Copied from [mailto]. +@ProviderFor(mailto) +const mailtoProvider = MailtoFamily(); + +/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// +/// Copied from [mailto]. +class MailtoFamily extends Family { + /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri + /// + /// Copied from [mailto]. + const MailtoFamily(); + + /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri + /// + /// Copied from [mailto]. + MailtoProvider call({ + required Uri mailtoUri, + required MimeMessage originatingMessage, + }) { + return MailtoProvider( + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, + ); + } + + @override + MailtoProvider getProviderOverride( + covariant MailtoProvider provider, + ) { + return call( + mailtoUri: provider.mailtoUri, + originatingMessage: provider.originatingMessage, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'mailtoProvider'; +} + +/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// +/// Copied from [mailto]. +class MailtoProvider extends AutoDisposeProvider { + /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri + /// + /// Copied from [mailto]. + MailtoProvider({ + required Uri mailtoUri, + required MimeMessage originatingMessage, + }) : this._internal( + (ref) => mailto( + ref as MailtoRef, + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, + ), + from: mailtoProvider, + name: r'mailtoProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$mailtoHash, + dependencies: MailtoFamily._dependencies, + allTransitiveDependencies: MailtoFamily._allTransitiveDependencies, + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, + ); + + MailtoProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.mailtoUri, + required this.originatingMessage, + }) : super.internal(); + + final Uri mailtoUri; + final MimeMessage originatingMessage; + + @override + Override overrideWith( + MessageBuilder Function(MailtoRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: MailtoProvider._internal( + (ref) => create(ref as MailtoRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _MailtoProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MailtoProvider && + other.mailtoUri == mailtoUri && + other.originatingMessage == originatingMessage; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, mailtoUri.hashCode); + hash = _SystemHash.combine(hash, originatingMessage.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin MailtoRef on AutoDisposeProviderRef { + /// The parameter `mailtoUri` of this provider. + Uri get mailtoUri; + + /// The parameter `originatingMessage` of this provider. + MimeMessage get originatingMessage; +} + +class _MailtoProviderElement extends AutoDisposeProviderElement + with MailtoRef { + _MailtoProviderElement(super.provider); + + @override + Uri get mailtoUri => (origin as MailtoProvider).mailtoUri; + @override + MimeMessage get originatingMessage => + (origin as MailtoProvider).originatingMessage; +} + +String _$currentMailboxHash() => r'b11103d25c249597f26cf9342d17fa7e5e192359'; + +/// Provides the locally current active mailbox +/// +/// Copied from [currentMailbox]. +@ProviderFor(currentMailbox) +final currentMailboxProvider = AutoDisposeProvider.internal( + currentMailbox, + name: r'currentMailboxProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentMailboxHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CurrentMailboxRef = AutoDisposeProviderRef; String _$sourceHash() => r'ed4bfa87f9547328583d2c849f27a43200a6df1f'; -abstract class _$Source extends BuildlessAsyncNotifier { - late final Account account; +abstract class _$Source extends BuildlessAsyncNotifier { + late final Account account; + late final Mailbox? mailbox; + + Future build({ + required Account account, + Mailbox? mailbox, + }); +} + +/// Provides the message source for the given account +/// +/// Copied from [Source]. +@ProviderFor(Source) +const sourceProvider = SourceFamily(); + +/// Provides the message source for the given account +/// +/// Copied from [Source]. +class SourceFamily extends Family> { + /// Provides the message source for the given account + /// + /// Copied from [Source]. + const SourceFamily(); + + /// Provides the message source for the given account + /// + /// Copied from [Source]. + SourceProvider call({ + required Account account, + Mailbox? mailbox, + }) { + return SourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + SourceProvider getProviderOverride( + covariant SourceProvider provider, + ) { + return call( + account: provider.account, + mailbox: provider.mailbox, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'sourceProvider'; +} + +/// Provides the message source for the given account +/// +/// Copied from [Source]. +class SourceProvider extends AsyncNotifierProviderImpl { + /// Provides the message source for the given account + /// + /// Copied from [Source]. + SourceProvider({ + required Account account, + Mailbox? mailbox, + }) : this._internal( + () => Source() + ..account = account + ..mailbox = mailbox, + from: sourceProvider, + name: r'sourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$sourceHash, + dependencies: SourceFamily._dependencies, + allTransitiveDependencies: SourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + SourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.mailbox, + }) : super.internal(); + + final Account account; + final Mailbox? mailbox; + + @override + Future runNotifierBuild( + covariant Source notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(Source Function() create) { + return ProviderOverride( + origin: this, + override: SourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AsyncNotifierProviderElement createElement() { + return _SourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SourceProvider && + other.account == account && + other.mailbox == mailbox; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin SourceRef on AsyncNotifierProviderRef { + /// The parameter `account` of this provider. + Account get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _SourceProviderElement + extends AsyncNotifierProviderElement with SourceRef { + _SourceProviderElement(super.provider); + + @override + Account get account => (origin as SourceProvider).account; + @override + Mailbox? get mailbox => (origin as SourceProvider).mailbox; +} + +String _$unifiedSourceHash() => r'99774ff4963680842bdf0e538d5c4b8554bac75c'; + +abstract class _$UnifiedSource + extends BuildlessAsyncNotifier { + late final UnifiedAccount account; + late final Mailbox? mailbox; + + Future build({ + required UnifiedAccount account, + Mailbox? mailbox, + }); +} + +/// Provides the message source for the given account +/// +/// Copied from [UnifiedSource]. +@ProviderFor(UnifiedSource) +const unifiedSourceProvider = UnifiedSourceFamily(); + +/// Provides the message source for the given account +/// +/// Copied from [UnifiedSource]. +class UnifiedSourceFamily extends Family> { + /// Provides the message source for the given account + /// + /// Copied from [UnifiedSource]. + const UnifiedSourceFamily(); + + /// Provides the message source for the given account + /// + /// Copied from [UnifiedSource]. + UnifiedSourceProvider call({ + required UnifiedAccount account, + Mailbox? mailbox, + }) { + return UnifiedSourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + UnifiedSourceProvider getProviderOverride( + covariant UnifiedSourceProvider provider, + ) { + return call( + account: provider.account, + mailbox: provider.mailbox, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'unifiedSourceProvider'; +} + +/// Provides the message source for the given account +/// +/// Copied from [UnifiedSource]. +class UnifiedSourceProvider + extends AsyncNotifierProviderImpl { + /// Provides the message source for the given account + /// + /// Copied from [UnifiedSource]. + UnifiedSourceProvider({ + required UnifiedAccount account, + Mailbox? mailbox, + }) : this._internal( + () => UnifiedSource() + ..account = account + ..mailbox = mailbox, + from: unifiedSourceProvider, + name: r'unifiedSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$unifiedSourceHash, + dependencies: UnifiedSourceFamily._dependencies, + allTransitiveDependencies: + UnifiedSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + UnifiedSourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.mailbox, + }) : super.internal(); + + final UnifiedAccount account; + final Mailbox? mailbox; + + @override + Future runNotifierBuild( + covariant UnifiedSource notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(UnifiedSource Function() create) { + return ProviderOverride( + origin: this, + override: UnifiedSourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AsyncNotifierProviderElement + createElement() { + return _UnifiedSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is UnifiedSourceProvider && + other.account == account && + other.mailbox == mailbox; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin UnifiedSourceRef on AsyncNotifierProviderRef { + /// The parameter `account` of this provider. + UnifiedAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _UnifiedSourceProviderElement + extends AsyncNotifierProviderElement + with UnifiedSourceRef { + _UnifiedSourceProviderElement(super.provider); + + @override + UnifiedAccount get account => (origin as UnifiedSourceProvider).account; + @override + Mailbox? get mailbox => (origin as UnifiedSourceProvider).mailbox; +} + +String _$realSourceHash() => r'073627644194316dbd304481cad3a0f9306fcb8f'; + +abstract class _$RealSource + extends BuildlessAsyncNotifier { + late final RealAccount account; + late final Mailbox? mailbox; + + Future build({ + required RealAccount account, + Mailbox? mailbox, + }); +} + +/// Provides the message source for the given account +/// +/// Copied from [RealSource]. +@ProviderFor(RealSource) +const realSourceProvider = RealSourceFamily(); + +/// Provides the message source for the given account +/// +/// Copied from [RealSource]. +class RealSourceFamily extends Family> { + /// Provides the message source for the given account + /// + /// Copied from [RealSource]. + const RealSourceFamily(); + + /// Provides the message source for the given account + /// + /// Copied from [RealSource]. + RealSourceProvider call({ + required RealAccount account, + Mailbox? mailbox, + }) { + return RealSourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + RealSourceProvider getProviderOverride( + covariant RealSourceProvider provider, + ) { + return call( + account: provider.account, + mailbox: provider.mailbox, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'realSourceProvider'; +} + +/// Provides the message source for the given account +/// +/// Copied from [RealSource]. +class RealSourceProvider + extends AsyncNotifierProviderImpl { + /// Provides the message source for the given account + /// + /// Copied from [RealSource]. + RealSourceProvider({ + required RealAccount account, + Mailbox? mailbox, + }) : this._internal( + () => RealSource() + ..account = account + ..mailbox = mailbox, + from: realSourceProvider, + name: r'realSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$realSourceHash, + dependencies: RealSourceFamily._dependencies, + allTransitiveDependencies: + RealSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + RealSourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.mailbox, + }) : super.internal(); + + final RealAccount account; + final Mailbox? mailbox; + + @override + Future runNotifierBuild( + covariant RealSource notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(RealSource Function() create) { + return ProviderOverride( + origin: this, + override: RealSourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AsyncNotifierProviderElement + createElement() { + return _RealSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is RealSourceProvider && + other.account == account && + other.mailbox == mailbox; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin RealSourceRef on AsyncNotifierProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _RealSourceProviderElement + extends AsyncNotifierProviderElement + with RealSourceRef { + _RealSourceProviderElement(super.provider); + + @override + RealAccount get account => (origin as RealSourceProvider).account; + @override + Mailbox? get mailbox => (origin as RealSourceProvider).mailbox; +} + +String _$realMimeSourceHash() => r'a0faaeb48c887fc8a93351f1b8efb0d01c8fd366'; + +abstract class _$RealMimeSource + extends BuildlessAsyncNotifier { + late final RealAccount account; late final Mailbox? mailbox; - Future build({ - required Account account, + Future build({ + required RealAccount account, Mailbox? mailbox, }); } /// Provides the message source for the given account /// -/// Copied from [Source]. -@ProviderFor(Source) -const sourceProvider = SourceFamily(); +/// Copied from [RealMimeSource]. +@ProviderFor(RealMimeSource) +const realMimeSourceProvider = RealMimeSourceFamily(); /// Provides the message source for the given account /// -/// Copied from [Source]. -class SourceFamily extends Family> { +/// Copied from [RealMimeSource]. +class RealMimeSourceFamily extends Family> { /// Provides the message source for the given account /// - /// Copied from [Source]. - const SourceFamily(); + /// Copied from [RealMimeSource]. + const RealMimeSourceFamily(); /// Provides the message source for the given account /// - /// Copied from [Source]. - SourceProvider call({ - required Account account, + /// Copied from [RealMimeSource]. + RealMimeSourceProvider call({ + required RealAccount account, Mailbox? mailbox, }) { - return SourceProvider( + return RealMimeSourceProvider( account: account, mailbox: mailbox, ); } @override - SourceProvider getProviderOverride( - covariant SourceProvider provider, + RealMimeSourceProvider getProviderOverride( + covariant RealMimeSourceProvider provider, ) { return call( account: provider.account, @@ -387,36 +1377,38 @@ class SourceFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'sourceProvider'; + String? get name => r'realMimeSourceProvider'; } /// Provides the message source for the given account /// -/// Copied from [Source]. -class SourceProvider extends AsyncNotifierProviderImpl { +/// Copied from [RealMimeSource]. +class RealMimeSourceProvider + extends AsyncNotifierProviderImpl { /// Provides the message source for the given account /// - /// Copied from [Source]. - SourceProvider({ - required Account account, + /// Copied from [RealMimeSource]. + RealMimeSourceProvider({ + required RealAccount account, Mailbox? mailbox, }) : this._internal( - () => Source() + () => RealMimeSource() ..account = account ..mailbox = mailbox, - from: sourceProvider, - name: r'sourceProvider', + from: realMimeSourceProvider, + name: r'realMimeSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$sourceHash, - dependencies: SourceFamily._dependencies, - allTransitiveDependencies: SourceFamily._allTransitiveDependencies, + : _$realMimeSourceHash, + dependencies: RealMimeSourceFamily._dependencies, + allTransitiveDependencies: + RealMimeSourceFamily._allTransitiveDependencies, account: account, mailbox: mailbox, ); - SourceProvider._internal( + RealMimeSourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -427,12 +1419,12 @@ class SourceProvider extends AsyncNotifierProviderImpl { required this.mailbox, }) : super.internal(); - final Account account; + final RealAccount account; final Mailbox? mailbox; @override - Future runNotifierBuild( - covariant Source notifier, + Future runNotifierBuild( + covariant RealMimeSource notifier, ) { return notifier.build( account: account, @@ -441,10 +1433,10 @@ class SourceProvider extends AsyncNotifierProviderImpl { } @override - Override overrideWith(Source Function() create) { + Override overrideWith(RealMimeSource Function() create) { return ProviderOverride( origin: this, - override: SourceProvider._internal( + override: RealMimeSourceProvider._internal( () => create() ..account = account ..mailbox = mailbox, @@ -460,13 +1452,14 @@ class SourceProvider extends AsyncNotifierProviderImpl { } @override - AsyncNotifierProviderElement createElement() { - return _SourceProviderElement(this); + AsyncNotifierProviderElement + createElement() { + return _RealMimeSourceProviderElement(this); } @override bool operator ==(Object other) { - return other is SourceProvider && + return other is RealMimeSourceProvider && other.account == account && other.mailbox == mailbox; } @@ -481,68 +1474,68 @@ class SourceProvider extends AsyncNotifierProviderImpl { } } -mixin SourceRef on AsyncNotifierProviderRef { +mixin RealMimeSourceRef on AsyncNotifierProviderRef { /// The parameter `account` of this provider. - Account get account; + RealAccount get account; /// The parameter `mailbox` of this provider. Mailbox? get mailbox; } -class _SourceProviderElement - extends AsyncNotifierProviderElement with SourceRef { - _SourceProviderElement(super.provider); +class _RealMimeSourceProviderElement + extends AsyncNotifierProviderElement + with RealMimeSourceRef { + _RealMimeSourceProviderElement(super.provider); @override - Account get account => (origin as SourceProvider).account; + RealAccount get account => (origin as RealMimeSourceProvider).account; @override - Mailbox? get mailbox => (origin as SourceProvider).mailbox; + Mailbox? get mailbox => (origin as RealMimeSourceProvider).mailbox; } -String _$unifiedSourceHash() => r'de509cae0ff4f5d7d917b94a192edc45cd7fc398'; +String _$mailClientSourceHash() => r'd9c97325207816d3dadefe6e6afee06707af88b5'; -abstract class _$UnifiedSource - extends BuildlessAsyncNotifier { - late final UnifiedAccount account; +abstract class _$MailClientSource extends BuildlessNotifier { + late final RealAccount account; late final Mailbox? mailbox; - Future build({ - required UnifiedAccount account, + MailClient build({ + required RealAccount account, Mailbox? mailbox, }); } -/// Provides the message source for the given account +/// Provides mail clients /// -/// Copied from [UnifiedSource]. -@ProviderFor(UnifiedSource) -const unifiedSourceProvider = UnifiedSourceFamily(); +/// Copied from [MailClientSource]. +@ProviderFor(MailClientSource) +const mailClientSourceProvider = MailClientSourceFamily(); -/// Provides the message source for the given account +/// Provides mail clients /// -/// Copied from [UnifiedSource]. -class UnifiedSourceFamily extends Family> { - /// Provides the message source for the given account +/// Copied from [MailClientSource]. +class MailClientSourceFamily extends Family { + /// Provides mail clients /// - /// Copied from [UnifiedSource]. - const UnifiedSourceFamily(); + /// Copied from [MailClientSource]. + const MailClientSourceFamily(); - /// Provides the message source for the given account + /// Provides mail clients /// - /// Copied from [UnifiedSource]. - UnifiedSourceProvider call({ - required UnifiedAccount account, + /// Copied from [MailClientSource]. + MailClientSourceProvider call({ + required RealAccount account, Mailbox? mailbox, }) { - return UnifiedSourceProvider( + return MailClientSourceProvider( account: account, mailbox: mailbox, ); } @override - UnifiedSourceProvider getProviderOverride( - covariant UnifiedSourceProvider provider, + MailClientSourceProvider getProviderOverride( + covariant MailClientSourceProvider provider, ) { return call( account: provider.account, @@ -562,38 +1555,38 @@ class UnifiedSourceFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'unifiedSourceProvider'; + String? get name => r'mailClientSourceProvider'; } -/// Provides the message source for the given account +/// Provides mail clients /// -/// Copied from [UnifiedSource]. -class UnifiedSourceProvider - extends AsyncNotifierProviderImpl { - /// Provides the message source for the given account +/// Copied from [MailClientSource]. +class MailClientSourceProvider + extends NotifierProviderImpl { + /// Provides mail clients /// - /// Copied from [UnifiedSource]. - UnifiedSourceProvider({ - required UnifiedAccount account, + /// Copied from [MailClientSource]. + MailClientSourceProvider({ + required RealAccount account, Mailbox? mailbox, }) : this._internal( - () => UnifiedSource() + () => MailClientSource() ..account = account ..mailbox = mailbox, - from: unifiedSourceProvider, - name: r'unifiedSourceProvider', + from: mailClientSourceProvider, + name: r'mailClientSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$unifiedSourceHash, - dependencies: UnifiedSourceFamily._dependencies, + : _$mailClientSourceHash, + dependencies: MailClientSourceFamily._dependencies, allTransitiveDependencies: - UnifiedSourceFamily._allTransitiveDependencies, + MailClientSourceFamily._allTransitiveDependencies, account: account, mailbox: mailbox, ); - UnifiedSourceProvider._internal( + MailClientSourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -604,12 +1597,12 @@ class UnifiedSourceProvider required this.mailbox, }) : super.internal(); - final UnifiedAccount account; + final RealAccount account; final Mailbox? mailbox; @override - Future runNotifierBuild( - covariant UnifiedSource notifier, + MailClient runNotifierBuild( + covariant MailClientSource notifier, ) { return notifier.build( account: account, @@ -618,10 +1611,10 @@ class UnifiedSourceProvider } @override - Override overrideWith(UnifiedSource Function() create) { + Override overrideWith(MailClientSource Function() create) { return ProviderOverride( origin: this, - override: UnifiedSourceProvider._internal( + override: MailClientSourceProvider._internal( () => create() ..account = account ..mailbox = mailbox, @@ -637,14 +1630,13 @@ class UnifiedSourceProvider } @override - AsyncNotifierProviderElement - createElement() { - return _UnifiedSourceProviderElement(this); + NotifierProviderElement createElement() { + return _MailClientSourceProviderElement(this); } @override bool operator ==(Object other) { - return other is UnifiedSourceProvider && + return other is MailClientSourceProvider && other.account == account && other.mailbox == mailbox; } @@ -659,69 +1651,71 @@ class UnifiedSourceProvider } } -mixin UnifiedSourceRef on AsyncNotifierProviderRef { +mixin MailClientSourceRef on NotifierProviderRef { /// The parameter `account` of this provider. - UnifiedAccount get account; + RealAccount get account; /// The parameter `mailbox` of this provider. Mailbox? get mailbox; } -class _UnifiedSourceProviderElement - extends AsyncNotifierProviderElement - with UnifiedSourceRef { - _UnifiedSourceProviderElement(super.provider); +class _MailClientSourceProviderElement + extends NotifierProviderElement + with MailClientSourceRef { + _MailClientSourceProviderElement(super.provider); @override - UnifiedAccount get account => (origin as UnifiedSourceProvider).account; + RealAccount get account => (origin as MailClientSourceProvider).account; @override - Mailbox? get mailbox => (origin as UnifiedSourceProvider).mailbox; + Mailbox? get mailbox => (origin as MailClientSourceProvider).mailbox; } -String _$realSourceHash() => r'463138cc3fd08bab9e850e7591385610bf33bfb2'; +String _$firstTimeMailClientSourceHash() => + r'b0c0d4b9bf0d46a2f8bd4d2fd272dace35e279cf'; -abstract class _$RealSource - extends BuildlessAsyncNotifier { +abstract class _$FirstTimeMailClientSource + extends BuildlessAutoDisposeAsyncNotifier { late final RealAccount account; late final Mailbox? mailbox; - Future build({ + Future build({ required RealAccount account, Mailbox? mailbox, }); } -/// Provides the message source for the given account +/// Provides mail clients /// -/// Copied from [RealSource]. -@ProviderFor(RealSource) -const realSourceProvider = RealSourceFamily(); +/// Copied from [FirstTimeMailClientSource]. +@ProviderFor(FirstTimeMailClientSource) +const firstTimeMailClientSourceProvider = FirstTimeMailClientSourceFamily(); -/// Provides the message source for the given account +/// Provides mail clients /// -/// Copied from [RealSource]. -class RealSourceFamily extends Family> { - /// Provides the message source for the given account +/// Copied from [FirstTimeMailClientSource]. +class FirstTimeMailClientSourceFamily + extends Family> { + /// Provides mail clients /// - /// Copied from [RealSource]. - const RealSourceFamily(); + /// Copied from [FirstTimeMailClientSource]. + const FirstTimeMailClientSourceFamily(); - /// Provides the message source for the given account + /// Provides mail clients /// - /// Copied from [RealSource]. - RealSourceProvider call({ + /// Copied from [FirstTimeMailClientSource]. + FirstTimeMailClientSourceProvider call({ required RealAccount account, Mailbox? mailbox, }) { - return RealSourceProvider( + return FirstTimeMailClientSourceProvider( account: account, mailbox: mailbox, ); } @override - RealSourceProvider getProviderOverride( - covariant RealSourceProvider provider, + FirstTimeMailClientSourceProvider getProviderOverride( + covariant FirstTimeMailClientSourceProvider provider, ) { return call( account: provider.account, @@ -741,38 +1735,39 @@ class RealSourceFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'realSourceProvider'; + String? get name => r'firstTimeMailClientSourceProvider'; } -/// Provides the message source for the given account +/// Provides mail clients /// -/// Copied from [RealSource]. -class RealSourceProvider - extends AsyncNotifierProviderImpl { - /// Provides the message source for the given account +/// Copied from [FirstTimeMailClientSource]. +class FirstTimeMailClientSourceProvider + extends AutoDisposeAsyncNotifierProviderImpl { + /// Provides mail clients /// - /// Copied from [RealSource]. - RealSourceProvider({ + /// Copied from [FirstTimeMailClientSource]. + FirstTimeMailClientSourceProvider({ required RealAccount account, Mailbox? mailbox, }) : this._internal( - () => RealSource() + () => FirstTimeMailClientSource() ..account = account ..mailbox = mailbox, - from: realSourceProvider, - name: r'realSourceProvider', + from: firstTimeMailClientSourceProvider, + name: r'firstTimeMailClientSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$realSourceHash, - dependencies: RealSourceFamily._dependencies, + : _$firstTimeMailClientSourceHash, + dependencies: FirstTimeMailClientSourceFamily._dependencies, allTransitiveDependencies: - RealSourceFamily._allTransitiveDependencies, + FirstTimeMailClientSourceFamily._allTransitiveDependencies, account: account, mailbox: mailbox, ); - RealSourceProvider._internal( + FirstTimeMailClientSourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -787,8 +1782,8 @@ class RealSourceProvider final Mailbox? mailbox; @override - Future runNotifierBuild( - covariant RealSource notifier, + Future runNotifierBuild( + covariant FirstTimeMailClientSource notifier, ) { return notifier.build( account: account, @@ -797,10 +1792,10 @@ class RealSourceProvider } @override - Override overrideWith(RealSource Function() create) { + Override overrideWith(FirstTimeMailClientSource Function() create) { return ProviderOverride( origin: this, - override: RealSourceProvider._internal( + override: FirstTimeMailClientSourceProvider._internal( () => create() ..account = account ..mailbox = mailbox, @@ -816,14 +1811,14 @@ class RealSourceProvider } @override - AsyncNotifierProviderElement - createElement() { - return _RealSourceProviderElement(this); + AutoDisposeAsyncNotifierProviderElement createElement() { + return _FirstTimeMailClientSourceProviderElement(this); } @override bool operator ==(Object other) { - return other is RealSourceProvider && + return other is FirstTimeMailClientSourceProvider && other.account == account && other.mailbox == mailbox; } @@ -838,7 +1833,8 @@ class RealSourceProvider } } -mixin RealSourceRef on AsyncNotifierProviderRef { +mixin FirstTimeMailClientSourceRef + on AutoDisposeAsyncNotifierProviderRef { /// The parameter `account` of this provider. RealAccount get account; @@ -846,15 +1842,16 @@ mixin RealSourceRef on AsyncNotifierProviderRef { Mailbox? get mailbox; } -class _RealSourceProviderElement - extends AsyncNotifierProviderElement - with RealSourceRef { - _RealSourceProviderElement(super.provider); +class _FirstTimeMailClientSourceProviderElement + extends AutoDisposeAsyncNotifierProviderElement with FirstTimeMailClientSourceRef { + _FirstTimeMailClientSourceProviderElement(super.provider); @override - RealAccount get account => (origin as RealSourceProvider).account; + RealAccount get account => + (origin as FirstTimeMailClientSourceProvider).account; @override - Mailbox? get mailbox => (origin as RealSourceProvider).mailbox; + Mailbox? get mailbox => (origin as FirstTimeMailClientSourceProvider).mailbox; } // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/mail/service.dart b/lib/mail/service.dart new file mode 100644 index 0000000..e95a236 --- /dev/null +++ b/lib/mail/service.dart @@ -0,0 +1,137 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; + +import '../account/model.dart'; +import '../events/app_event_bus.dart'; +import '../locator.dart'; +import '../models/async_mime_source.dart'; +import '../models/async_mime_source_factory.dart'; +import '../services/providers.dart'; + +/// Callback when the configuration of a mail client has changed, +/// typically when the OAuth token has been refreshed +typedef OnMailClientConfigChanged = Future Function(MailAccount account); + +/// Abstracts interaction and creation of mail clients / mime sources +class EmailService { + EmailService._(); + static final _instance = EmailService._(); + + /// Retrieves the singleton instance + static EmailService get instance => _instance; + + static const _clientId = Id(name: 'Maily', version: '1.0'); + final _mimeSourceFactory = + const AsyncMimeSourceFactory(isOfflineModeSupported: false); + + /// Creates a mime source for the given account + Future createMimeSource({ + required MailClient mailClient, + Mailbox? mailbox, + }) async { + await mailClient.connect(); + if (mailbox == null) { + mailbox = await mailClient.selectInbox(); + } else { + await mailClient.selectMailbox(mailbox); + } + final source = _mimeSourceFactory.createMailboxMimeSource( + mailClient, + mailbox, + ); //..addSubscriber(this); + + return source; + } + + /// Creates a mail client for the given account + MailClient createMailClient( + MailAccount mailAccount, + OnMailClientConfigChanged? onMailClientConfigChanged, + ) { + final bool isLogEnabled = kDebugMode || + (mailAccount.attributes[RealAccount.attributeEnableLogging] ?? false); + + return MailClient( + mailAccount, + isLogEnabled: isLogEnabled, + logName: mailAccount.name, + eventBus: AppEventBus.eventBus, + clientId: _clientId, + refresh: _refreshToken, + onConfigChanged: onMailClientConfigChanged, + downloadSizeLimit: 32 * 1024, + ); + } + + Future _refreshToken( + MailClient mailClient, + OauthToken expiredToken, + ) { + final providerId = expiredToken.provider; + if (providerId == null) { + throw MailException( + mailClient, + 'no provider registered for token $expiredToken', + ); + } + // TODO(RV): replace provider service with a riverpod provider + final provider = locator()[providerId]; + if (provider == null) { + throw MailException( + mailClient, + 'no provider "$providerId" found - token: $expiredToken', + ); + } + final oauthClient = provider.oauthClient; + if (oauthClient == null || !oauthClient.isEnabled) { + throw MailException( + mailClient, + 'provider $providerId has no valid OAuth configuration', + ); + } + + return oauthClient.refresh(expiredToken); + } + + /// Connects a MailAccount. + /// + /// Adapts the authentication user name if necessary + Future connectFirstTime( + MailAccount mailAccount, + OnMailClientConfigChanged? onMailClientConfigChanged, + ) async { + var usedMailAccount = mailAccount; + var mailClient = createMailClient( + usedMailAccount, + onMailClientConfigChanged, + ); + try { + await mailClient.connect(); + } on MailException { + await mailClient.disconnect(); + final email = usedMailAccount.email; + var preferredUserName = + usedMailAccount.incoming.serverConfig.getUserName(email); + if (preferredUserName == null || preferredUserName == email) { + final atIndex = mailAccount.email.lastIndexOf('@'); + preferredUserName = usedMailAccount.email.substring(0, atIndex); + usedMailAccount = + usedMailAccount.copyWithAuthenticationUserName(preferredUserName); + await mailClient.disconnect(); + mailClient = createMailClient( + usedMailAccount, + onMailClientConfigChanged, + ); + try { + await mailClient.connect(); + } on MailException { + await mailClient.disconnect(); + + return null; + } + } + } + + return ConnectedAccount(usedMailAccount, mailClient); + } +} diff --git a/lib/main.dart b/lib/main.dart index d2be699..4188140 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'account/providers.dart'; +import 'account/provider.dart'; import 'app_lifecycle/provider.dart'; import 'localization/app_localizations.g.dart'; import 'locator.dart'; diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 6eff503..48b737e 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -25,12 +25,3 @@ class Contact { //phone numbers, profile photo(s), //TODO consider full vCard support } - -class ContactManager { - ContactManager(this.addresses); - final List addresses; - - Iterable find(String search) => addresses.where((address) => - address.email.contains(search) || - (address.hasPersonalName && address.personalName!.contains(search))); -} diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 26ada3d..8468529 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -492,10 +492,10 @@ abstract class MessageSource extends ChangeNotifier for (final message in messages) { final source = getMimeSource(message); if (source == null) { - if (kDebugMode) { - print( - 'unable to locate mime-source for message ${message.mimeMessage}'); - } + logger.w( + 'unable to locate mime-source for ' + 'message ${message.mimeMessage}', + ); continue; } final existingMessages = mimesBySource[source]; @@ -534,6 +534,7 @@ abstract class MessageSource extends ChangeNotifier final future = source.store(messages, flags, action: action); futures.add(future); } + return Future.wait(futures); } @@ -612,6 +613,11 @@ abstract class MessageSource extends ChangeNotifier ) => Message(mime, this, index); + /// Loads the message source for the given [payload] + Future loadSingleMessage(MailNotificationPayload payload) { + throw UnimplementedError(); + } + // void replaceMime(Message message, MimeMessage mime) { // final mimeSource = getMimeSource(message); // remove(message); @@ -640,6 +646,7 @@ class MailboxMessageSource extends MessageSource { @override int get size => mimeSource.size; + /// The mime source for this message source final AsyncMimeSource mimeSource; @override @@ -745,6 +752,22 @@ class MailboxMessageSource extends MessageSource { cache.clear(); notifyListeners(); } + + @override + Future loadSingleMessage( + MailNotificationPayload payload, + ) async { + final payloadMime = MimeMessage() + ..sequenceId = payload.sequenceId + ..uid = payload.uid; + final mime = await mimeSource.mailClient.fetchMessageContents(payloadMime); + + final source = SingleMessageSource(this, account: account); + final message = Message(mime, source, 0); + source.singleMessage = message; + + return message; + } } class _MultipleMessageSourceId { diff --git a/lib/routes.dart b/lib/routes.dart index 3b6b348..291c520 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -11,19 +11,43 @@ import 'account/model.dart'; import 'main.dart'; import 'models/models.dart'; import 'screens/screens.dart'; +import 'services/notification_service.dart'; import 'settings/view/view.dart'; import 'widgets/app_drawer.dart'; class Routes { Routes._(); static const String _root = '/'; + + /// Displays either the welcome screen or the mail screen + /// for the default account static const String home = '/home'; + + /// Creates a new account static const String accountAdd = '/accountAdd'; + + /// Allows to edit a single account + /// + /// pathParameters: [pathParameterEmail] static const String accountEdit = '/accountEdit'; + + /// Allows to edit a the account server settings + /// + /// pathParameters: [pathParameterEmail] or + /// extra: [RealAccount] static const String accountServerDetails = '/accountServerDetails'; + + /// Displays messages of the given account + /// + /// pathParameters: [pathParameterEmail] + /// queryParameters: [queryParameterEncodedMailboxPath] static const String mail = '/mail'; + + /// Displays the settings static const String settings = '/settings'; static const String settingsSecurity = '/settings/security'; + + /// Displays the settings for all accounts static const String settingsAccounts = '/settings/accounts'; static const String settingsDesign = '/settings/design'; static const String settingsFeedback = '/settings/feedback'; @@ -35,15 +59,52 @@ class Routes { static const String settingsSignature = '/settingsSignature'; static const String settingsDefaultSender = '/settingsDefaultSender'; static const String settingsReplyFormat = '/settingsReplyFormat'; + + /// Displays a message source directly + /// + /// extra: [MessageSource] static const String messageSource = '/messageSource'; static const String messageSourceFuture = '/messageSource/future'; + + /// Displays a mail search + /// + /// extra: [MailSearch] + static const String mailSearch = '/mailSearch'; + + /// Shows message details + /// + /// extra: [Message] + /// queryParameters: [queryParameterBlockExternalContent] static const String mailDetails = '/mailDetails'; + + /// Loads message details from notification data + /// + /// extra: [MailNotificationPayload] + /// queryParameters: [queryParameterBlockExternalContent] + static const String mailDetailsForNotification = + '/mailDetailsForNotification'; + + /// Shows all message contents + /// + /// extra: [Message] static const String mailContents = '/mailContents'; + + /// Composes a new message + /// + /// extra: [ComposeData] static const String mailCompose = '/mailCompose'; static const String welcome = '/welcome'; static const String splash = '/splash'; + + /// Displays interactive media + /// + /// extra: [InteractiveMediaWidget] static const String interactiveMedia = '/interactiveMedia'; static const String locationPicker = '/locationPicker'; + + /// Displays the source code of a message + /// + /// extra: [MimeMessage] static const String sourceCode = '/sourceCode'; static const String webview = '/webview'; static const String appDrawer = '/appDrawer'; @@ -52,9 +113,14 @@ class Routes { /// Path parameter name for an email address static const String pathParameterEmail = 'email'; - /// Path parameter name for an encoded mailbox path + /// Query parameter name for an encoded mailbox path static const String queryParameterEncodedMailboxPath = 'mailbox'; + /// Query parameter to signal external images should be blocked + static const String queryParameterBlockExternalContent = 'blockExternal'; + + /// The navigator key to use for routing when a widget's context is not + /// mounted anymore static final navigatorKey = GlobalKey(); /// The routing configuration @@ -69,6 +135,13 @@ class Routes { path: splash, builder: (context, state) => const SplashScreen(), ), + GoRoute( + name: accountAdd, + path: accountAdd, + builder: (context, state) => AccountAddScreen( + launchedFromWelcome: state.uri.queryParameters['welcome'] == 'true', + ), + ), GoRoute( path: home, builder: (context, state) => const HomeScreen(), @@ -82,6 +155,75 @@ class Routes { state.uri.queryParameters[queryParameterEncodedMailboxPath], ), ), + GoRoute( + name: mailSearch, + path: mailSearch, + builder: (context, state) { + final extra = state.extra; + + return extra is MailSearch + ? MailSearchScreen(search: extra) + : const HomeScreen(); + }, + ), + GoRoute( + name: messageSource, + path: messageSource, + builder: (context, state) { + final extra = state.extra; + + return extra is MessageSource + ? MessageSourceScreen(messageSource: extra) + : const HomeScreen(); + }, + ), + GoRoute( + name: mailDetails, + path: mailDetails, + builder: (context, state) { + final extra = state.extra; + final blockExternalContent = + state.uri.queryParameters[queryParameterBlockExternalContent] == + 'true'; + + return extra is Message + ? MessageDetailsScreen( + message: extra, + blockExternalContent: blockExternalContent, + ) + : const HomeScreen(); + }, + ), + GoRoute( + name: mailDetailsForNotification, + path: mailDetailsForNotification, + builder: (context, state) { + final extra = state.extra; + final blockExternalContent = + state.uri.queryParameters[queryParameterBlockExternalContent] == + 'true'; + + return extra is MailNotificationPayload + ? MessageDetailsForNotificationScreen( + payload: extra, + blockExternalContent: blockExternalContent, + ) + : const HomeScreen(); + }, + ), + GoRoute( + name: mailContents, + path: mailContents, + builder: (context, state) { + final extra = state.extra; + + return extra is Message + ? MessageContentsScreen( + message: extra, + ) + : const HomeScreen(); + }, + ), GoRoute( name: accountEdit, path: '$accountEdit/:$pathParameterEmail', @@ -89,6 +231,48 @@ class Routes { accountEmail: state.pathParameters[pathParameterEmail] ?? '', ), ), + GoRoute( + name: accountServerDetails, + path: '$accountServerDetails/:$pathParameterEmail', + builder: (context, state) { + final email = state.pathParameters[pathParameterEmail]; + if (email != null) { + return AccountServerDetailsScreen( + accountEmail: email, + ); + } + final account = state.extra; + if (account is RealAccount) { + return AccountServerDetailsScreen( + account: account, + ); + } + + return const HomeScreen(); + }, + ), + GoRoute( + name: mailCompose, + path: mailCompose, + builder: (context, state) { + final data = state.extra; + + return data is ComposeData + ? ComposeScreen(data: data) + : const HomeScreen(); + }, + ), + GoRoute( + name: interactiveMedia, + path: interactiveMedia, + builder: (context, state) { + final widget = state.extra; + + return widget is InteractiveMediaWidget + ? InteractiveMediaScreen(mediaWidget: widget) + : const HomeScreen(); + }, + ), GoRoute( path: settings, name: settings, @@ -99,6 +283,72 @@ class Routes { path: settingsAccounts, builder: (context, state) => const SettingsAccountsScreen(), ), + GoRoute( + name: settingsDefaultSender, + path: settingsDefaultSender, + builder: (context, state) => const SettingsDefaultSenderScreen(), + ), + GoRoute( + name: settingsDesign, + path: settingsDesign, + builder: (context, state) => const SettingsDesignScreen(), + ), + GoRoute( + name: settingsDevelopment, + path: settingsDevelopment, + builder: (context, state) => const SettingsDeveloperModeScreen(), + ), + GoRoute( + name: settingsFeedback, + path: settingsFeedback, + builder: (context, state) => const SettingsFeedbackScreen(), + ), + GoRoute( + name: settingsFolders, + path: settingsFolders, + builder: (context, state) => const SettingsFoldersScreen(), + ), + GoRoute( + name: settingsLanguage, + path: settingsLanguage, + builder: (context, state) => const SettingsLanguageScreen(), + ), + GoRoute( + name: settingsReadReceipts, + path: settingsReadReceipts, + builder: (context, state) => const SettingsReadReceiptsScreen(), + ), + GoRoute( + name: settingsReplyFormat, + path: settingsReplyFormat, + builder: (context, state) => const SettingsReplyScreen(), + ), + GoRoute( + name: settingsSecurity, + path: settingsSecurity, + builder: (context, state) => const SettingsSecurityScreen(), + ), + GoRoute( + name: settingsSignature, + path: settingsSignature, + builder: (context, state) => const SettingsSignatureScreen(), + ), + GoRoute( + name: settingsSwipe, + path: settingsSwipe, + builder: (context, state) => const SettingsSwipeScreen(), + ), + GoRoute( + name: sourceCode, + path: sourceCode, + builder: (context, state) { + final mimeMessage = state.extra; + + return mimeMessage is MimeMessage + ? SourceCodeScreen(mimeMessage: mimeMessage) + : const HomeScreen(); + }, + ), ], ); } @@ -112,9 +362,6 @@ class AppRouter { launchedFromWelcome: arguments == true, ); break; - case Routes.accountServerDetails: - page = AccountServerDetailsScreen(account: arguments! as RealAccount); - break; case Routes.accountEdit: page = AccountEditScreen(accountEmail: (arguments! as RealAccount).email); @@ -129,7 +376,7 @@ class AppRouter { page = const SettingsAccountsScreen(); break; case Routes.settingsDesign: - page = const SettingsThemeScreen(); + page = const SettingsDesignScreen(); break; case Routes.settingsFeedback: page = const SettingsFeedbackScreen(); @@ -171,7 +418,7 @@ class AppRouter { } else if (arguments is DisplayMessageArguments) { page = MessageDetailsScreen( message: arguments.message, - blockExternalContents: arguments.blockExternalContent, + blockExternalContent: arguments.blockExternalContent, ); } else { page = const WelcomeScreen(); diff --git a/lib/screens/account_add_screen.dart b/lib/screens/account_add_screen.dart index fe9a635..7f9abb0 100644 --- a/lib/screens/account_add_screen.dart +++ b/lib/screens/account_add_screen.dart @@ -9,15 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; import '../account/model.dart'; -import '../account/providers.dart'; +import '../account/provider.dart'; import '../extensions/extensions.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; +import '../mail/provider.dart'; import '../routes.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; -import '../services/providers.dart' as mailProviders; +import '../services/providers.dart' as mail_providers; import '../util/modal_bottom_sheet_helper.dart'; import '../util/validator.dart'; import '../widgets/account_provider_selector.dart'; @@ -52,7 +51,7 @@ class _AccountAddScreenState extends ConsumerState { final TextEditingController _userNameController = TextEditingController(); bool _isProviderResolving = false; - mailProviders.Provider? _provider; + mail_providers.Provider? _provider; final bool _isManualSettings = false; bool _isAccountVerifying = false; bool _isAccountVerified = false; @@ -65,7 +64,7 @@ class _AccountAddScreenState extends ConsumerState { BuildContext context, AppLocalizations localizations, ) async { - mailProviders.Provider? selectedProvider; + mail_providers.Provider? selectedProvider; final result = await ModelBottomSheetHelper.showModalBottomSheet( context, localizations.accountProviderStepTitle, @@ -81,7 +80,6 @@ class _AccountAddScreenState extends ConsumerState { if (!result) { return; } - if (selectedProvider != null) { // a standard provider has been chosen, now query the password or start the oauth process: setState(() { @@ -101,16 +99,19 @@ class _AccountAddScreenState extends ConsumerState { serverConfig: ServerConfig(), ), ); - - final editResult = await locator() - .push(Routes.accountServerDetails, arguments: RealAccount(account)); - if (editResult is ConnectedAccount) { - setState(() { - _realAccount = RealAccount(editResult.mailAccount); - _mailClient = editResult.mailClient; - _currentStep = 2; - _isAccountVerified = true; - }); + if (context.mounted) { + final editResult = await context.pushNamed( + Routes.accountServerDetails, + extra: RealAccount(account), + ); + if (editResult is ConnectedAccount) { + setState(() { + _realAccount = RealAccount(editResult.mailAccount); + _mailClient = editResult.mailClient; + _currentStep = 2; + _isAccountVerified = true; + }); + } } } } @@ -130,8 +131,7 @@ class _AccountAddScreenState extends ConsumerState { // print('build: current step=$_currentStep'); final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.addAccountTitle, content: Column( children: [ @@ -165,7 +165,7 @@ class _AccountAddScreenState extends ConsumerState { _buildAccountSetupStep(context, localizations), ], ), - ) + ), ], ), ); @@ -197,7 +197,7 @@ class _AccountAddScreenState extends ConsumerState { print('discover settings for $email'); } final provider = - await locator().discover(email); + await locator().discover(email); if (!mounted) { // ignore if user has cancelled operation return; @@ -223,7 +223,7 @@ class _AccountAddScreenState extends ConsumerState { }); } - Future _loginWithOAuth(mailProviders.Provider provider, String email) async { + Future _loginWithOAuth(mail_providers.Provider provider, String email) async { setState(() { _isAccountVerifying = true; _currentStep = _stepAccountSetup; @@ -248,13 +248,16 @@ class _AccountAddScreenState extends ConsumerState { auth: OauthAuthentication(email, token), config: provider.clientConfig, ); - final connectedAccount = - await locator().connectFirstTime(mailAccount); + final connectedAccount = await ref.read( + firstTimeMailClientSourceProvider( + account: RealAccount(mailAccount), + ).future, + ); _mailClient = connectedAccount?.mailClient; final isVerified = _mailClient?.isConnected ?? false; - if (isVerified) { + if (connectedAccount != null && isVerified) { final extensions = await AppExtension.loadFor(mailAccount); - _realAccount = RealAccount(mailAccount, appExtensions: extensions); + _realAccount = connectedAccount.copyWith(appExtensions: extensions); } else { FocusManager.instance.primaryFocus?.unfocus(); } @@ -279,14 +282,17 @@ class _AccountAddScreenState extends ConsumerState { password: _passwordController.text, config: _provider!.clientConfig, ); - final connectedAccount = - await locator().connectFirstTime(mailAccount); + final connectedAccount = await ref.read( + firstTimeMailClientSourceProvider( + account: RealAccount(mailAccount), + ).future, + ); _mailClient = connectedAccount?.mailClient; final isVerified = _mailClient?.isConnected ?? false; - if (isVerified) { + if (connectedAccount != null && isVerified) { final extensions = await AppExtension.loadFor(mailAccount); - _realAccount = RealAccount(mailAccount, appExtensions: extensions); + _realAccount = connectedAccount.copyWith(appExtensions: extensions); } else { FocusManager.instance.primaryFocus?.unfocus(); } @@ -378,9 +384,12 @@ class _AccountAddScreenState extends ConsumerState { ); Step _buildPasswordStep( - BuildContext context, AppLocalizations localizations) { + BuildContext context, + AppLocalizations localizations, + ) { final provider = _provider; final appSpecificPasswordSetupUrl = provider?.appSpecificPasswordSetupUrl; + return Step( title: Text(localizations.addAccountPasswordLabel), //state: StepState.complete, @@ -623,7 +632,7 @@ class _AccountAddScreenState extends ConsumerState { ), ); - void _onProviderChanged(mailProviders.Provider provider, String email) { + void _onProviderChanged(mail_providers.Provider provider, String email) { final mailAccount = MailAccount.fromDiscoveredSettings( name: _emailController.text, email: _emailController.text, diff --git a/lib/screens/account_edit_screen.dart b/lib/screens/account_edit_screen.dart index 08afadc..14a04cd 100644 --- a/lib/screens/account_edit_screen.dart +++ b/lib/screens/account_edit_screen.dart @@ -5,17 +5,18 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; -import '../account/providers.dart'; +import '../account/provider.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; +import '../logger.dart'; +import '../mail/provider.dart'; import '../routes.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; import '../services/providers.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/provider.dart'; @@ -39,12 +40,12 @@ class AccountEditScreen extends HookConsumerWidget { final account = ref.watch( findRealAccountByEmailProvider(email: accountEmail), ); + final unifiedAccount = ref.watch(unifiedAccountProvider); final localizations = context.text; final accountNameController = useTextEditingController(text: account.name); final userNameController = useTextEditingController(text: account.userName); final theme = Theme.of(context); final iconService = locator(); - final mailService = locator(); final enableDeveloperMode = ref.watch( settingsProvider.select((value) => value.enableDeveloperMode), @@ -52,6 +53,9 @@ class AccountEditScreen extends HookConsumerWidget { final isRetryingToConnectState = useState(false); + Future saveAccounts() => + ref.read(realAccountsProvider.notifier).save(); + Widget buildEditContent() => SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(8), @@ -61,7 +65,7 @@ class AccountEditScreen extends HookConsumerWidget { builder: (context, child) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (mailService.hasError(account)) ...[ + if (account.hasError) ...[ Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -82,6 +86,7 @@ class AccountEditScreen extends HookConsumerWidget { child: PlatformTextButtonIcon( onPressed: () => _reconnect( context, + ref, account, account.mailAccount, isRetryingToConnectState, @@ -97,6 +102,7 @@ class AccountEditScreen extends HookConsumerWidget { child: PlatformTextButton( onPressed: () => _updateAuthentication( context, + ref, account, isRetryingToConnectState, ), @@ -118,7 +124,7 @@ class AccountEditScreen extends HookConsumerWidget { ), onChanged: (value) async { account.name = value; - await locator().saveAccounts(); + await saveAccounts(); }, ), DecoratedPlatformTextField( @@ -129,23 +135,21 @@ class AccountEditScreen extends HookConsumerWidget { ), onChanged: (value) async { account.userName = value; - await locator().saveAccounts(); + await saveAccounts(); }, ), - if (locator().hasUnifiedAccount) + if (unifiedAccount != null) PlatformCheckboxListTile( value: !account.excludeFromUnified, onChanged: (value) async { final exclude = (value == false); account.excludeFromUnified = exclude; - await locator() - .excludeAccountFromUnified( - account, - exclude, - ); + ref.refresh(unifiedAccountProvider); + await saveAccounts(); }, title: Text( - localizations.editAccountIncludeInUnifiedLabel), + localizations.editAccountIncludeInUnifiedLabel, + ), ), const Divider(), Text( @@ -177,11 +181,14 @@ class AccountEditScreen extends HookConsumerWidget { color: Colors.red, child: Icon(iconService.messageActionDelete), ), - onDismissed: (direction) async { - await account.removeAlias(alias); + onDismissed: (direction) { + account.removeAlias(alias); locator().showTextSnackBar( - localizations - .editAccountAliasRemoved(alias.email)); + localizations.editAccountAliasRemoved( + alias.email, + ), + ); + ref.read(realAccountsProvider.notifier).save(); }, child: PlatformListTile( title: Text(alias.toString()), @@ -232,11 +239,9 @@ class AccountEditScreen extends HookConsumerWidget { ); if (result != null) { account.supportsPlusAliases = result; - locator() - .markAccountAsTestedForPlusAlias(account); - await locator().saveAccount( - account.mailAccount, - ); + account.setAttribute( + RealAccount.attributePlusAliasTested, true); + await saveAccounts(); } }, ), @@ -249,9 +254,7 @@ class AccountEditScreen extends HookConsumerWidget { onChanged: (value) async { final bccMyself = value ?? false; account.bccMyself = bccMyself; - await locator().saveAccount( - account.mailAccount, - ); + await saveAccounts(); }, title: Text(localizations.editAccountBccMyself), ), @@ -269,9 +272,12 @@ class AccountEditScreen extends HookConsumerWidget { const Divider(), PlatformTextButtonIcon( - onPressed: () => locator().push( - Routes.accountServerDetails, - arguments: account), + onPressed: () => context.pushNamed( + Routes.accountServerDetails, + pathParameters: { + Routes.pathParameterEmail: account.email + }, + ), icon: const Icon(Icons.edit), label: ButtonText( localizations.editAccountServerSettingsAction), @@ -306,18 +312,16 @@ class AccountEditScreen extends HookConsumerWidget { action: localizations.actionDelete, isDangerousAction: true); if (result ?? false) { - final mailService = locator(); if (!context.mounted) { return; } - await mailService.removeAccount(account); - if (mailService.accounts.isEmpty) { - await locator().push( - Routes.welcome, - clear: true, - ); + ref + .read(realAccountsProvider.notifier) + .removeAccount(account); + if (ref.read(realAccountsProvider).isEmpty) { + context.go(Routes.welcome); } else { - locator().pop(); + context.pop(); } } }, @@ -333,7 +337,7 @@ class AccountEditScreen extends HookConsumerWidget { onChanged: (value) { if (value != null) { account.enableLogging = value; - locator().saveAccounts(); + ref.read(realAccountsProvider.notifier).save(); final message = value ? localizations.editAccountLoggingEnabled : localizations.editAccountLoggingDisabled; @@ -360,11 +364,12 @@ class AccountEditScreen extends HookConsumerWidget { Future _updateAuthentication( BuildContext context, + WidgetRef ref, RealAccount account, ValueNotifier isRetryingToConnectState, ) async { - final mailService = locator(); - unawaited(mailService.disconnect(account)); + // TODO(RV): find solution to disconnect possibly connected account + // unawaited(mailService.disconnect(account)); final authentication = account.mailAccount.incoming.authentication; if (authentication is PlainAuthentication) { // simple case: password is directly given, @@ -399,14 +404,14 @@ class AccountEditScreen extends HookConsumerWidget { ), ); } - final result = await _reconnect( - context, - account, - updatedMailAccount, - isRetryingToConnectState, - ); - if (result) { - await mailService.saveAccounts(); + if (context.mounted) { + await _reconnect( + context, + ref, + account, + updatedMailAccount, + isRetryingToConnectState, + ); } } } else if (authentication is OauthAuthentication) { @@ -437,13 +442,15 @@ class AccountEditScreen extends HookConsumerWidget { outgoing: mailAccount.outgoing .copyWith(authentication: adaptedOutgoingAuth), ); - await _reconnect( - context, - account, - updatedMailAccount, - isRetryingToConnectState, - ); - await mailService.saveAccounts(); + if (context.mounted) { + await _reconnect( + context, + ref, + account, + updatedMailAccount, + isRetryingToConnectState, + ); + } } } } @@ -451,17 +458,31 @@ class AccountEditScreen extends HookConsumerWidget { Future _reconnect( BuildContext context, + WidgetRef ref, RealAccount account, MailAccount mailAccount, ValueNotifier isRetryingToConnectState, ) async { isRetryingToConnectState.value = true; - final accountCopy = account.copyWith(mailAccount: mailAccount); - final mailService = locator(); - final result = await mailService.reconnect(accountCopy); - isRetryingToConnectState.value = false; - if (context.mounted) { - if (result) { + + try { + final accountCopy = account.copyWith(mailAccount: mailAccount); + final connectedAccount = await ref.read( + firstTimeMailClientSourceProvider( + account: accountCopy, + ).future, + ); + if (connectedAccount == null || + !connectedAccount.mailClient.isConnected) { + throw Exception( + 'Unable to connect', + ); + } + ref + .read(realAccountsProvider.notifier) + .replaceAccount(oldAccount: account, newAccount: connectedAccount); + isRetryingToConnectState.value = false; + if (context.mounted) { final localizations = context.text; await LocalizedDialogHelper.showTextDialog( context, @@ -469,9 +490,13 @@ class AccountEditScreen extends HookConsumerWidget { localizations.editAccountFailureToConnectFixedInfo, ); } - } - return result; + return true; + } catch (e) { + logger.e('Unable to reconnect account: $e'); + + return false; + } } } @@ -511,25 +536,27 @@ class _PasswordUpdateDialogState extends State<_PasswordUpdateDialog> { ); } -class _PlusAliasTestingDialog extends StatefulWidget { +class _PlusAliasTestingDialog extends StatefulHookConsumerWidget { const _PlusAliasTestingDialog({required this.account}); final RealAccount account; @override - _PlusAliasTestingDialogState createState() => _PlusAliasTestingDialogState(); + ConsumerState<_PlusAliasTestingDialog> createState() => + _PlusAliasTestingDialogState(); } -class _PlusAliasTestingDialogState extends State<_PlusAliasTestingDialog> { +class _PlusAliasTestingDialogState + extends ConsumerState<_PlusAliasTestingDialog> { bool _isContinueAvailable = true; int _step = 0; static const int _maxStep = 1; late String _generatedAliasAddress; // MimeMessage? _testMessage; + MailClient? _mailClient; @override void initState() { - _generatedAliasAddress = - locator().generateRandomPlusAlias(widget.account); + _generatedAliasAddress = generateRandomPlusAlias(widget.account); super.initState(); } @@ -546,7 +573,8 @@ class _PlusAliasTestingDialogState extends State<_PlusAliasTestingDialog> { _isContinueAvailable = true; _step++; }); - _deleteMessage(msg); + _deleteMessage(event.mailClient, msg); + return true; } else if ((msg.getHeaderValue('auto-submitted') != null) && (msg.isTextPlainMessage()) && @@ -557,32 +585,30 @@ class _PlusAliasTestingDialogState extends State<_PlusAliasTestingDialog> { _isContinueAvailable = true; _step++; }); - _deleteMessage(msg); + _deleteMessage(event.mailClient, msg); + return true; } } + return false; } - Future _deleteMessage(MimeMessage msg) async { - final mailClient = await locator().getClientFor( - widget.account, - ); + Future _deleteMessage(MailClient mailClient, MimeMessage msg) async { await mailClient.flagMessage(msg, isDeleted: true); } @override - Future dispose() async { + void dispose() { super.dispose(); - final mailClient = await locator().getClientFor( - widget.account, - ); - mailClient.removeEventFilter(_filter); + _mailClient?.removeEventFilter(_filter); + _mailClient?.disconnect(); } @override Widget build(BuildContext context) { final localizations = context.text; + return PlatformAlertDialog( title: Text( localizations.editAccountTestPlusAliasTitle(widget.account.name), @@ -608,13 +634,17 @@ class _PlusAliasTestingDialogState extends State<_PlusAliasTestingDialog> { }); // send the email and wait for a response: final msg = MessageBuilder.buildSimpleTextMessage( - widget.account.fromAddress, - [MailAddress(null, _generatedAliasAddress)], - 'This is an automated message testing support for + aliases. Please ignore.', - subject: 'Testing + Alias'); + widget.account.fromAddress, + [MailAddress(null, _generatedAliasAddress)], + 'This is an automated message testing support ' + 'for + aliases. Please ignore.', + subject: 'Testing + Alias', + ); // _testMessage = msg; - final mailClient = await locator() - .getClientFor(widget.account); + final mailClient = ref.read( + mailClientSourceProvider(account: widget.account), + ); + _mailClient = mailClient; mailClient.addEventFilter(_filter); await mailClient.sendMessage(msg, appendToSent: false); break; @@ -623,10 +653,13 @@ class _PlusAliasTestingDialogState extends State<_PlusAliasTestingDialog> { steps: [ Step( title: Text( - localizations.editAccountTestPlusAliasStepIntroductionTitle), + localizations.editAccountTestPlusAliasStepIntroductionTitle, + ), content: Text( localizations.editAccountTestPlusAliasStepIntroductionText( - widget.account.name, _generatedAliasAddress), + widget.account.name, + _generatedAliasAddress, + ), style: const TextStyle(fontSize: 12), ), isActive: _step == 0, @@ -660,9 +693,24 @@ class _PlusAliasTestingDialogState extends State<_PlusAliasTestingDialog> { ), ); } + + /// Creates a new random plus alias based on the primary email address + /// of the [account]. + String generateRandomPlusAlias(RealAccount account) { + final mail = account.email; + final atIndex = mail.lastIndexOf('@'); + if (atIndex == -1) { + throw StateError( + 'unable to create alias based on invalid email <$mail>.', + ); + } + final random = MessageBuilder.createRandomId(length: 8); + + return '${mail.substring(0, atIndex)}+$random${mail.substring(atIndex)}'; + } } -class _AliasEditDialog extends StatefulWidget { +class _AliasEditDialog extends StatefulHookConsumerWidget { const _AliasEditDialog({ required this.isNewAlias, required this.alias, @@ -673,10 +721,10 @@ class _AliasEditDialog extends StatefulWidget { final bool isNewAlias; @override - _AliasEditDialogState createState() => _AliasEditDialogState(); + ConsumerState<_AliasEditDialog> createState() => _AliasEditDialogState(); } -class _AliasEditDialogState extends State<_AliasEditDialog> { +class _AliasEditDialogState extends ConsumerState<_AliasEditDialog> { late TextEditingController _nameController; late TextEditingController _emailController; late bool _isEmailValid; @@ -694,6 +742,7 @@ class _AliasEditDialogState extends State<_AliasEditDialog> { @override Widget build(BuildContext context) { final localizations = context.text; + return PlatformAlertDialog( title: Text(widget.isNewAlias ? localizations.editAccountAddAliasTitle @@ -708,7 +757,7 @@ class _AliasEditDialogState extends State<_AliasEditDialog> { ), PlatformTextButton( onPressed: _isEmailValid - ? () async { + ? () { setState(() { _isSaving = true; }); @@ -716,15 +765,16 @@ class _AliasEditDialogState extends State<_AliasEditDialog> { email: _emailController.text, personalName: _nameController.text, ); - await widget.account.addAlias(alias); - if (mounted) { - Navigator.of(context).pop(); - } + widget.account.addAlias(alias); + ref.read(realAccountsProvider.notifier).save(); + context.pop(); } : null, - child: ButtonText(widget.isNewAlias - ? localizations.editAccountAliasAddAction - : localizations.editAccountAliasUpdateAction), + child: ButtonText( + widget.isNewAlias + ? localizations.editAccountAliasAddAction + : localizations.editAccountAliasUpdateAction, + ), ), ], ); @@ -737,8 +787,9 @@ class _AliasEditDialogState extends State<_AliasEditDialog> { DecoratedPlatformTextField( controller: _nameController, decoration: InputDecoration( - labelText: localizations.editAccountEditAliasNameLabel, - hintText: localizations.addAccountNameOfUserHint), + labelText: localizations.editAccountEditAliasNameLabel, + hintText: localizations.addAccountNameOfUserHint, + ), ), DecoratedPlatformTextField( controller: _emailController, diff --git a/lib/screens/account_server_details_screen.dart b/lib/screens/account_server_details_screen.dart index 5e3cd85..0419550 100644 --- a/lib/screens/account_server_details_screen.dart +++ b/lib/screens/account_server_details_screen.dart @@ -3,43 +3,58 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; +import '../account/provider.dart'; import '../localization/extension.dart'; -import '../locator.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; +import '../mail/provider.dart'; import '../util/localized_dialog_helper.dart'; import '../widgets/password_field.dart'; import 'base.dart'; +import 'home_screen.dart'; -class AccountServerDetailsScreen extends StatelessWidget { +class AccountServerDetailsScreen extends ConsumerWidget { const AccountServerDetailsScreen({ super.key, - required this.account, + this.accountEmail, + this.account, this.title, this.includeDrawer = true, }); - final RealAccount account; + + final String? accountEmail; + final RealAccount? account; final String? title; final bool includeDrawer; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final accountEmail = this.accountEmail; + final account = this.account ?? + (accountEmail != null + ? ref.watch( + findRealAccountByEmailProvider(email: accountEmail), + ) + : null); + if (account == null) { + return const HomeScreen(); + } final editor = AccountServerDetailsEditor(account: account); - return Base.buildAppChrome( - context, + + return BasePage( title: title ?? account.name, content: editor, includeDrawer: includeDrawer, - appBarActions: [ - const _SaveButton(), + appBarActions: const [ + _SaveButton(), ], ); } } -class AccountServerDetailsEditor extends StatefulWidget { +class AccountServerDetailsEditor extends StatefulHookConsumerWidget { const AccountServerDetailsEditor({ super.key, required this.account, @@ -47,7 +62,7 @@ class AccountServerDetailsEditor extends StatefulWidget { final RealAccount account; @override - State createState() => + ConsumerState createState() => _AccountServerDetailsEditorState(); Future testConnection(BuildContext context) async { @@ -70,6 +85,7 @@ class _SaveButtonState extends State<_SaveButton> { if (_isSaving) { return const PlatformProgressIndicator(); } + return PlatformIconButton( icon: Icon(PlatformInfo.isCupertino ? CupertinoIcons.check_mark_circled @@ -91,7 +107,7 @@ class _SaveButtonState extends State<_SaveButton> { } class _AccountServerDetailsEditorState - extends State { + extends ConsumerState { static _AccountServerDetailsEditorState? _currentState; final TextEditingController _emailController = TextEditingController(); final TextEditingController _userNameController = TextEditingController(); @@ -243,6 +259,7 @@ class _AccountServerDetailsEditorState ), ); } + return; } else { final incoming = mailAccount.incoming; @@ -263,11 +280,17 @@ class _AccountServerDetailsEditorState } } // now try to sign in: - final connectedAccount = - await locator().connectFirstTime(mailAccount); + final connectedAccount = await ref.read( + firstTimeMailClientSourceProvider( + account: RealAccount(mailAccount), + ).future, + ); + final mailClient = connectedAccount?.mailClient; if (mailClient != null && mailClient.isConnected) { - locator().pop(connectedAccount); + if (context.mounted) { + context.pop(connectedAccount); + } } else if (mounted) { await LocalizedDialogHelper.showTextDialog( context, @@ -283,6 +306,7 @@ class _AccountServerDetailsEditorState @override Widget build(BuildContext context) { final localizations = context.text; + return SingleChildScrollView( child: Material( child: SafeArea( diff --git a/lib/screens/base.dart b/lib/screens/base.dart index 91ea32a..3ac3be3 100644 --- a/lib/screens/base.dart +++ b/lib/screens/base.dart @@ -2,13 +2,15 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../locator.dart'; -import '../services/mail_service.dart'; +import '../account/provider.dart'; import '../widgets/app_drawer.dart'; import '../widgets/menu_with_badge.dart'; -class BasePage extends StatelessWidget { +/// Provides a basic page layout with an app bar and a drawer. +class BasePage extends ConsumerWidget { + /// Creates a new [BasePage]. const BasePage({ super.key, this.title, @@ -20,174 +22,135 @@ class BasePage extends StatelessWidget { this.drawer, this.bottom, this.includeDrawer = true, - this.isRoot = false, }); + /// The title of the page. final String? title; + + /// The subtitle of the page. final String? subtitle; + + /// The content of the page. final Widget? content; + + /// The floating action button of the page. final FloatingActionButton? floatingActionButton; + + /// The actions of the app bar. final List? appBarActions; + + /// The app bar. final PlatformAppBar? appBar; - final Widget? drawer; - final Widget? bottom; - final bool includeDrawer; - final bool isRoot; - @override - Widget build(BuildContext context) => Base.buildAppChrome( - context, - title: title, - subtitle: subtitle, - content: content, - floatingActionButton: floatingActionButton, - appBarActions: appBarActions, - appBar: appBar, - drawer: drawer, - bottom: bottom, - includeDrawer: includeDrawer, - isRoot: isRoot, - ); -} + /// The drawer. + final Widget? drawer; -class BaseAppBar extends StatelessWidget { - const BaseAppBar({ - super.key, - this.title, - this.actions, - this.subtitle, - this.floatingActionButton, - this.includeDrawer = true, - }); + /// The bottom widget. + final Widget? bottom; - final String? title; - final List? actions; - final String? subtitle; - final FloatingActionButton? floatingActionButton; + /// Whether to include the drawer. final bool includeDrawer; @override - Widget build(BuildContext context) => Base.buildAppBar( - context, - title, - subtitle: subtitle, - floatingActionButton: floatingActionButton, - includeDrawer: includeDrawer, - ); -} + Widget build(BuildContext context, WidgetRef ref) { + PlatformAppBar? buildAppBar() { + final title = this.title; -class Base { - @Deprecated('Use BasePage instead') - static Widget buildAppChrome( - BuildContext context, { - required String? title, - required Widget? content, - FloatingActionButton? floatingActionButton, - List? appBarActions, - PlatformAppBar? appBar, - Widget? drawer, - String? subtitle, - Widget? bottom, - bool includeDrawer = true, - bool isRoot = false, - }) { - appBar ??= (title == null && subtitle == null && appBarActions == null) - ? null - : buildAppBar( - context, - title, - actions: appBarActions, - subtitle: subtitle, - floatingActionButton: floatingActionButton, - includeDrawer: includeDrawer, - isRoot: isRoot, - ); - if (includeDrawer) { - drawer ??= buildDrawer(context); + if (title == null && subtitle == null && appBarActions == null) { + return null; + } + final floatingActionButton = this.floatingActionButton; + + return PlatformAppBar( + material: (context, platform) => MaterialAppBarData( + elevation: 0, + ), + cupertino: (context, platform) => CupertinoNavigationBarData( + transitionBetweenRoutes: false, + trailing: floatingActionButton == null + ? null + : CupertinoButton( + onPressed: floatingActionButton.onPressed, + child: floatingActionButton.child ?? const SizedBox.shrink(), + ), + ), + leading: (includeDrawer && ref.watch(hasAccountWithErrorProvider)) + ? const MenuWithBadge() + : null, + title: (title == null && subtitle == null) + ? null + : BaseTitle( + title: title ?? '', + subtitle: subtitle, + ), + automaticallyImplyLeading: true, + trailingActions: appBarActions ?? [], + ); } return PlatformPageScaffold( - appBar: appBar, + appBar: buildAppBar(), body: content, bottomBar: bottom, material: (context, platform) => MaterialScaffoldData( - drawer: drawer, + drawer: drawer ?? (includeDrawer ? const AppDrawer() : null), floatingActionButton: floatingActionButton, // bottomNavBar: bottom, ), ); } +} - static PlatformAppBar buildAppBar( - BuildContext context, - String? title, { - List? actions, - String? subtitle, - FloatingActionButton? floatingActionButton, - bool includeDrawer = true, - bool isRoot = false, - }) => PlatformAppBar( - material: (context, platform) => MaterialAppBarData( - elevation: 0, - ), - cupertino: (context, platform) => CupertinoNavigationBarData( - transitionBetweenRoutes: false, - trailing: floatingActionButton == null - ? null - : CupertinoButton( - onPressed: floatingActionButton.onPressed, - child: floatingActionButton.child!, - ), - ), - leading: (includeDrawer && locator().hasAccountsWithErrors()) - ? const MenuWithBadge() - : null, - title: buildTitle(title, subtitle), - automaticallyImplyLeading: true, - trailingActions: actions ?? [], - ); +/// Renders a title consisting of a title and an optional subtitle. +class BaseTitle extends StatelessWidget { + /// Creates a new [BaseTitle]. + const BaseTitle({ + super.key, + required this.title, + this.subtitle, + }); - static Widget? buildTitle(String? title, String? subtitle) { - if (subtitle == null) { - if (title == null) { - return null; - } - return Text( - title, - overflow: TextOverflow.fade, - ); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title!, - overflow: TextOverflow.fade, - ), - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - subtitle, - overflow: TextOverflow.fade, - style: const TextStyle(fontSize: 10, fontStyle: FontStyle.italic), - ), - ), - ], - ); - } - } + /// The title of the app bar. + final String title; - static Widget buildDrawer(BuildContext context) => const AppDrawer(); + /// The subtitle of the app bar. + final String? subtitle; + + @override + Widget build(BuildContext context) { + final subtitle = this.subtitle; + + return subtitle == null + ? Text(title, overflow: TextOverflow.fade) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, overflow: TextOverflow.fade), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + subtitle, + overflow: TextOverflow.fade, + style: const TextStyle( + fontSize: 10, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + } } class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { + SliverSingleChildHeaderDelegate({ + required this.maxHeight, + required this.minHeight, + required this.child, + this.elevation, + this.background, + }); - SliverSingleChildHeaderDelegate( - {required this.maxHeight, - required this.minHeight, - required this.child, - this.elevation, - this.background}); final double maxHeight; final double minHeight; final double? elevation; @@ -196,24 +159,25 @@ class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { @override Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) => Material( - elevation: elevation ?? 0, - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: maxHeight), - child: Stack( - children: [ - Positioned( - bottom: 0, - left: 0, - right: 0, - top: 0, - child: background!, - ), - child - ], + BuildContext context, double shrinkOffset, bool overlapsContent) => + Material( + elevation: elevation ?? 0, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: maxHeight), + child: Stack( + children: [ + Positioned( + bottom: 0, + left: 0, + right: 0, + top: 0, + child: background ?? const SizedBox.shrink(), + ), + child, + ], + ), ), - ), - ); + ); @override double get maxExtent => kToolbarHeight + maxHeight; @@ -222,13 +186,13 @@ class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { double get minExtent => kToolbarHeight + minHeight; @override - bool shouldRebuild(SliverSingleChildHeaderDelegate oldDelegate) => maxHeight != oldDelegate.maxHeight || - minHeight != oldDelegate.minHeight || - child != oldDelegate.child; + bool shouldRebuild(SliverSingleChildHeaderDelegate oldDelegate) => + maxHeight != oldDelegate.maxHeight || + minHeight != oldDelegate.minHeight || + child != oldDelegate.child; } class CustomApBarSliverDelegate extends SliverPersistentHeaderDelegate { - CustomApBarSliverDelegate({ this.title, this.child, @@ -244,10 +208,14 @@ class CustomApBarSliverDelegate extends SliverPersistentHeaderDelegate { @override Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { final appBarSize = maxExtent - shrinkOffset; final proportion = 2 - (maxExtent / appBarSize); final percent = proportion < 0 || proportion > 1 ? 0.0 : proportion; + return ConstrainedBox( constraints: BoxConstraints(minHeight: maxHeight), child: Stack( @@ -257,7 +225,7 @@ class CustomApBarSliverDelegate extends SliverPersistentHeaderDelegate { left: 0, right: 0, top: 0, - child: background!, + child: background ?? const SizedBox.shrink(), ), Positioned( bottom: 0, diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index b01a4d0..40d63ea 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_html_editor/enough_html_editor.dart'; import 'package:enough_mail/enough_mail.dart'; @@ -6,21 +8,22 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:enough_text_editor/enough_text_editor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; +import '../account/provider.dart'; +import '../contact/provider.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; +import '../mail/provider.dart'; import '../models/compose_data.dart'; import '../models/sender.dart'; import '../models/shared_data.dart'; import '../routes.dart'; import '../services/app_service.dart'; -import '../services/contact_service.dart'; import '../services/i18n_service.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/provider.dart'; import '../util/localized_dialog_helper.dart'; @@ -65,6 +68,7 @@ class _ComposeScreenState extends ConsumerState { ComposeData? _resumeComposeData; bool _isReadReceiptRequested = false; late ComposeMode _composeMode; + late RealAccount _realAccount; TextEditorApi? _plainTextEditorApi; @@ -83,12 +87,16 @@ class _ComposeScreenState extends ConsumerState { : (_subjectController.text.isEmpty) ? _Autofocus.subject : _Autofocus.text; - _senders = locator().getSenders(); - final currentAccount = (locator().currentAccount is RealAccount - ? locator().currentAccount - : locator() - .accounts - .firstWhere((account) => account is RealAccount))! as RealAccount; + _senders = ref.watch(sendersProvider); + final realAccounts = ref.watch(realAccountsProvider); + final providedCurrentAccount = ref.watch(currentAccountProvider); + + final currentAccount = providedCurrentAccount is RealAccount + ? providedCurrentAccount + : (providedCurrentAccount is UnifiedAccount + ? providedCurrentAccount.accounts.first + : realAccounts.first); + _realAccount = currentAccount; final defaultSender = ref.read(settingsProvider).defaultSender; mb.from ??= [defaultSender ?? currentAccount.fromAddress]; Sender? from; @@ -98,7 +106,8 @@ class _ComposeScreenState extends ConsumerState { } else { final senderEmail = mb.from?.first.email.toLowerCase(); from = _senders.firstWhereOrNull( - (s) => s.address.email.toLowerCase() == senderEmail); + (s) => s.address.email.toLowerCase() == senderEmail, + ); } if (from == null) { from = Sender(mb.from!.first, currentAccount); @@ -106,11 +115,9 @@ class _ComposeScreenState extends ConsumerState { } _from = from; _checkAccountContactManager(_from.account); - if (widget.data.resumeText != null) { - _loadMailTextFuture = _loadMailTextFromComposeData(); - } else { - _loadMailTextFuture = _loadMailTextFromMessage(); - } + _loadMailTextFuture = widget.data.resumeText != null + ? _loadMailTextFromComposeData() + : _loadMailTextFromMessage(); final future = widget.data.future; if (future != null) { _downloadAttachmentsFuture = future; @@ -309,8 +316,8 @@ class _ComposeScreenState extends ConsumerState { return mimeMessage; } - Future _getMailClient() => - locator().getClientFor(_from.account); + MailClient _getMailClient() => + ref.read(mailClientSourceProvider(account: _realAccount)); Future _send(AppLocalizations localizations) async { final subject = _subjectController.text.trim(); @@ -325,8 +332,10 @@ class _ComposeScreenState extends ConsumerState { return; } } - locator().pop(); - final mailClient = await _getMailClient(); + if (context.mounted) { + context.pop(); + } + final mailClient = _getMailClient(); final mimeMessage = await _buildMimeMessage(mailClient); try { final append = !_from.account.addsSentMailAutomatically; @@ -342,26 +351,30 @@ class _ComposeScreenState extends ConsumerState { print('Unable to send or append mail: $e $s'); } // this state's context is now invalid because this widget is not mounted anymore - final currentContext = locator().currentContext!; - final message = (e is MailException) ? e.message! : e.toString(); - await LocalizedDialogHelper.showTextDialog( - currentContext, - localizations.errorTitle, - localizations.composeSendErrorInfo(message), - actions: [ - PlatformTextButton( - child: ButtonText(localizations.actionCancel), - onPressed: () => Navigator.of(currentContext).pop(), - ), - PlatformTextButton( - child: ButtonText(localizations.composeContinueEditingAction), - onPressed: () { - Navigator.of(currentContext).pop(); - _returnToCompose(); - }, - ), - ], - ); + final currentContext = Routes.navigatorKey.currentContext; + if (currentContext != null && currentContext.mounted) { + final message = + (e is MailException) ? e.message ?? e.toString() : e.toString(); + await LocalizedDialogHelper.showTextDialog( + currentContext, + localizations.errorTitle, + localizations.composeSendErrorInfo(message), + actions: [ + PlatformTextButton( + child: ButtonText(localizations.actionCancel), + onPressed: () => Navigator.of(currentContext).pop(), + ), + PlatformTextButton( + child: ButtonText(localizations.composeContinueEditingAction), + onPressed: () { + Navigator.of(currentContext).pop(); + _returnToCompose(); + }, + ), + ], + ); + } + return; } final action = widget.data.action; @@ -515,22 +528,29 @@ class _ComposeScreenState extends ConsumerState { ) .toList(), onChanged: (s) async { - final builder = widget.data.messageBuilder; - - builder.from = [s!.address]; - final lastSignature = _signature; - _from = s; - final newSignature = _signature; - if (newSignature != lastSignature) { - await _htmlEditorApi! - .replaceAll(lastSignature, newSignature); - } - if (_isReadReceiptRequested) { - builder.requestReadReceipt(recipient: _from.address); + if (s != null) { + final builder = widget.data.messageBuilder + ..from = [s.address]; + final lastSignature = _signature; + _from = s; + final newSignature = _signature; + if (newSignature != lastSignature) { + await _htmlEditorApi?.replaceAll( + lastSignature, + newSignature, + ); + } + if (_isReadReceiptRequested) { + builder.requestReadReceipt( + recipient: _from.address, + ); + } + setState(() { + _realAccount = s.account; + }); + + await _checkAccountContactManager(_from.account); } - setState(() {}); - - _checkAccountContactManager(_from.account); }, value: _from, hint: Text(localizations.composeSenderHint), @@ -620,6 +640,7 @@ class _ComposeScreenState extends ConsumerState { case ConnectionState.done: if (_composeMode == ComposeMode.html) { final text = snapshot.data ?? '

'; + return HtmlEditor( onCreated: (api) { setState(() { @@ -634,6 +655,7 @@ class _ComposeScreenState extends ConsumerState { } else { // compose mode is plainText _plainTextController.text = snapshot.data ?? ''; + return Padding( padding: const EdgeInsets.all(8), child: TextEditor( @@ -659,15 +681,17 @@ class _ComposeScreenState extends ConsumerState { } Future _showSourceCode() async { - final mailClient = await locator().getClientFor(_from.account); + final mailClient = _getMailClient(); final mime = await _buildMimeMessage(mailClient); - await locator().push(Routes.sourceCode, arguments: mime); + if (context.mounted) { + unawaited(context.pushNamed(Routes.sourceCode, extra: mime)); + } } Future _saveAsDraft() async { - locator().pop(); + context.pop(); final localizations = locator().localizations; - final mailClient = await locator().getClientFor(_from.account); + final mailClient = _getMailClient(); final mime = await _buildMimeMessage(mailClient); try { await mailClient.saveDraftMessage(mime); @@ -693,25 +717,27 @@ class _ComposeScreenState extends ConsumerState { if (kDebugMode) { print('unable to save draft message $e $s'); } - final currentContext = locator().currentContext!; - await LocalizedDialogHelper.showTextDialog( - currentContext, - localizations.errorTitle, - localizations.composeMessageSavedAsDraftErrorInfo(e.toString()), - actions: [ - PlatformTextButton( - child: ButtonText(localizations.actionCancel), - onPressed: () => Navigator.of(currentContext).pop(), - ), - PlatformTextButton( - child: ButtonText(localizations.composeContinueEditingAction), - onPressed: () { - Navigator.of(currentContext).pop(); - _returnToCompose(); - }, - ), - ], - ); + final currentContext = Routes.navigatorKey.currentContext; + if (currentContext != null && currentContext.mounted) { + await LocalizedDialogHelper.showTextDialog( + currentContext, + localizations.errorTitle, + localizations.composeMessageSavedAsDraftErrorInfo(e.toString()), + actions: [ + PlatformTextButton( + onPressed: currentContext.pop, + child: ButtonText(localizations.actionCancel), + ), + PlatformTextButton( + child: ButtonText(localizations.composeContinueEditingAction), + onPressed: () { + currentContext.pop(); + _returnToCompose(); + }, + ), + ], + ); + } } } @@ -746,16 +772,18 @@ class _ComposeScreenState extends ConsumerState { } void _returnToCompose() { - locator() - .push(Routes.mailCompose, arguments: _resumeComposeData); + final currentContext = Routes.navigatorKey.currentContext; + if (currentContext != null && currentContext.mounted) { + context.pushNamed( + Routes.mailCompose, + extra: _resumeComposeData, + ); + } } - void _checkAccountContactManager(RealAccount account) { - if (account.contactManager == null) { - locator().getForAccount(account).then((value) { - setState(() {}); - }); - } + Future _checkAccountContactManager(RealAccount account) async { + account.contactManager ??= + await ref.read(contactsLoaderProvider(account: account).future); } Future _onSharedData(List sharedData) { @@ -766,11 +794,13 @@ class _ComposeScreenState extends ConsumerState { final api = _htmlEditorApi; if (api != null) { for (final data in sharedData) { - data.addToMessageBuilder(widget.data.messageBuilder); - data.addToEditor(api); + data + ..addToMessageBuilder(widget.data.messageBuilder) + ..addToEditor(api); } } } + return Future.value(); } @@ -779,8 +809,13 @@ class _ComposeScreenState extends ConsumerState { } class _HtmlGenerationArguments { - _HtmlGenerationArguments(this.quoteTemplate, this.mimeMessage, - this.blockExternalImages, this.emptyMessageText, this.maxImageWidth); + _HtmlGenerationArguments( + this.quoteTemplate, + this.mimeMessage, + this.blockExternalImages, + this.emptyMessageText, + this.maxImageWidth, + ); final String? quoteTemplate; final MimeMessage? mimeMessage; final bool blockExternalImages; diff --git a/lib/screens/email_screen.dart b/lib/screens/email_screen.dart index 0580649..ca57b68 100644 --- a/lib/screens/email_screen.dart +++ b/lib/screens/email_screen.dart @@ -2,7 +2,7 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../account/providers.dart'; +import '../account/provider.dart'; import '../mail/provider.dart'; import 'mail_screen.dart'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6c01264..ce6bc22 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../account/providers.dart'; +import '../account/provider.dart'; import '../settings/provider.dart'; import 'screens.dart'; diff --git a/lib/screens/location_screen.dart b/lib/screens/location_screen.dart index d91bce4..260481f 100644 --- a/lib/screens/location_screen.dart +++ b/lib/screens/location_screen.dart @@ -41,8 +41,7 @@ class _LocationScreenState extends State { Widget build(BuildContext context) { final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.attachTypeLocation, appBarActions: [ PlatformIconButton( @@ -59,11 +58,13 @@ class _LocationScreenState extends State { case ConnectionState.active: return const Center(child: PlatformProgressIndicator()); case ConnectionState.done: - final data = snapshot.data; - if (data != null) { - return _buildMap(context, data.latitude!, data.longitude!); + final latitude = snapshot.data?.latitude; + final longitude = snapshot.data?.longitude; + if (latitude != null && longitude != null) { + return _buildMap(context, latitude, longitude); } } + return const Center(child: PlatformProgressIndicator()); }, ), diff --git a/lib/screens/lock_screen.dart b/lib/screens/lock_screen.dart index 5bded46..fd528e3 100644 --- a/lib/screens/lock_screen.dart +++ b/lib/screens/lock_screen.dart @@ -16,8 +16,7 @@ class LockScreen extends StatelessWidget { Widget build(BuildContext context) { final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( includeDrawer: false, title: localizations.lockScreenTitle, content: _buildContent(context, localizations), @@ -39,7 +38,7 @@ class LockScreen extends StatelessWidget { PlatformTextButton( child: PlatformText(localizations.lockScreenUnlockAction), onPressed: () => _authenticate(context), - ) + ), ], ), ), diff --git a/lib/screens/mail_screen.dart b/lib/screens/mail_screen.dart index 8719af2..112c75a 100644 --- a/lib/screens/mail_screen.dart +++ b/lib/screens/mail_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; +import '../account/provider.dart'; import '../localization/extension.dart'; import '../mail/provider.dart'; import 'base.dart'; @@ -33,20 +34,26 @@ class MailScreen extends ConsumerWidget { account is UnifiedAccount ? text.unifiedAccountName : account.name; final subtitle = account.fromAddress.email; - return sourceFuture.when( - loading: () => BasePage( - title: title, - subtitle: subtitle, - content: const Center( - child: PlatformProgressIndicator(), + return ProviderScope( + overrides: [ + currentAccountProvider.overrideWithValue(account), + currentMailboxProvider.overrideWithValue(mailbox), + ], + child: sourceFuture.when( + loading: () => BasePage( + title: title, + subtitle: subtitle, + content: const Center( + child: PlatformProgressIndicator(), + ), ), + error: (error, stack) => BasePage( + title: title, + subtitle: subtitle, + content: Center(child: Text('$error')), + ), + data: (source) => MessageSourceScreen(messageSource: source), ), - error: (error, stack) => BasePage( - title: title, - subtitle: subtitle, - content: Center(child: Text('$error')), - ), - data: (source) => MessageSourceScreen(messageSource: source), ); } } diff --git a/lib/screens/mail_search_screen.dart b/lib/screens/mail_search_screen.dart new file mode 100644 index 0000000..a4921de --- /dev/null +++ b/lib/screens/mail_search_screen.dart @@ -0,0 +1,45 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_platform_widgets/platform.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../localization/extension.dart'; +import '../mail/provider.dart'; +import 'base.dart'; +import 'message_source_screen.dart'; + +/// Displays the search result for +class MailSearchScreen extends ConsumerWidget { + /// Creates a [MailScreen] + const MailSearchScreen({ + super.key, + required this.search, + }); + + /// The account to display + final MailSearch search; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final text = context.text; + final searchSource = ref.watch( + mailSearchProvider( + search: search, + ), + ); + + return searchSource.when( + loading: () => BasePage( + title: text.searchQueryTitle(search.query), + content: const Center( + child: PlatformProgressIndicator(), + ), + ), + error: (error, stack) => BasePage( + title: text.searchQueryTitle(search.query), + content: Center(child: Text('$error')), + ), + data: (source) => MessageSourceScreen(messageSource: source), + ); + } +} diff --git a/lib/screens/media_screen.dart b/lib/screens/media_screen.dart index 2f42c44..87b5ca0 100644 --- a/lib/screens/media_screen.dart +++ b/lib/screens/media_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -7,11 +8,13 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path_provider/path_provider.dart' as pathprovider; import 'package:share_plus/share_plus.dart'; import '../account/model.dart'; +import '../account/provider.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; @@ -19,8 +22,6 @@ import '../models/message.dart'; import '../models/message_source.dart'; import '../routes.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; import '../settings/provider.dart'; import '../util/localized_dialog_helper.dart'; import 'base.dart'; @@ -38,14 +39,13 @@ class InteractiveMediaScreen extends ConsumerWidget { final localizations = context.text; final iconService = locator(); - return Base.buildAppChrome( - context, + return BasePage( title: mediaWidget.mediaProvider.name, content: mediaWidget, appBarActions: [ DensePlatformIconButton( icon: Icon(iconService.messageActionForward), - onPressed: _forward, + onPressed: () => _forward(context), ), DensePlatformIconButton( icon: Icon(iconService.share), @@ -67,19 +67,19 @@ class InteractiveMediaScreen extends ConsumerWidget { mime = MimeMessage.parseFromData(provider.data); } if (mime != null) { - final mailService = locator(); - final account = mailService.currentAccount; + final account = ref.read(currentAccountProvider); if (account is RealAccount) { final source = SingleMessageSource( - mailService.messageSource, + null, account: account, ); - final message = Message(mime, source, 0); - message.isEmbedded = true; + final message = Message(mime, source, 0) + ..isEmbedded = true; source.singleMessage = message; showErrorMessage = false; - await locator() - .push(Routes.mailDetails, arguments: message); + unawaited( + context.pushNamed(Routes.mailDetails, extra: message), + ); } } } catch (e, s) { @@ -88,11 +88,15 @@ class InteractiveMediaScreen extends ConsumerWidget { } } if (showErrorMessage) { - await LocalizedDialogHelper.showTextDialog( + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( context, localizations.errorTitle, - localizations.developerShowAsEmailFailed); + localizations.developerShowAsEmailFailed, + ); + } } + break; } }, @@ -107,23 +111,26 @@ class InteractiveMediaScreen extends ConsumerWidget { ); } - void _forward() { + void _forward(BuildContext context) { final provider = mediaWidget.mediaProvider; final messageBuilder = MessageBuilder()..subject = provider.name; if (provider is TextMediaProvider) { - messageBuilder.addBinary(utf8.encode(provider.text) as Uint8List, - MediaType.fromText(provider.mediaType), - filename: provider.name); + messageBuilder.addBinary( + utf8.encode(provider.text) as Uint8List, + MediaType.fromText(provider.mediaType), + filename: provider.name, + ); } else if (provider is MemoryMediaProvider) { messageBuilder.addBinary( - provider.data, MediaType.fromText(provider.mediaType), - filename: provider.name); + provider.data, + MediaType.fromText(provider.mediaType), + filename: provider.name, + ); } final composeData = ComposeData(null, messageBuilder, ComposeAction.newMessage); - locator() - .push(Routes.mailCompose, arguments: composeData); + context.pushNamed(Routes.mailCompose, extra: composeData); } void _share() { diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index f364922..2121e00 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -6,20 +6,20 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; +import '../mail/provider.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; import '../models/message_source.dart'; import '../routes.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; import '../services/notification_service.dart'; import '../settings/model.dart'; import '../settings/provider.dart'; @@ -38,10 +38,10 @@ class MessageDetailsScreen extends ConsumerStatefulWidget { const MessageDetailsScreen({ super.key, required this.message, - this.blockExternalContents = false, + this.blockExternalContent = false, }); final Message message; - final bool blockExternalContents; + final bool blockExternalContent; @override ConsumerState createState() => _DetailsScreenState(); @@ -78,20 +78,17 @@ class _DetailsScreenState extends ConsumerState { if (_current.sourceIndex == index) { return Future.value(_current); } + return _source.getMessageAt(index); } - bool _blockExternalContents(int index) { - if (_current.sourceIndex == index) { - return widget.blockExternalContents; - } else { - return false; - } - } + bool _blockExternalContents(int index) => + _current.sourceIndex == index && widget.blockExternalContent; @override Widget build(BuildContext context) { final localizations = context.text; + return BasePage( title: _current.mimeMessage.decodeSubject() ?? localizations.subjectUndefined, @@ -101,8 +98,7 @@ class _DetailsScreenState extends ConsumerState { onSelected: (_OverflowMenuChoice result) { switch (result) { case _OverflowMenuChoice.showContents: - locator() - .push(Routes.mailContents, arguments: _current); + context.pushNamed(Routes.mailContents, extra: _current); break; case _OverflowMenuChoice.showSourceCode: _showSourceCode(); @@ -133,6 +129,7 @@ class _DetailsScreenState extends ConsumerState { if (data == null) { return const EmptyMessage(); } + return _MessageContent( data, blockExternalContents: _blockExternalContents(index), @@ -151,10 +148,8 @@ class _DetailsScreenState extends ConsumerState { ); } - void _showSourceCode() { - locator() - .push(Routes.sourceCode, arguments: _current.mimeMessage); - } + void _showSourceCode() => + context.pushNamed(Routes.sourceCode, extra: _current.mimeMessage); } class _MessageContent extends ConsumerStatefulWidget { @@ -222,6 +217,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { final attachments = widget.message.attachments; final date = locator().formatDateTime(mime.decodeDate()); final subject = mime.decodeSubject(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -292,10 +288,10 @@ class _MessageContentState extends ConsumerState<_MessageContent> { if (_isWebViewZoomedOut) PlatformIconButton( icon: const Icon(Icons.zoom_in), - onPressed: () { - locator() - .push(Routes.mailContents, arguments: widget.message); - }, + onPressed: () => context.pushNamed( + Routes.mailContents, + extra: widget.message, + ), ) else Container(), @@ -496,22 +492,20 @@ class _MessageContentState extends ConsumerState<_MessageContent> { } Future _handleMailto(Uri mailto, MimeMessage mimeMessage) { - final settings = ref.read(settingsProvider); - final messageBuilder = locator().mailto( - mailto, - mimeMessage, - settings, + final messageBuilder = ref.read( + mailtoProvider( + mailtoUri: mailto, + originatingMessage: mimeMessage, + ), ); final composeData = ComposeData([widget.message], messageBuilder, ComposeAction.newMessage); - return locator() - .push(Routes.mailCompose, arguments: composeData); + return context.pushNamed(Routes.mailCompose, extra: composeData); } - Future _navigateToMedia(InteractiveMediaWidget mediaWidget) async => - locator() - .push(Routes.interactiveMedia, arguments: mediaWidget); + Future _navigateToMedia(InteractiveMediaWidget mediaWidget) => + context.pushNamed(Routes.interactiveMedia, extra: mediaWidget); // void _next() { // _navigateToMessage(widget.message.next); @@ -534,8 +528,7 @@ class MessageContentsScreen extends ConsumerWidget { final Message message; @override - Widget build(BuildContext context, WidgetRef ref) => Base.buildAppChrome( - context, + Widget build(BuildContext context, WidgetRef ref) => BasePage( title: message.mimeMessage.decodeSubject() ?? context.text.subjectUndefined, content: SafeArea( @@ -543,33 +536,37 @@ class MessageContentsScreen extends ConsumerWidget { mimeMessage: message.mimeMessage, adjustHeight: false, mailtoDelegate: (uri, mime) => - _handleMailto(uri, mime, ref.read(settingsProvider)), - showMediaDelegate: _navigateToMedia, + _handleMailto(context, ref, uri, mime), + showMediaDelegate: (mediaViewer) => + _navigateToMedia(context, mediaViewer), enableDarkMode: Theme.of(context).brightness == Brightness.dark, ), ), ); Future _handleMailto( + BuildContext context, + WidgetRef ref, Uri mailto, MimeMessage mimeMessage, - Settings settings, ) { - final messageBuilder = locator().mailto( - mailto, - mimeMessage, - settings, + final messageBuilder = ref.read( + mailtoProvider( + mailtoUri: mailto, + originatingMessage: mimeMessage, + ), ); final composeData = ComposeData([message], messageBuilder, ComposeAction.newMessage); - return locator() - .push(Routes.mailCompose, arguments: composeData); + return context.pushNamed(Routes.mailCompose, extra: composeData); } - Future _navigateToMedia(InteractiveMediaWidget mediaWidget) => - locator() - .push(Routes.interactiveMedia, arguments: mediaWidget); + Future _navigateToMedia( + BuildContext context, + InteractiveMediaWidget mediaWidget, + ) => + context.pushNamed(Routes.interactiveMedia, extra: mediaWidget); } class ThreadSequenceButton extends StatefulWidget { @@ -661,7 +658,7 @@ class _ThreadSequenceButtonState extends State { void _select(Message message) { _removeOverlay(); - locator().push(Routes.mailDetails, arguments: message); + context.pushNamed(Routes.mailDetails, extra: message); } OverlayEntry _buildThreadsOverlay() { diff --git a/lib/screens/message_details_screen_for_notification.dart b/lib/screens/message_details_screen_for_notification.dart new file mode 100644 index 0000000..fe6646c --- /dev/null +++ b/lib/screens/message_details_screen_for_notification.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../mail/provider.dart'; +import '../services/notification_service.dart'; +import 'message_details_screen.dart'; + +/// Displays the message details for a notification +class MessageDetailsForNotificationScreen extends ConsumerWidget { + /// Creates a [MessageDetailsForNotificationScreen] + const MessageDetailsForNotificationScreen({ + super.key, + required this.payload, + this.blockExternalContent = false, + }); + + /// The payload of the notification + final MailNotificationPayload payload; + + /// Whether to block external content + final bool blockExternalContent; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messageValue = ref.watch( + singleMessageLoaderProvider(payload: payload), + ); + + return messageValue.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text('$error')), + data: (data) => MessageDetailsScreen( + message: data, + blockExternalContent: blockExternalContent, + ), + ); + } +} diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index 3eed841..9c79d24 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -6,7 +6,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../account/providers.dart'; +import '../account/provider.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; @@ -111,8 +111,9 @@ class _MessageSourceScreenState extends ConsumerState @override void dispose() { _searchEditingController.dispose(); - _sectionedMessageSource.removeListener(_update); - _sectionedMessageSource.dispose(); + _sectionedMessageSource + ..removeListener(_update) + ..dispose(); super.dispose(); } @@ -173,7 +174,7 @@ class _MessageSourceScreenState extends ConsumerState ) : (PlatformInfo.isCupertino) ? Text(source.name ?? '') - : Base.buildTitle(source.name ?? '', source.description ?? ''); + : BaseTitle(title: source.name ?? '', subtitle: source.description); final appBarActions = [ if (_isInSearchMode && _hasSearchInput) @@ -305,9 +306,7 @@ class _MessageSourceScreenState extends ConsumerState ) : null, material: (context, platform) => MaterialScaffoldData( - drawer: AppDrawer( - currentAccount: widget.messageSource.account, - ), + drawer: const AppDrawer(), floatingActionButton: _visualization == _Visualization.stack ? null : const NewMailMessageButton(), @@ -1017,19 +1016,13 @@ class _MessageSourceScreenState extends ConsumerState ); } } - final mailbox = account.isVirtual - ? null // //TODO set current mailbox, e.g. current: widget.messageSource.currentMailbox, - : _selectedMessages.first.source - .getMimeSource(_selectedMessages.first) - ?.mailClient - .selectedMailbox; + LocalizedDialogHelper.showWidgetDialog( context, SingleChildScrollView( child: MailboxTree( account: account, onSelected: moveTo, - current: mailbox, ), ), title: localizations.multipleMoveTitle(_selectedMessages.length), diff --git a/lib/screens/screens.dart b/lib/screens/screens.dart index 1a0e01b..e110b3e 100644 --- a/lib/screens/screens.dart +++ b/lib/screens/screens.dart @@ -7,8 +7,10 @@ export 'home_screen.dart'; export 'location_screen.dart'; export 'lock_screen.dart'; export 'mail_screen.dart'; +export 'mail_search_screen.dart'; export 'media_screen.dart'; export 'message_details_screen.dart'; +export 'message_details_screen_for_notification.dart'; export 'message_source_screen.dart'; export 'sourcecode_screen.dart'; export 'splash_screen.dart'; diff --git a/lib/screens/sourcecode_screen.dart b/lib/screens/sourcecode_screen.dart index 212a370..4296d48 100644 --- a/lib/screens/sourcecode_screen.dart +++ b/lib/screens/sourcecode_screen.dart @@ -6,41 +6,38 @@ import 'base.dart'; class SourceCodeScreen extends StatelessWidget { const SourceCodeScreen({super.key, required this.mimeMessage}); - final MimeMessage? mimeMessage; + final MimeMessage mimeMessage; @override Widget build(BuildContext context) { String? sizeText; - if (mimeMessage!.size != null) { + if (mimeMessage.size != null) { final sizeFormat = NumberFormat('###.0#'); - final sizeKb = mimeMessage!.size! / 1024; + final sizeKb = (mimeMessage.size ?? 0) / 1024; final sizeMb = sizeKb / 1024; sizeText = sizeMb > 1 ? 'Size: ${sizeFormat.format(sizeKb)} kb / ${sizeFormat.format(sizeMb)} mb' - : 'Size: ${sizeFormat.format(sizeKb)} kb / ${mimeMessage!.size} bytes'; + : 'Size: ${sizeFormat.format(sizeKb)} kb / ${mimeMessage.size} bytes'; } - return Base.buildAppChrome( - context, - title: mimeMessage!.decodeSubject() ?? '', + + return BasePage( + title: mimeMessage.decodeSubject() ?? '', content: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SelectableText('ID: ${mimeMessage!.sequenceId}'), - SelectableText('UID: ${mimeMessage!.uid}'), - if (sizeText != null) - SelectableText(sizeText), - - if (mimeMessage!.body != null) - SelectableText('BODY: ${mimeMessage!.body}'), - + SelectableText('ID: ${mimeMessage.sequenceId}'), + SelectableText('UID: ${mimeMessage.uid}'), + if (sizeText != null) SelectableText(sizeText), + if (mimeMessage.body != null) + SelectableText('BODY: ${mimeMessage.body}'), Divider( color: Theme.of(context).colorScheme.secondary, thickness: 1, height: 16, ), - SelectableText(mimeMessage!.renderMessage()), + SelectableText(mimeMessage.renderMessage()), ], ), ), diff --git a/lib/screens/webview_screen.dart b/lib/screens/webview_screen.dart index 873507d..fc3497f 100644 --- a/lib/screens/webview_screen.dart +++ b/lib/screens/webview_screen.dart @@ -13,19 +13,14 @@ class WebViewScreen extends StatelessWidget { final WebViewConfiguration configuration; @override - Widget build(BuildContext context) { - // final localizations = context.text; - - return Base.buildAppChrome( - context, - title: configuration.title ?? configuration.uri.host, - content: SafeArea( - child: webview.InAppWebView( - initialUrlRequest: webview.URLRequest( - url: webview.WebUri.uri(configuration.uri), + Widget build(BuildContext context) => BasePage( + title: configuration.title ?? configuration.uri.host, + content: SafeArea( + child: webview.InAppWebView( + initialUrlRequest: webview.URLRequest( + url: webview.WebUri.uri(configuration.uri), + ), ), ), - ), - ); - } + ); } diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 07783eb..734362b 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,5 +1,6 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:shimmer_animation/shimmer_animation.dart'; @@ -8,7 +9,6 @@ import '../localization/extension.dart'; import '../locator.dart'; import '../routes.dart'; import '../services/icon_service.dart'; -import '../services/navigation_service.dart'; import '../widgets/button_text.dart'; import '../widgets/legalese.dart'; @@ -18,29 +18,38 @@ class WelcomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { final localizations = context.text; - final pages = _buildPages(localizations); + final pages = _buildPages(context, localizations); + return Theme( data: ThemeData( brightness: Brightness.dark, primarySwatch: Colors.green, ), - child: PlatformScaffold( - body: IntroductionScreen( - pages: pages, - done: ButtonText(localizations.actionDone), - onDone: () { - locator() - .push(Routes.accountAdd, arguments: true); - }, - next: ButtonText(localizations.actionNext), - skip: ButtonText(localizations.actionSkip), - showSkipButton: true, + child: SafeArea( + child: PlatformScaffold( + body: IntroductionScreen( + pages: pages, + done: ButtonText(localizations.actionDone), + onDone: () { + context.goNamed( + Routes.accountAdd, + queryParameters: {'welcome': 'true'}, + ); + }, + next: ButtonText(localizations.actionNext), + skip: ButtonText(localizations.actionSkip), + showSkipButton: true, + ), ), ), ); //Material App } - List _buildPages(AppLocalizations localizations) => [ + List _buildPages( + BuildContext context, + AppLocalizations localizations, + ) => + [ PageViewModel( title: localizations.welcomePanel1Title, body: localizations.welcomePanel1Text, @@ -50,7 +59,7 @@ class WelcomeScreen extends StatelessWidget { fit: BoxFit.cover, ), decoration: PageDecoration(pageColor: Colors.green[700]), - footer: _buildFooter(localizations), + footer: _buildFooter(context, localizations), ), PageViewModel( title: localizations.welcomePanel2Title, @@ -61,7 +70,7 @@ class WelcomeScreen extends StatelessWidget { fit: BoxFit.cover, ), decoration: const PageDecoration(pageColor: Color(0xff543226)), - footer: _buildFooter(localizations), + footer: _buildFooter(context, localizations), ), PageViewModel( title: localizations.welcomePanel3Title, @@ -72,7 +81,7 @@ class WelcomeScreen extends StatelessWidget { fit: BoxFit.cover, ), decoration: const PageDecoration(pageColor: Color(0xff761711)), - footer: _buildFooter(localizations), + footer: _buildFooter(context, localizations), ), PageViewModel( title: localizations.welcomePanel4Title, @@ -82,11 +91,12 @@ class WelcomeScreen extends StatelessWidget { height: 200, fit: BoxFit.cover, ), - footer: _buildFooter(localizations), + footer: _buildFooter(context, localizations), ), ]; - Widget _buildFooter(AppLocalizations localizations) => Column( + Widget _buildFooter(BuildContext context, AppLocalizations localizations) => + Column( children: [ Padding( padding: const EdgeInsets.all(16), @@ -99,8 +109,10 @@ class WelcomeScreen extends StatelessWidget { child: PlatformText(localizations.welcomeActionSignIn), ), onPressed: () { - locator() - .push(Routes.accountAdd, arguments: true); + context.goNamed( + Routes.accountAdd, + queryParameters: {'welcome': 'true'}, + ); }, ), ), diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart index 0e3ef09..045e00b 100644 --- a/lib/services/app_service.dart +++ b/lib/services/app_service.dart @@ -112,7 +112,8 @@ class AppService { } Future> _collectSharedData( - Map shared) async { + Map shared, + ) async { final sharedData = []; final String? mimeTypeText = shared['mimeType']; final mediaType = (mimeTypeText == null || mimeTypeText.contains('*')) @@ -145,6 +146,7 @@ class AppService { sharedData.add(SharedText(text, mediaType, subject: shared['subject'])); } } + return sharedData; } @@ -161,8 +163,10 @@ class AppService { MessageBuilder builder; final firstData = sharedData.first; if (firstData is SharedMailto) { - builder = MessageBuilder.prepareMailtoBasedMessage(firstData.mailto, - locator().currentAccount!.fromAddress); + builder = MessageBuilder.prepareMailtoBasedMessage( + firstData.mailto, + locator().currentAccount!.fromAddress, + ); } else { builder = MessageBuilder(); for (final data in sharedData) { @@ -170,6 +174,7 @@ class AppService { } } final composeData = ComposeData(null, builder, ComposeAction.newMessage); + return locator() .push(Routes.mailCompose, arguments: composeData, fade: true); } diff --git a/lib/services/contact_service.dart b/lib/services/contact_service.dart deleted file mode 100644 index 7d8a9b8..0000000 --- a/lib/services/contact_service.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:enough_mail/enough_mail.dart'; -import 'package:flutter/foundation.dart'; - -import '../account/model.dart'; -import '../locator.dart'; -import '../models/contact.dart'; -import 'mail_service.dart'; - -class ContactService { - Future getForAccount(RealAccount account) async { - var contactManager = account.contactManager; - if (contactManager == null) { - contactManager = await init(account); - account.contactManager = contactManager; - } - return contactManager; - } - - Future init(RealAccount account) async { - final mailClient = await locator().createClientFor( - account, - store: false, - ); - try { - final mailbox = await mailClient.selectMailboxByFlag(MailboxFlag.sent); - if (mailbox.messagesExists > 0) { - var startId = mailbox.messagesExists - 100; - if (startId < 1) { - startId = 1; - } - final sentMessages = await mailClient.fetchMessageSequence( - MessageSequence.fromRangeToLast(startId), - fetchPreference: FetchPreference.envelope); - final addressesByEmail = {}; - for (final message in sentMessages) { - _addAddresses(message.to, addressesByEmail); - _addAddresses(message.cc, addressesByEmail); - _addAddresses(message.bcc, addressesByEmail); - } - return ContactManager(addressesByEmail.values.toList()); - } - } catch (e, s) { - if (kDebugMode) { - print('unable to load sent messages: $e $s'); - } - } finally { - await mailClient.disconnect(); - } - return ContactManager([]); - } - - void _addAddresses( - List? addresses, Map addressesByEmail) { - if (addresses == null) { - return; - } - for (final address in addresses) { - final email = address.email.toLowerCase(); - final existing = addressesByEmail[email]; - if (existing == null || !existing.hasPersonalName) { - addressesByEmail[email] = address; - } - } - } -} diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index 663c99a..4363d67 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -596,6 +596,7 @@ class MailService implements MimeSourceSubscriber { Future connectAccount(MailAccount mailAccount) async { final mailClient = createMailClient(mailAccount); await _connect(mailClient); + return mailClient; } @@ -619,10 +620,12 @@ class MailService implements MimeSourceSubscriber { await _connect(mailClient); } on MailException { await mailClient.disconnect(); + return null; } } } + return ConnectedAccount(usedMailAccount, mailClient); } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 4b82011..860ea59 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -4,16 +4,12 @@ import 'dart:io'; import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:go_router/go_router.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../account/model.dart'; -import '../locator.dart'; import '../logger.dart'; import '../models/message.dart' as maily; -import '../models/message_source.dart'; import '../routes.dart'; -import 'mail_service.dart'; -import 'navigation_service.dart'; part 'notification_service.g.dart'; @@ -23,11 +19,13 @@ class NotificationService { static const String _messagePayloadStart = 'msg:'; final _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - Future init( - {bool checkForLaunchDetails = true}) async { + Future init({ + bool checkForLaunchDetails = true, + }) async { // print('init notification service...'); // set up local notifications: - // initialize the plugin. app_icon needs to be a added as a drawable resource to the Android head project + // initialize the plugin. app_icon needs to be a added as a drawable + // resource to the Android head project if (defaultTargetPlatform == TargetPlatform.windows) { // Windows is not yet supported: return NotificationServiceInitResult.normal; @@ -50,8 +48,10 @@ class NotificationService { final response = launchDetails.notificationResponse; if (response != null) { // print( - // 'got notification launched details: $launchDetails with payload ${response.payload}'); - await _selectNotification(response); + // 'got notification launched details: $launchDetails + // with payload ${response.payload}'); + _selectNotification(response); + return NotificationServiceInitResult.appLaunchedByNotification; } } @@ -102,40 +102,22 @@ class NotificationService { return MailNotificationPayload.fromJson(json); } - Future _selectNotification(NotificationResponse response) async { + void _selectNotification(NotificationResponse response) { final payloadText = response.payload; if (kDebugMode) { print('select notification: $payloadText'); } - if (payloadText != null && payloadText.startsWith(_messagePayloadStart)) { - try { - final payload = _deserialize(payloadText); + final context = Routes.navigatorKey.currentContext; + if (context == null) { + return; + } - final mailClient = await locator() - .getClientForAccountWithEmail(payload.accountEmail); - if (mailClient.selectedMailbox == null) { - await mailClient.selectInbox(); - } - final mimeMessage = MimeMessage() - ..sequenceId = payload.sequenceId - ..guid = payload.guid - ..uid = payload.uid - ..size = payload.size; - final currentMessageSource = locator().messageSource; - final messageSource = SingleMessageSource( - currentMessageSource, - account: - currentMessageSource?.account ?? RealAccount(mailClient.account), - ); - final message = maily.Message(mimeMessage, messageSource, 0); - messageSource.singleMessage = message; - await locator() - .push(Routes.mailDetails, arguments: message); - } on MailException catch (e, s) { - if (kDebugMode) { - print('Unable to fetch notification message $payloadText: $e $s '); - } - } + if (payloadText != null && payloadText.startsWith(_messagePayloadStart)) { + final payload = _deserialize(payloadText); + context.pushNamed( + Routes.mailDetailsForNotification, + extra: payload, + ); } } diff --git a/lib/settings/view/settings_accounts_screen.dart b/lib/settings/view/settings_accounts_screen.dart index 36597d1..f95b238 100644 --- a/lib/settings/view/settings_accounts_screen.dart +++ b/lib/settings/view/settings_accounts_screen.dart @@ -6,7 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/model.dart'; -import '../../account/providers.dart'; +import '../../account/provider.dart'; import '../../localization/app_localizations.g.dart'; import '../../localization/extension.dart'; import '../../locator.dart'; diff --git a/lib/settings/view/settings_default_sender_screen.dart b/lib/settings/view/settings_default_sender_screen.dart index fde5ef7..e6697f3 100644 --- a/lib/settings/view/settings_default_sender_screen.dart +++ b/lib/settings/view/settings_default_sender_screen.dart @@ -1,14 +1,13 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../account/provider.dart'; import '../../localization/extension.dart'; -import '../../locator.dart'; import '../../routes.dart'; import '../../screens/base.dart'; -import '../../services/mail_service.dart'; -import '../../services/navigation_service.dart'; import '../../widgets/text_with_links.dart'; import '../provider.dart'; @@ -40,10 +39,8 @@ class SettingsDefaultSenderScreen extends ConsumerWidget { ), ); - final availableSenders = locator() - .getSenders() - .map((sender) => sender.address) - .toList(); + final availableSenders = + ref.watch(sendersProvider).map((sender) => sender.address).toList(); final firstAccount = localizations .defaultSenderSettingsFirstAccount(availableSenders.first.email); final senders = [null, ...availableSenders]; @@ -56,13 +53,12 @@ class SettingsDefaultSenderScreen extends ConsumerWidget { TextLink(aliasInfo.substring(0, asIndex)), TextLink.callback( accountSettings, - () => locator().push(Routes.settingsAccounts), + () => context.pushNamed(Routes.settingsAccounts), ), TextLink(aliasInfo.substring(asIndex + '[AS]'.length)), ]; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.defaultSenderSettingsTitle, content: SingleChildScrollView( child: SafeArea( @@ -71,8 +67,10 @@ class SettingsDefaultSenderScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(localizations.defaultSenderSettingsLabel, - style: theme.textTheme.bodySmall), + Text( + localizations.defaultSenderSettingsLabel, + style: theme.textTheme.bodySmall, + ), FittedBox( child: PlatformDropdownButton( value: defaultSender, diff --git a/lib/settings/view/settings_developer_mode_screen.dart b/lib/settings/view/settings_developer_mode_screen.dart index 8881c07..bb9bb98 100644 --- a/lib/settings/view/settings_developer_mode_screen.dart +++ b/lib/settings/view/settings_developer_mode_screen.dart @@ -1,16 +1,15 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../account/model.dart'; +import '../../account/provider.dart'; import '../../extensions/extensions.dart'; import '../../localization/extension.dart'; -import '../../locator.dart'; import '../../screens/base.dart'; -import '../../services/mail_service.dart'; -import '../../services/navigation_service.dart'; import '../../util/localized_dialog_helper.dart'; import '../../widgets/button_text.dart'; import '../provider.dart'; @@ -30,8 +29,7 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { final developerModeState = useState(isDeveloperModeEnabled); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.settingsDevelopment, content: SingleChildScrollView( child: SafeArea( @@ -80,15 +78,15 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { ), PlatformListTile( title: Text(localizations.extensionsReloadAction), - onTap: () => _reloadExtensions(context), + onTap: () => _reloadExtensions(context, ref), ), PlatformListTile( title: Text(localizations.extensionDeactivateAllAction), - onTap: _deactivateAllExtensions, + onTap: () => _deactivateAllExtensions(ref), ), PlatformListTile( title: Text(localizations.extensionsManualAction), - onTap: () => _loadExtensionManually(context), + onTap: () => _loadExtensionManually(context, ref), ), ], ), @@ -98,12 +96,13 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { ); } - Future _loadExtensionManually(BuildContext context) async { + Future _loadExtensionManually( + BuildContext context, + WidgetRef ref, + ) async { final localizations = context.text; final controller = TextEditingController(); - String? url; - final NavigationService navService = locator(); - final result = await LocalizedDialogHelper.showWidgetDialog( + final url = await LocalizedDialogHelper.showWidgetDialog( context, DecoratedPlatformTextField( controller: controller, @@ -116,79 +115,84 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { actions: [ PlatformTextButton( child: ButtonText(localizations.actionCancel), - onPressed: () => navService.pop(false), + onPressed: () => context.pop(), ), PlatformTextButton( child: ButtonText(localizations.actionOk), onPressed: () { - url = controller.text.trim(); - navService.pop(true); + final urlText = controller.text.trim(); + context.pop(urlText); }, ), ], ); // controller.dispose(); - if (result == true && url != null) { - if (url!.length > 4) { - if (!url!.contains(':')) { - url = 'https://$url'; + if (url != null) { + var usedUrl = url; + if (url.length > 4) { + if (!url.contains(':')) { + usedUrl = 'https://$url'; } - if (!url!.endsWith('json')) { - if (url!.endsWith('/')) { - url = '$url.maily.json'; + if (!url.endsWith('json')) { + if (url.endsWith('/')) { + usedUrl = '$url.maily.json'; } else { - url = '$url/.maily.json'; + usedUrl = '$url/.maily.json'; } } - final appExtension = await AppExtension.loadFromUrl(url!); + final appExtension = await AppExtension.loadFromUrl(usedUrl); if (appExtension != null) { - final currentAccount = locator().currentAccount; - final account = (currentAccount is RealAccount + final currentAccount = ref.read(currentAccountProvider); + ((currentAccount is RealAccount) ? currentAccount - : locator() - .accounts - .firstWhere((account) => account is RealAccount)) - as RealAccount; - account.appExtensions = [appExtension]; + : ref.read(realAccountsProvider).first) + .appExtensions = [appExtension]; if (context.mounted) { _showExtensionDetails(context, url, appExtension); } + await ref.read(realAccountsProvider.notifier).save(); } else if (context.mounted) { await LocalizedDialogHelper.showTextDialog( context, localizations.errorTitle, - localizations.extensionsManualLoadingError(url!), + localizations.extensionsManualLoadingError(url), ); } } else if (context.mounted) { await LocalizedDialogHelper.showTextDialog( - context, localizations.errorTitle, 'Invalid URL "$url"'); + context, + localizations.errorTitle, + 'Invalid URL "$url"', + ); } } } - void _deactivateAllExtensions() { - final accounts = locator().accounts; + void _deactivateAllExtensions(WidgetRef ref) { + final accounts = ref.read(realAccountsProvider); for (final account in accounts) { - if (account is RealAccount) { - account.appExtensions = []; - } + account.appExtensions = []; } + ref.read(realAccountsProvider.notifier).save(); } - Future _reloadExtensions(BuildContext context) async { + Future _reloadExtensions(BuildContext context, WidgetRef ref) async { final localizations = context.text; - final accounts = locator().accounts; + final accounts = ref.read(realAccountsProvider); final domains = <_AccountDomain>[]; for (final account in accounts) { - if (account is RealAccount) { - account.appExtensions = []; - _addEmail(account, account.email, domains); - _addHostname(account, - account.mailAccount.incoming.serverConfig.hostname!, domains); - _addHostname(account, - account.mailAccount.outgoing.serverConfig.hostname!, domains); - } + account.appExtensions = []; + _addEmail(account, account.email, domains); + _addHostname( + account, + account.mailAccount.incoming.serverConfig.hostname ?? '', + domains, + ); + _addHostname( + account, + account.mailAccount.outgoing.serverConfig.hostname ?? '', + domains, + ); } await LocalizedDialogHelper.showWidgetDialog( context, @@ -202,20 +206,23 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { trailing: FutureBuilder( future: domain.future, builder: (context, snapshot) { - if (snapshot.hasData) { - domain.account!.appExtensions!.add(snapshot.data!); + final data = snapshot.data; + if (data != null) { + domain.account?.appExtensions?.add(data); + return PlatformIconButton( icon: const Icon(Icons.check), onPressed: () => _showExtensionDetails( context, domain.domain, - snapshot.data!, + data, ), ); } else if (snapshot.connectionState == ConnectionState.done) { return const Icon(Icons.cancel_outlined); } + return const PlatformProgressIndicator(); }, ), @@ -228,12 +235,18 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { } void _addEmail( - RealAccount? account, String email, List<_AccountDomain> domains) { + RealAccount? account, + String email, + List<_AccountDomain> domains, + ) { _addDomain(account, email.substring(email.indexOf('@') + 1), domains); } void _addHostname( - RealAccount? account, String hostname, List<_AccountDomain> domains) { + RealAccount? account, + String hostname, + List<_AccountDomain> domains, + ) { final domainIndex = hostname.indexOf('.'); if (domainIndex != -1) { _addDomain(account, hostname.substring(domainIndex + 1), domains); @@ -241,7 +254,10 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { } void _addDomain( - RealAccount? account, String domain, List<_AccountDomain> domains) { + RealAccount? account, + String domain, + List<_AccountDomain> domains, + ) { if (!domains.any((k) => k.domain == domain)) { domains .add(_AccountDomain(account, domain, AppExtension.loadFrom(domain))); @@ -253,23 +269,28 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { String? domainOrUrl, AppExtension data, ) { + final accountSideMenu = data.accountSideMenu; + final forgotPasswordAction = data.forgotPasswordAction; + LocalizedDialogHelper.showWidgetDialog( context, Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Version: ${data.version}'), - if (data.accountSideMenu != null) ...[ + if (accountSideMenu != null) ...[ const Divider(), const Text('Account side menus:'), - for (final entry in data.accountSideMenu!) - Text('"${entry.getLabel('en')}": ${entry.action!.url}'), + for (final entry in accountSideMenu) + Text('"${entry.getLabel('en')}": ${entry.action?.url}'), ], - if (data.forgotPasswordAction != null) ...[ + if (forgotPasswordAction != null) ...[ const Divider(), const Text('Forgot password:'), Text( - '"${data.forgotPasswordAction!.getLabel('en')}": ${data.forgotPasswordAction!.action!.url}'), + '"${forgotPasswordAction.getLabel('en')}": ' + '${forgotPasswordAction.action?.url}', + ), ], if (data.signatureHtml != null) ...[ const Divider(), diff --git a/lib/settings/view/settings_feedback_screen.dart b/lib/settings/view/settings_feedback_screen.dart index f189701..92601c8 100644 --- a/lib/settings/view/settings_feedback_screen.dart +++ b/lib/settings/view/settings_feedback_screen.dart @@ -57,8 +57,7 @@ class _SettingsFeedbackScreenState extends State { final theme = Theme.of(context); final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.feedbackTitle, content: SingleChildScrollView( child: SafeArea( @@ -69,8 +68,10 @@ class _SettingsFeedbackScreenState extends State { children: [ Padding( padding: const EdgeInsets.all(8), - child: Text(localizations.feedbackIntro, - style: theme.textTheme.titleMedium), + child: Text( + localizations.feedbackIntro, + style: theme.textTheme.titleMedium, + ), ), if (info == null) const Padding( @@ -87,7 +88,7 @@ class _SettingsFeedbackScreenState extends State { ), Padding( padding: const EdgeInsets.all(8), - child: Text(info!), + child: Text(info ?? ''), ), Padding( padding: const EdgeInsets.all(8), @@ -96,7 +97,8 @@ class _SettingsFeedbackScreenState extends State { onPressed: () { Clipboard.setData(ClipboardData(text: info ?? '')); locator().showTextSnackBar( - localizations.feedbackResultInfoCopied); + localizations.feedbackResultInfoCopied, + ); }, ), ), @@ -129,8 +131,11 @@ class _SettingsFeedbackScreenState extends State { child: ButtonText(localizations.feedbackActionHelpDeveloping), onPressed: () async { - await launcher.launchUrl(Uri.parse( - 'https://github.com/Enough-Software/enough_mail_app')); + await launcher.launchUrl( + Uri.parse( + 'https://github.com/Enough-Software/enough_mail_app', + ), + ); }, ), ), diff --git a/lib/settings/view/settings_folders_screen.dart b/lib/settings/view/settings_folders_screen.dart index 815d4fe..1fa0a4c 100644 --- a/lib/settings/view/settings_folders_screen.dart +++ b/lib/settings/view/settings_folders_screen.dart @@ -5,13 +5,13 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/model.dart'; +import '../../account/provider.dart'; import '../../localization/extension.dart'; import '../../locator.dart'; +import '../../mail/provider.dart'; import '../../models/models.dart'; import '../../screens/base.dart'; -import '../../services/i18n_service.dart'; import '../../services/icon_service.dart'; -import '../../services/mail_service.dart'; import '../../services/scaffold_messenger_service.dart'; import '../../util/localized_dialog_helper.dart'; import '../../widgets/account_selector.dart'; @@ -33,8 +33,7 @@ class SettingsFoldersScreen extends ConsumerWidget { void onFolderNameSettingChanged(FolderNameSetting? value) => _onFolderNameSettingChanged(context, value, ref); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.settingsFolders, content: SingleChildScrollView( child: SafeArea( @@ -43,8 +42,10 @@ class SettingsFoldersScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(localizations.folderNamesIntroduction, - style: theme.textTheme.bodySmall), + Text( + localizations.folderNamesIntroduction, + style: theme.textTheme.bodySmall, + ), PlatformRadioListTile( value: FolderNameSetting.localized, groupValue: folderNameSetting, @@ -91,24 +92,26 @@ class SettingsFoldersScreen extends ConsumerWidget { final localizations = context.text; var customNames = settings.customFolderNames; if (customNames == null) { - final l = locator().localizations; + final l = context.text; customNames = [ l.folderInbox, l.folderDrafts, l.folderSent, l.folderTrash, l.folderArchive, - l.folderJunk + l.folderJunk, ]; } final result = await LocalizedDialogHelper.showWidgetDialog( - context, CustomFolderNamesEditor(customNames: customNames), - title: localizations.folderNamesCustomTitle, - defaultActions: DialogActions.okAndCancel); + context, + CustomFolderNamesEditor(customNames: customNames), + title: localizations.folderNamesCustomTitle, + defaultActions: DialogActions.okAndCancel, + ); if (result == true) { - settings = settings.copyWith(customFolderNames: customNames); - locator().applyFolderNameSettings(settings); - await ref.read(settingsProvider.notifier).update(settings); + await ref.read(settingsProvider.notifier).update( + settings.copyWith(customFolderNames: customNames), + ); } } @@ -122,7 +125,6 @@ class SettingsFoldersScreen extends ConsumerWidget { await ref.read(settingsProvider.notifier).update( settings.copyWith(folderNameSetting: value), ); - locator().applyFolderNameSettings(settings); } } @@ -210,23 +212,23 @@ class CustomFolderNamesEditor extends HookConsumerWidget { } } -class FolderManagement extends StatefulWidget { +class FolderManagement extends StatefulHookConsumerWidget { const FolderManagement({super.key}); @override - State createState() => _FolderManagementState(); + ConsumerState createState() => _FolderManagementState(); } -class _FolderManagementState extends State { +class _FolderManagementState extends ConsumerState { late RealAccount _account; Mailbox? _mailbox; late TextEditingController _folderNameController; @override void initState() { - _account = locator() - .accounts - .firstWhere((account) => account is RealAccount) as RealAccount; + final account = ref.read(currentAccountProvider); + _account = + account is RealAccount ? account : ref.read(realAccountsProvider).first; _folderNameController = TextEditingController(); super.initState(); } @@ -240,6 +242,7 @@ class _FolderManagementState extends State { @override Widget build(BuildContext context) { final localizations = context.text; + return SingleChildScrollView( child: SafeArea( child: Column( @@ -249,12 +252,10 @@ class _FolderManagementState extends State { AccountSelector( account: _account, onChanged: (account) { - if (account != null) { - setState(() { - _mailbox = null; - _account = account; - }); - } + setState(() { + _mailbox = null; + _account = account; + }); }, ), const Divider(), @@ -288,32 +289,35 @@ class _FolderManagementState extends State { } } -class MailboxWidget extends StatelessWidget { - const MailboxWidget( - {super.key, - required this.mailbox, - required this.account, - required this.onMailboxAdded, - required this.onMailboxDeleted}); +class MailboxWidget extends ConsumerWidget { + const MailboxWidget({ + super.key, + required this.mailbox, + required this.account, + required this.onMailboxAdded, + required this.onMailboxDeleted, + }); + final RealAccount account; final Mailbox? mailbox; final void Function() onMailboxAdded; final void Function() onMailboxDeleted; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final localizations = context.text; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ PlatformTextButtonIcon( - onPressed: () => _createFolder(context), + onPressed: () => _createFolder(context, ref), icon: Icon(CommonPlatformIcons.add), label: ButtonText(localizations.folderAddAction), ), if (mailbox != null) PlatformTextButtonIcon( - onPressed: () => _deleteFolder(context), + onPressed: () => _deleteFolder(context, ref), backgroundColor: Colors.red, style: TextButton.styleFrom(backgroundColor: Colors.red), icon: Icon( @@ -322,17 +326,16 @@ class MailboxWidget extends StatelessWidget { ), label: ButtonText( localizations.folderDeleteAction, - style: Theme.of(context) - .textTheme - .labelLarge! - .copyWith(color: Colors.white), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + ), ), ), ], ); } - Future _createFolder(context) async { + Future _createFolder(BuildContext context, WidgetRef ref) async { final localizations = context.text; final folderNameController = TextEditingController(); final result = await LocalizedDialogHelper.showWidgetDialog( @@ -350,34 +353,44 @@ class MailboxWidget extends StatelessWidget { ); if (result == true) { try { - await locator().createMailbox( - account, - folderNameController.text, - mailbox, - ); + await ref + .read(mailClientSourceProvider(account: account).notifier) + .createMailbox( + folderNameController.text, + mailbox, + ); locator() .showTextSnackBar(localizations.folderAddResultSuccess); onMailboxAdded(); } on MailException catch (e) { - await LocalizedDialogHelper.showTextDialog( - context, - localizations.errorTitle, - localizations.folderAddResultFailure(e.message!), - ); + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( + context, + localizations.errorTitle, + localizations.folderAddResultFailure(e.message ?? e.toString()), + ); + } } } } - Future _deleteFolder(BuildContext context) async { + Future _deleteFolder(BuildContext context, WidgetRef ref) async { final localizations = context.text; + final mailbox = this.mailbox; + if (mailbox == null) { + return; + } + final confirmed = await LocalizedDialogHelper.askForConfirmation( context, title: localizations.folderDeleteConfirmTitle, - query: localizations.folderDeleteConfirmText(mailbox!.path), + query: localizations.folderDeleteConfirmText(mailbox.path), ); - if (confirmed == true) { + if (confirmed ?? false) { try { - await locator().deleteMailbox(account, mailbox!); + await ref + .read(mailClientSourceProvider(account: account).notifier) + .deleteMailbox(mailbox); locator() .showTextSnackBar(localizations.folderDeleteResultSuccess); onMailboxDeleted(); @@ -386,7 +399,7 @@ class MailboxWidget extends StatelessWidget { await LocalizedDialogHelper.showTextDialog( context, localizations.errorTitle, - localizations.folderDeleteResultFailure(e.message!), + localizations.folderDeleteResultFailure(e.message ?? e.toString()), ); } } diff --git a/lib/settings/view/settings_language_screen.dart b/lib/settings/view/settings_language_screen.dart index 80a01fa..b22d70d 100644 --- a/lib/settings/view/settings_language_screen.dart +++ b/lib/settings/view/settings_language_screen.dart @@ -47,8 +47,7 @@ class SettingsLanguageScreen extends HookConsumerWidget { final selectedLanguageState = useState(selectedLanguage); final selectedLocalizationsState = useState(null); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.languageSettingTitle, content: SingleChildScrollView( child: SafeArea( @@ -57,8 +56,10 @@ class SettingsLanguageScreen extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(localizations.languageSettingLabel, - style: theme.textTheme.bodySmall), + Text( + localizations.languageSettingLabel, + style: theme.textTheme.bodySmall, + ), PlatformDropdownButton<_Language>( value: selectedLanguage, onChanged: (value) async { @@ -70,6 +71,7 @@ class SettingsLanguageScreen extends HookConsumerWidget { await ref .read(settingsProvider.notifier) .update(settings.removeLanguageTag()); + return; } @@ -79,23 +81,22 @@ class SettingsLanguageScreen extends HookConsumerWidget { if (context.mounted) { final confirmed = await LocalizedDialogHelper.showTextDialog( - context, - selectedLocalizations - .languageSettingConfirmationTitle, - selectedLocalizations - .languageSettingConfirmationQuery, - actions: [ - PlatformTextButton( - child: ButtonText( - selectedLocalizations.actionCancel, - ), - onPressed: () => Navigator.of(context).pop(false), - ), - PlatformTextButton( - child: ButtonText(selectedLocalizations.actionOk), - onPressed: () => Navigator.of(context).pop(true), + context, + selectedLocalizations.languageSettingConfirmationTitle, + selectedLocalizations.languageSettingConfirmationQuery, + actions: [ + PlatformTextButton( + child: ButtonText( + selectedLocalizations.actionCancel, ), - ]); + onPressed: () => Navigator.of(context).pop(false), + ), + PlatformTextButton( + child: ButtonText(selectedLocalizations.actionOk), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ); if (confirmed) { selectedLanguageState.value = value; @@ -108,11 +109,13 @@ class SettingsLanguageScreen extends HookConsumerWidget { } }, selectedItemBuilder: (context) => languages - .map((language) => Text(language.displayName!)) + .map((language) => Text(language.displayName ?? '')) .toList(), items: languages .map((language) => DropdownMenuItem( - value: language, child: Text(language.displayName!))) + value: language, + child: Text(language.displayName ?? ''), + )) .toList(), ), if (selectedLocalizationsState.value != null) diff --git a/lib/settings/view/settings_readreceipts_screen.dart b/lib/settings/view/settings_readreceipts_screen.dart index c55b927..8cc6060 100644 --- a/lib/settings/view/settings_readreceipts_screen.dart +++ b/lib/settings/view/settings_readreceipts_screen.dart @@ -23,8 +23,7 @@ class SettingsReadReceiptsScreen extends HookConsumerWidget { void onReadReceiptDisplaySettingChanged(ReadReceiptDisplaySetting? value) => _onReadReceiptDisplaySettingChanged(value, ref); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.settingsReadReceipts, content: SingleChildScrollView( child: Padding( @@ -33,8 +32,10 @@ class SettingsReadReceiptsScreen extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(localizations.readReceiptsSettingsIntroduction, - style: theme.textTheme.bodySmall), + Text( + localizations.readReceiptsSettingsIntroduction, + style: theme.textTheme.bodySmall, + ), PlatformRadioListTile( value: ReadReceiptDisplaySetting.always, groupValue: readReceiptDisplaySetting, diff --git a/lib/settings/view/settings_reply_screen.dart b/lib/settings/view/settings_reply_screen.dart index 109c37a..2042584 100644 --- a/lib/settings/view/settings_reply_screen.dart +++ b/lib/settings/view/settings_reply_screen.dart @@ -28,8 +28,7 @@ class SettingsReplyScreen extends ConsumerWidget { settingsProvider.select((value) => value.replyFormatPreference), ); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.replySettingsTitle, content: SingleChildScrollView( child: SafeArea( diff --git a/lib/settings/view/settings_screen.dart b/lib/settings/view/settings_screen.dart index 6fe280c..0ebcdf6 100644 --- a/lib/settings/view/settings_screen.dart +++ b/lib/settings/view/settings_screen.dart @@ -15,8 +15,7 @@ class SettingsScreen extends StatelessWidget { Widget build(BuildContext context) { final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.settingsTitle, content: SingleChildScrollView( child: SafeArea( diff --git a/lib/settings/view/settings_security_screen.dart b/lib/settings/view/settings_security_screen.dart index 3b9a6d5..0743586 100644 --- a/lib/settings/view/settings_security_screen.dart +++ b/lib/settings/view/settings_security_screen.dart @@ -37,8 +37,7 @@ class SettingsSecurityScreen extends HookConsumerWidget { } } - return Base.buildAppChrome( - context, + return BasePage( title: localizations.securitySettingsTitle, content: SingleChildScrollView( child: SafeArea( diff --git a/lib/settings/view/settings_signature_screen.dart b/lib/settings/view/settings_signature_screen.dart index 44b2b6a..71aff1d 100644 --- a/lib/settings/view/settings_signature_screen.dart +++ b/lib/settings/view/settings_signature_screen.dart @@ -1,16 +1,15 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/model.dart'; +import '../../account/provider.dart'; import '../../localization/extension.dart'; -import '../../locator.dart'; import '../../models/compose_data.dart'; import '../../routes.dart'; import '../../screens/base.dart'; -import '../../services/mail_service.dart'; -import '../../services/navigation_service.dart'; import '../../widgets/button_text.dart'; import '../../widgets/signature.dart'; import '../provider.dart'; @@ -36,12 +35,10 @@ class SettingsSignatureScreen extends HookConsumerWidget { final theme = Theme.of(context); final localizations = context.text; - final accounts = locator().accounts; + final accounts = ref.read(realAccountsProvider); final accountsWithSignature = List.from( accounts.where( - (account) => - account is RealAccount && - account.getSignatureHtml(localizations.localeName) != null, + (account) => account.getSignatureHtml(localizations.localeName) != null, ), ); String getActionName(ComposeAction action) { @@ -55,8 +52,7 @@ class SettingsSignatureScreen extends HookConsumerWidget { } } - return Base.buildAppChrome( - context, + return BasePage( title: localizations.signatureSettingsTitle, content: SingleChildScrollView( child: SafeArea( @@ -102,8 +98,7 @@ class SettingsSignatureScreen extends HookConsumerWidget { Text(localizations.signatureSettingsAccountInfo), PlatformTextButton( onPressed: () { - locator() - .push(Routes.settingsAccounts); + context.pushNamed(Routes.settingsAccounts); }, child: ButtonText(localizations.settingsActionAccounts), ), diff --git a/lib/settings/view/settings_swipe_screen.dart b/lib/settings/view/settings_swipe_screen.dart index 3300921..becc194 100644 --- a/lib/settings/view/settings_swipe_screen.dart +++ b/lib/settings/view/settings_swipe_screen.dart @@ -24,8 +24,7 @@ class SettingsSwipeScreen extends ConsumerWidget { final theme = Theme.of(context); final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.swipeSettingTitle, content: SingleChildScrollView( child: SafeArea( @@ -34,15 +33,19 @@ class SettingsSwipeScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(localizations.swipeSettingLeftToRightLabel, - style: theme.textTheme.bodySmall), + Text( + localizations.swipeSettingLeftToRightLabel, + style: theme.textTheme.bodySmall, + ), _SwipeSetting( swipeAction: leftToRightAction, isLeftToRight: true, ), const Divider(), - Text(localizations.swipeSettingRightToLeftLabel, - style: theme.textTheme.bodySmall), + Text( + localizations.swipeSettingRightToLeftLabel, + style: theme.textTheme.bodySmall, + ), _SwipeSetting( swipeAction: rightToLeftAction, isLeftToRight: false, @@ -120,6 +123,7 @@ class _SwipeSetting extends HookConsumerWidget { if (action == false) { return null; } + return action; } diff --git a/lib/settings/view/settings_theme_screen.dart b/lib/settings/view/settings_theme_screen.dart index b435523..5991695 100644 --- a/lib/settings/view/settings_theme_screen.dart +++ b/lib/settings/view/settings_theme_screen.dart @@ -11,8 +11,8 @@ import '../../widgets/button_text.dart'; import '../provider.dart'; import '../theme/model.dart'; -class SettingsThemeScreen extends HookConsumerWidget { - const SettingsThemeScreen({super.key}); +class SettingsDesignScreen extends HookConsumerWidget { + const SettingsDesignScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -42,8 +42,7 @@ class SettingsThemeScreen extends HookConsumerWidget { themeSettings.copyWith(themeModeSetting: value), ); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.designTitle, content: SingleChildScrollView( child: Material( @@ -53,8 +52,10 @@ class SettingsThemeScreen extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(localizations.designSectionThemeTitle, - style: theme.textTheme.titleMedium), + Text( + localizations.designSectionThemeTitle, + style: theme.textTheme.titleMedium, + ), PlatformRadioListTile( title: Text(localizations.designThemeOptionLight), value: ThemeModeSetting.light, @@ -109,8 +110,11 @@ class SettingsThemeScreen extends HookConsumerWidget { }, ), PlatformTextButton( - child: ButtonText(localizations.designThemeCustomEnd( - darkThemeEndTime.format(context))), + child: ButtonText( + localizations.designThemeCustomEnd( + darkThemeEndTime.format(context), + ), + ), onPressed: () async { final pickedTime = await showPlatformTimePicker( context: context, diff --git a/lib/util/modal_bottom_sheet_helper.dart b/lib/util/modal_bottom_sheet_helper.dart index 15ec988..94f1963 100644 --- a/lib/util/modal_bottom_sheet_helper.dart +++ b/lib/util/modal_bottom_sheet_helper.dart @@ -8,8 +8,12 @@ class ModelBottomSheetHelper { ModelBottomSheetHelper._(); static Future showModalBottomSheet( - BuildContext context, String title, Widget child, - {List? appBarActions, bool useScrollView = true}) async { + BuildContext context, + String title, + Widget child, { + List? appBarActions, + bool useScrollView = true, + }) async { appBarActions ??= [ DensePlatformIconButton( icon: Icon(CommonPlatformIcons.ok), @@ -20,8 +24,7 @@ class ModelBottomSheetHelper { bottom: false, child: Padding( padding: const EdgeInsets.only(top: 32), - child: Base.buildAppChrome( - context, + child: BasePage( title: title, includeDrawer: false, appBarActions: appBarActions, @@ -37,23 +40,22 @@ class ModelBottomSheetHelper { ); dynamic result; - if (PlatformInfo.isCupertino) { - result = await showCupertinoModalBottomSheet( - context: context, - builder: (context) => bottomSheetContent, - elevation: 8, - expand: true, - isDismissible: true, - ); - } else { - result = await showMaterialModalBottomSheet( - context: context, - builder: (context) => bottomSheetContent, - elevation: 8, - expand: true, - backgroundColor: Colors.transparent, - ); - } + result = PlatformInfo.isCupertino + ? await showCupertinoModalBottomSheet( + context: context, + builder: (context) => bottomSheetContent, + elevation: 8, + expand: true, + isDismissible: true, + ) + : await showMaterialModalBottomSheet( + context: context, + builder: (context) => bottomSheetContent, + elevation: 8, + expand: true, + backgroundColor: Colors.transparent, + ); + return (result == true); } } diff --git a/lib/widgets/account_selector.dart b/lib/widgets/account_selector.dart index 78a58f8..f4a8895 100644 --- a/lib/widgets/account_selector.dart +++ b/lib/widgets/account_selector.dart @@ -1,11 +1,11 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; -import '../locator.dart'; -import '../services/mail_service.dart'; +import '../account/provider.dart'; -class AccountSelector extends StatelessWidget { +class AccountSelector extends ConsumerWidget { const AccountSelector({ super.key, required this.onChanged, @@ -14,16 +14,15 @@ class AccountSelector extends StatelessWidget { }); final RealAccount? account; final bool excludeAccountsWithErrors; - final void Function(RealAccount? account) onChanged; + final void Function(RealAccount account) onChanged; @override - Widget build(BuildContext context) { - final accounts = List.from( - (excludeAccountsWithErrors - ? locator().accountsWithoutErrors - : locator().accounts) - .whereType(), - ); + Widget build(BuildContext context, WidgetRef ref) { + final allAccounts = ref.watch(realAccountsProvider); + final accounts = excludeAccountsWithErrors + ? allAccounts.where((account) => !account.hasError).toList() + : allAccounts; + return PlatformDropdownButton( value: account, items: accounts @@ -32,7 +31,11 @@ class AccountSelector extends StatelessWidget { child: Text(account.name), )) .toList(), - onChanged: onChanged, + onChanged: (account) { + if (account != null) { + onChanged(account); + } + }, ); } } diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 6a432a2..d296323 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:badges/badges.dart' as badges; +import 'package:collection/collection.dart'; import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; @@ -8,30 +9,29 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; -import '../account/providers.dart'; +import '../account/provider.dart'; import '../extensions/extension_action_tile.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../routes.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; import '../util/localized_dialog_helper.dart'; import 'mailbox_tree.dart'; +/// Displays the base navigation drawer with all accounts class AppDrawer extends ConsumerWidget { - const AppDrawer({super.key, this.currentAccount}); - - final Account? currentAccount; + /// Creates a new [AppDrawer] + const AppDrawer({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final accounts = ref.watch(allAccountsProvider); - final mailService = locator(); final theme = Theme.of(context); final localizations = context.text; final iconService = locator(); - final currentAccount = this.currentAccount; + final currentAccount = ref.watch(currentAccountProvider); + final hasAccountsWithErrors = ref.watch(hasAccountWithErrorProvider); return PlatformDrawer( child: SafeArea( @@ -44,7 +44,7 @@ class AppDrawer extends ConsumerWidget { child: _buildAccountHeader( context, currentAccount, - mailService.accounts, + accounts, theme, ), ), @@ -57,10 +57,10 @@ class AppDrawer extends ConsumerWidget { children: [ _buildAccountSelection( context, - mailService, accounts, currentAccount, localizations, + hasAccountsWithErrors: hasAccountsWithErrors, ), _buildFolderTree(context, currentAccount), if (currentAccount is RealAccount) @@ -104,14 +104,16 @@ class AppDrawer extends ConsumerWidget { ThemeData theme, ) { if (currentAccount == null) { - return Container(); + return const SizedBox.shrink(); } final avatarAccount = currentAccount is RealAccount ? currentAccount - : accounts.isNotEmpty - ? accounts.first as RealAccount - : null; + : (currentAccount is UnifiedAccount + ? currentAccount.accounts.first + : accounts.firstWhereOrNull((a) => a is RealAccount) + as RealAccount?); final avatarImageUrl = avatarAccount?.imageUrlGravatar; + final hasError = currentAccount is RealAccount && currentAccount.hasError; final userName = currentAccount is RealAccount ? currentAccount.userName : null; @@ -119,9 +121,8 @@ class AppDrawer extends ConsumerWidget { currentAccount.name, style: const TextStyle(fontWeight: FontWeight.bold), ); - final accountNameWithBadge = locator().hasError(currentAccount) - ? badges.Badge(child: accountName) - : accountName; + final accountNameWithBadge = + hasError ? badges.Badge(child: accountName) : accountName; return PlatformListTile( onTap: () { @@ -185,70 +186,20 @@ class AppDrawer extends ConsumerWidget { Widget _buildAccountSelection( BuildContext context, - MailService mailService, List accounts, Account? currentAccount, - AppLocalizations localizations, - ) => + AppLocalizations localizations, { + required bool hasAccountsWithErrors, + }) => accounts.length > 1 ? ExpansionTile( - leading: - mailService.hasAccountsWithErrors() ? const Badge() : null, + leading: hasAccountsWithErrors ? const Badge() : null, title: Text( localizations.drawerAccountsSectionTitle(accounts.length), ), children: [ for (final account in accounts) - SelectablePlatformListTile( - leading: mailService.hasError(account) - ? const Icon(Icons.error_outline) - : null, - tileColor: - mailService.hasError(account) ? Colors.red : null, - title: Text( - account is UnifiedAccount - ? localizations.unifiedAccountName - : account.name, - ), - selected: account == currentAccount, - onTap: () { - if (!Platform.isIOS) { - context.pop(); - } - if (mailService.hasError(account)) { - context.pushNamed( - Routes.accountEdit, - pathParameters: { - Routes.pathParameterEmail: account.email, - }, - ); - } else { - context.pushNamed( - Routes.mail, - pathParameters: { - Routes.pathParameterEmail: account.email, - }, - ); - } - }, - onLongPress: () { - if (account is UnifiedAccount) { - context.pushNamed( - Routes.settingsAccounts, - pathParameters: { - Routes.pathParameterEmail: account.email, - }, - ); - } else { - context.pushNamed( - Routes.accountEdit, - pathParameters: { - Routes.pathParameterEmail: account.email, - }, - ); - } - }, - ), + _SelectableAccountTile(account: account), _buildAddAccountTile(context, localizations), ], ) @@ -269,22 +220,25 @@ class AppDrawer extends ConsumerWidget { }, ); - Widget _buildFolderTree(BuildContext context, Account? account) { + Widget _buildFolderTree( + BuildContext context, + Account? account, + ) { if (account == null) { return const SizedBox.shrink(); } return MailboxTree( account: account, - onSelected: (mailbox) => _navigateToMailbox(context, mailbox), + onSelected: (mailbox) => _navigateToMailbox(context, account, mailbox), ); } - Future _navigateToMailbox(BuildContext context, Mailbox mailbox) async { - final account = currentAccount; - if (account == null) { - return; - } + Future _navigateToMailbox( + BuildContext context, + Account account, + Mailbox mailbox, + ) async { await context.pushNamed( Routes.mail, pathParameters: { @@ -296,3 +250,64 @@ class AppDrawer extends ConsumerWidget { ); } } + +class _SelectableAccountTile extends StatelessWidget { + const _SelectableAccountTile({required this.account}); + + final Account account; + + @override + Widget build(BuildContext context) { + final account = this.account; + final hasError = account is RealAccount && account.hasError; + final localizations = context.text; + + return SelectablePlatformListTile( + leading: hasError ? const Icon(Icons.error_outline) : null, + tileColor: hasError ? Colors.red : null, + title: Text( + account is UnifiedAccount + ? localizations.unifiedAccountName + : account.name, + ), + selected: account == currentAccount, + onTap: () { + if (!Platform.isIOS) { + context.pop(); + } + if (hasError) { + context.pushNamed( + Routes.accountEdit, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, + ); + } else { + context.pushNamed( + Routes.mail, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, + ); + } + }, + onLongPress: () { + if (account is UnifiedAccount) { + context.pushNamed( + Routes.settingsAccounts, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, + ); + } else { + context.pushNamed( + Routes.accountEdit, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, + ); + } + }, + ); + } +} diff --git a/lib/widgets/attachment_compose_bar.dart b/lib/widgets/attachment_compose_bar.dart index bab3fea..fb7ee12 100644 --- a/lib/widgets/attachment_compose_bar.dart +++ b/lib/widgets/attachment_compose_bar.dart @@ -74,7 +74,7 @@ class _AttachmentComposeBarState extends State { } } -class AddAttachmentPopupButton extends StatelessWidget { +class AddAttachmentPopupButton extends ConsumerWidget { const AddAttachmentPopupButton({ super.key, required this.composeData, @@ -84,7 +84,7 @@ class AddAttachmentPopupButton extends StatelessWidget { final Function() update; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final localizations = context.text; final iconService = locator(); const brightness = Brightness.light; @@ -172,8 +172,10 @@ class AddAttachmentPopupButton extends StatelessWidget { await locator().push(Routes.locationPicker); if (result != null) { composeData.messageBuilder.addBinary( - result, MediaSubtype.imagePng.mediaType, - filename: 'location.jpg'); + result, + MediaSubtype.imagePng.mediaType, + filename: 'location.jpg', + ); changed = true; } break; @@ -181,7 +183,8 @@ class AddAttachmentPopupButton extends StatelessWidget { changed = await addAttachmentGif(context, localizations); break; case 6: // appointment - changed = await addAttachmentAppointment(context, localizations); + changed = + await addAttachmentAppointment(context, ref, localizations); break; } if (changed) { @@ -250,15 +253,21 @@ class AddAttachmentPopupButton extends StatelessWidget { return false; } composeData.messageBuilder.addBinary( - data, MediaType.fromSubtype(MediaSubtype.imageGif), - filename: '${gif.title}.gif'); + data, + MediaType.fromSubtype(MediaSubtype.imageGif), + filename: '${gif.title}.gif', + ); return true; } Future addAttachmentAppointment( - BuildContext context, AppLocalizations localizations) async { - final appointment = await IcalComposer.createOrEditAppointment(context); + BuildContext context, + WidgetRef ref, + AppLocalizations localizations, + ) async { + final appointment = + await IcalComposer.createOrEditAppointment(context, ref); if (appointment != null) { // idea: add some sort of finalizer that updates the appointment at the end // to set the organizer and the attendees @@ -293,11 +302,13 @@ class _AppointmentFinalizer { email: organizer.email, commonName: organizer.personalName, ); - event.addAttendee(AttendeeProperty.create( - attendeeEmail: organizer.email, - commonName: organizer.personalName, - participantStatus: ParticipantStatus.accepted, - )!); + event.addAttendee( + AttendeeProperty.create( + attendeeEmail: organizer.email, + commonName: organizer.personalName, + participantStatus: ParticipantStatus.accepted, + )!, + ); } final recipients = []; if (messageBuilder.to != null) { @@ -307,11 +318,13 @@ class _AppointmentFinalizer { recipients.addAll(messageBuilder.cc!); } for (final mailAddress in recipients) { - event.addAttendee(AttendeeProperty.create( - attendeeEmail: mailAddress.email, - commonName: mailAddress.personalName, - rsvp: true, - )!); + event.addAttendee( + AttendeeProperty.create( + attendeeEmail: mailAddress.email, + commonName: mailAddress.personalName, + rsvp: true, + )!, + ); } attachmentBuilder.text = appointment.toString(); } @@ -360,6 +373,7 @@ class ComposeAttachment extends ConsumerWidget { final appointment = VComponent.parse(text) as VCalendar; final update = await IcalComposer.createOrEditAppointment( context, + ref, appointment: appointment, ); if (update != null) { diff --git a/lib/widgets/ical_composer.dart b/lib/widgets/ical_composer.dart index f8f1ebf..51a9ed8 100644 --- a/lib/widgets/ical_composer.dart +++ b/lib/widgets/ical_composer.dart @@ -1,29 +1,33 @@ import 'package:enough_icalendar/enough_icalendar.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; +import '../account/provider.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../services/i18n_service.dart'; -import '../services/mail_service.dart'; import '../util/datetime.dart'; import '../util/modal_bottom_sheet_helper.dart'; -class IcalComposer extends StatefulWidget { +class IcalComposer extends StatefulHookConsumerWidget { const IcalComposer({super.key, required this.appointment}); final VCalendar appointment; @override - State createState() => _IcalComposerState(); + ConsumerState createState() => _IcalComposerState(); - static Future createOrEditAppointment(BuildContext context, - {VCalendar? appointment}) async { + static Future createOrEditAppointment( + BuildContext context, + WidgetRef ref, { + VCalendar? appointment, + }) async { final localizations = context.text; // final iconService = locator(); - var account = locator().currentAccount!; - if (account.isVirtual) { - account = locator().accounts.first; + var account = ref.read(currentAccountProvider); + if (account is UnifiedAccount) { + account = account.accounts.first; } if (account is! RealAccount) { return null; @@ -47,11 +51,12 @@ class IcalComposer extends StatefulWidget { _IcalComposerState._current.apply(); appointment = editAppointment; } + return appointment; } } -class _IcalComposerState extends State { +class _IcalComposerState extends ConsumerState { static late _IcalComposerState _current; final TextEditingController _summaryController = TextEditingController(); final TextEditingController _descriptionController = TextEditingController(); @@ -419,6 +424,7 @@ class _RecurrenceComposerState extends State { Widget build(BuildContext context) { final i18nService = locator(); final localizations = context.text; + final rule = _recurrenceRule; return Padding( padding: const EdgeInsets.all(8), @@ -546,16 +552,22 @@ class _RecurrenceComposerState extends State { ], PlatformListTile( title: Text(localizations.composeAppointmentRecurrenceUntilLabel), - trailing: Text(rule.until == null - ? localizations - .composeAppointmentRecurrenceUntilOptionUnlimited - : rule.until == _recommendationDate - ? localizations - .composeAppointmentRecurrenceUntilOptionRecommended( - i18nService.formatIsoDuration( - rule.frequency.recommendedUntil!)) - : i18nService.formatDate(rule.until, - useLongFormat: true)), + trailing: Text( + rule.until == null + ? localizations + .composeAppointmentRecurrenceUntilOptionUnlimited + : rule.until == _recommendationDate + ? localizations + .composeAppointmentRecurrenceUntilOptionRecommended( + i18nService.formatIsoDuration( + rule.frequency.recommendedUntil!, + ), + ) + : i18nService.formatDate( + rule.until, + useLongFormat: true, + ), + ), onTap: () async { final until = await UntilComposer.createOrEditUntil( context, diff --git a/lib/widgets/mail_address_chip.dart b/lib/widgets/mail_address_chip.dart index 5f46206..324ed3c 100644 --- a/lib/widgets/mail_address_chip.dart +++ b/lib/widgets/mail_address_chip.dart @@ -1,14 +1,15 @@ +import 'dart:async'; + import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../routes.dart'; -import '../services/mail_service.dart'; -import '../services/navigation_service.dart'; import '../services/scaffold_messenger_service.dart'; import 'icon_text.dart'; @@ -17,19 +18,19 @@ class MailAddressChip extends StatelessWidget { final MailAddress mailAddress; final Widget? icon; - String get text => (mailAddress.hasPersonalName) - ? mailAddress.personalName! + String get nameOrEmail => (mailAddress.hasPersonalName) + ? mailAddress.personalName ?? mailAddress.email : mailAddress.email; @override Widget build(BuildContext context) { final localizations = context.text; final theme = Theme.of(context); + return PlatformPopupMenuButton<_AddressAction>( cupertinoButtonPadding: EdgeInsets.zero, icon: icon, - title: - mailAddress.hasPersonalName ? Text(mailAddress.personalName!) : null, + title: mailAddress.hasPersonalName ? Text(nameOrEmail) : null, message: Text(mailAddress.email, style: theme.textTheme.bodySmall), itemBuilder: (context) => [ PlatformPopupMenuItem( @@ -67,19 +68,26 @@ class MailAddressChip extends StatelessWidget { final messageBuilder = MessageBuilder()..to = [mailAddress]; final composeData = ComposeData(null, messageBuilder, ComposeAction.newMessage); - await locator() - .push(Routes.mailCompose, arguments: composeData); + if (context.mounted) { + unawaited( + context.pushNamed(Routes.mailCompose, extra: composeData), + ); + } break; case _AddressAction.search: - final search = - MailSearch(mailAddress.email, SearchQueryType.fromOrTo); - final source = await locator().search(search); - await locator() - .push(Routes.messageSource, arguments: source); + final search = MailSearch( + mailAddress.email, + SearchQueryType.fromOrTo, + ); + if (context.mounted) { + unawaited( + context.pushNamed(Routes.mailSearch, extra: search), + ); + } break; } }, - child: PlatformChip(label: Text(text)), + child: PlatformChip(label: Text(nameOrEmail)), ); } } diff --git a/lib/widgets/mailbox_selector.dart b/lib/widgets/mailbox_selector.dart index b2c4c13..e52a1b7 100644 --- a/lib/widgets/mailbox_selector.dart +++ b/lib/widgets/mailbox_selector.dart @@ -1,12 +1,12 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; -import '../locator.dart'; -import '../services/mail_service.dart'; +import '../mail/provider.dart'; -class MailboxSelector extends StatelessWidget { +class MailboxSelector extends ConsumerWidget { const MailboxSelector({ super.key, required this.account, @@ -14,28 +14,44 @@ class MailboxSelector extends StatelessWidget { this.mailbox, required this.onChanged, }); + final Account account; final bool showRoot; final Mailbox? mailbox; final void Function(Mailbox? mailbox) onChanged; @override - Widget build(BuildContext context) { - final mailboxTreeData = locator().getMailboxTreeFor(account)!; - final mailboxes = mailboxTreeData.flatten((box) => !box!.isNotSelectable); - final items = mailboxes - .map((box) => DropdownMenuItem(value: box, child: Text(box!.path))) - .toList(); - if (showRoot) { - items.insert( - 0, - DropdownMenuItem(child: Text(mailboxes.first!.pathSeparator)), - ); - } - return PlatformDropdownButton( - items: items, - value: mailbox, - onChanged: onChanged, + Widget build(BuildContext context, WidgetRef ref) { + final mailboxTreeData = ref.watch(mailboxTreeProvider(account: account)); + + return mailboxTreeData.when( + loading: () => const Center(child: PlatformProgressIndicator()), + error: (error, stack) => Center(child: Text('$error')), + data: (mailboxTree) { + final mailboxes = + mailboxTree.flatten((box) => !(box?.isNotSelectable ?? true)); + final items = mailboxes + .map( + (box) => + DropdownMenuItem(value: box, child: Text(box?.path ?? '')), + ) + .toList(); + if (showRoot) { + final first = mailboxes.first; + if (first != null) { + items.insert( + 0, + DropdownMenuItem(child: Text(first.pathSeparator)), + ); + } + } + + return PlatformDropdownButton( + items: items, + value: mailbox, + onChanged: onChanged, + ); + }, ); } } diff --git a/lib/widgets/mailbox_tree.dart b/lib/widgets/mailbox_tree.dart index ad5eea2..a18ad67 100644 --- a/lib/widgets/mailbox_tree.dart +++ b/lib/widgets/mailbox_tree.dart @@ -13,16 +13,15 @@ class MailboxTree extends ConsumerWidget { super.key, required this.account, required this.onSelected, - this.current, }); final Account account; final void Function(Mailbox mailbox) onSelected; - final Mailbox? current; @override Widget build(BuildContext context, WidgetRef ref) { final mailboxTreeValue = ref.watch(mailboxTreeProvider(account: account)); + final currentMailbox = ref.watch(currentMailboxProvider); return mailboxTreeValue.when( loading: () => Center( @@ -39,14 +38,18 @@ class MailboxTree extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final element in mailboxTreeElements) - _buildMailboxElement(element, 0), + _buildMailboxElement(element, 0, currentMailbox), ], ); }, ); } - Widget _buildMailboxElement(TreeElement element, final int level) { + Widget _buildMailboxElement( + TreeElement element, + final int level, + Mailbox? current, + ) { final mailbox = element.value; if (mailbox == null) { return const SizedBox.shrink(); @@ -74,7 +77,7 @@ class MailboxTree extends ConsumerWidget { title: title, children: [ for (final childElement in children) - _buildMailboxElement(childElement, level + 1), + _buildMailboxElement(childElement, level + 1, current), ], ), ); diff --git a/lib/widgets/message_actions.dart b/lib/widgets/message_actions.dart index 88bdb04..f853951 100644 --- a/lib/widgets/message_actions.dart +++ b/lib/widgets/message_actions.dart @@ -5,12 +5,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; +import '../contact/provider.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; import '../routes.dart'; -import '../services/contact_service.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; import '../services/navigation_service.dart'; @@ -91,7 +91,7 @@ class MessageActions extends HookConsumerWidget { _moveArchive(); break; case _OverflowMenuChoice.redirect: - _redirectMessage(context); + _redirectMessage(context, ref); break; case _OverflowMenuChoice.addNotification: _addNotification(); @@ -321,12 +321,11 @@ class MessageActions extends HookConsumerWidget { _navigateToCompose(ref, message, builder, ComposeAction.answer); } - Future _redirectMessage(BuildContext context) async { + Future _redirectMessage(BuildContext context, WidgetRef ref) async { final account = message.account; if (account is RealAccount) { - if (account.contactManager == null) { - await locator().getForAccount(account); - } + account.contactManager ??= + await ref.read(contactsLoaderProvider(account: account).future); } if (!context.mounted) { diff --git a/lib/widgets/recipient_input_field.dart b/lib/widgets/recipient_input_field.dart index c6766fe..0b8cc1f 100644 --- a/lib/widgets/recipient_input_field.dart +++ b/lib/widgets/recipient_input_field.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:fluttercontactpicker/fluttercontactpicker.dart'; +import '../contact/model.dart'; import '../localization/extension.dart'; -import '../models/contact.dart'; import '../util/validator.dart'; import 'icon_text.dart'; diff --git a/lib/widgets/signature.dart b/lib/widgets/signature.dart index 421a093..c093eb9 100644 --- a/lib/widgets/signature.dart +++ b/lib/widgets/signature.dart @@ -1,16 +1,19 @@ +import 'dart:async'; + import 'package:enough_html_editor/enough_html_editor.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; import '../account/model.dart'; +import '../account/provider.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../services/icon_service.dart'; -import '../services/mail_service.dart'; import '../settings/provider.dart'; import '../util/modal_bottom_sheet_helper.dart'; @@ -48,10 +51,10 @@ class SignatureWidget extends HookConsumerWidget { onPressed: () async { signatureState.value = null; - Navigator.of(context).pop(false); + context.pop(false); if (account != null) { account.signatureHtml = null; - await locator().saveAccounts(); + unawaited(ref.read(realAccountsProvider.notifier).save()); } else { final settings = ref.read(settingsProvider); final notifier = ref.read(settingsProvider.notifier); @@ -79,7 +82,7 @@ class SignatureWidget extends HookConsumerWidget { ); } else { account.signatureHtml = newSignature; - await locator().saveAccounts(); + unawaited(ref.read(realAccountsProvider.notifier).save()); } } } From 16542971942137a623402b20eed92bdf37c7a004 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 10:36:31 +0200 Subject: [PATCH 09/95] feat: use localized mailbox/folder names --- lib/localization/app_de.arb | 1 + lib/localization/app_en.arb | 4 + lib/localization/app_localizations.g.dart | 6 + lib/localization/app_localizations_de.g.dart | 3 + lib/localization/app_localizations_en.g.dart | 3 + lib/localization/extension.dart | 1 + lib/mail/model.dart | 112 +++++++++++++++++++ lib/mail/provider.dart | 2 +- lib/models/async_mime_source.dart | 15 ++- lib/models/message_source.dart | 22 ++-- lib/models/offline_mime_source.dart | 25 +++-- lib/screens/message_source_screen.dart | 43 +++++-- lib/services/mail_service.dart | 2 +- lib/widgets/app_drawer.dart | 13 ++- lib/widgets/mailbox_tree.dart | 32 +++++- 15 files changed, 240 insertions(+), 44 deletions(-) create mode 100644 lib/mail/model.dart diff --git a/lib/localization/app_de.arb b/lib/localization/app_de.arb index 0d601ef..132086d 100644 --- a/lib/localization/app_de.arb +++ b/lib/localization/app_de.arb @@ -90,6 +90,7 @@ "folderTrash": "Papierkorb", "folderArchive": "Archiv", "folderJunk": "Spam Nachrichten", + "folderUnknown": "Unbekannt", "viewContentsAction": "Inhalt anzeigen", "viewSourceAction": "Sourcecode anzeigen", "detailsErrorDownloadInfo": "E-Mail konnte nicht geladen werden.", diff --git a/lib/localization/app_en.arb b/lib/localization/app_en.arb index 3983a38..2770fce 100644 --- a/lib/localization/app_en.arb +++ b/lib/localization/app_en.arb @@ -409,6 +409,10 @@ "@folderJunk": { "description": "Folder name." }, + "folderUnknown": "Unknown", + "@folderUnknown": { + "description": "Folder name for a message source without a name." + }, "viewContentsAction": "View contents", "@viewContentsAction": { "description": "Show contents of a message on a separate screen." diff --git a/lib/localization/app_localizations.g.dart b/lib/localization/app_localizations.g.dart index c144893..a69d075 100644 --- a/lib/localization/app_localizations.g.dart +++ b/lib/localization/app_localizations.g.dart @@ -627,6 +627,12 @@ abstract class AppLocalizations { /// **'Junk'** String get folderJunk; + /// Folder name for a message source without a name. + /// + /// In en, this message translates to: + /// **'Unknown'** + String get folderUnknown; + /// Show contents of a message on a separate screen. /// /// In en, this message translates to: diff --git a/lib/localization/app_localizations_de.g.dart b/lib/localization/app_localizations_de.g.dart index c74863b..40ca1e5 100644 --- a/lib/localization/app_localizations_de.g.dart +++ b/lib/localization/app_localizations_de.g.dart @@ -361,6 +361,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get folderJunk => 'Spam Nachrichten'; + @override + String get folderUnknown => 'Unbekannt'; + @override String get viewContentsAction => 'Inhalt anzeigen'; diff --git a/lib/localization/app_localizations_en.g.dart b/lib/localization/app_localizations_en.g.dart index 2101c66..1320094 100644 --- a/lib/localization/app_localizations_en.g.dart +++ b/lib/localization/app_localizations_en.g.dart @@ -361,6 +361,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get folderJunk => 'Junk'; + @override + String get folderUnknown => 'Unknown'; + @override String get viewContentsAction => 'View contents'; diff --git a/lib/localization/extension.dart b/lib/localization/extension.dart index 5a88199..9e04458 100644 --- a/lib/localization/extension.dart +++ b/lib/localization/extension.dart @@ -3,6 +3,7 @@ import 'package:flutter/cupertino.dart'; import 'app_localizations.g.dart'; import 'app_localizations_en.g.dart'; +/// Allows to look up the localized strings for the current locale extension AppLocalizationBuildContext on BuildContext { /// Retrieves the current localizations AppLocalizations get text => diff --git a/lib/mail/model.dart b/lib/mail/model.dart new file mode 100644 index 0000000..cf64401 --- /dev/null +++ b/lib/mail/model.dart @@ -0,0 +1,112 @@ +import 'package:enough_mail/enough_mail.dart'; + +import '../localization/app_localizations.g.dart'; +import '../models/message_source.dart'; +import '../settings/model.dart'; + +/// Retrieves the localized name for the given mailbox flag +extension _MailboxFlagExtensions on MailboxFlag { + /// Retrieves the localized name for the given mailbox flag + String localizedName( + AppLocalizations localizations, + Settings settings, + Mailbox? mailbox, + ) { + final identityFlag = this; + final folderNameSetting = settings.folderNameSetting; + final isVirtual = mailbox?.isVirtual ?? true; + switch (folderNameSetting) { + case FolderNameSetting.server: + return mailbox?.name ?? name; + case FolderNameSetting.localized: + switch (identityFlag) { + case MailboxFlag.inbox: + return isVirtual + ? localizations.unifiedFolderInbox + : localizations.folderInbox; + case MailboxFlag.drafts: + return isVirtual + ? localizations.unifiedFolderDrafts + : localizations.folderDrafts; + case MailboxFlag.sent: + return isVirtual + ? localizations.unifiedFolderSent + : localizations.folderSent; + case MailboxFlag.trash: + return isVirtual + ? localizations.unifiedFolderTrash + : localizations.folderTrash; + case MailboxFlag.archive: + return isVirtual + ? localizations.unifiedFolderArchive + : localizations.folderArchive; + case MailboxFlag.junk: + return isVirtual + ? localizations.unifiedFolderJunk + : localizations.folderJunk; + // ignore: no_default_cases + default: + return mailbox?.name ?? name; + } + case FolderNameSetting.custom: + final customNames = settings.customFolderNames ?? + (isVirtual + ? [ + localizations.unifiedFolderInbox, + localizations.unifiedFolderDrafts, + localizations.unifiedFolderSent, + localizations.unifiedFolderTrash, + localizations.unifiedFolderArchive, + localizations.unifiedFolderJunk, + ] + : [ + localizations.folderInbox, + localizations.folderDrafts, + localizations.folderSent, + localizations.folderTrash, + localizations.folderArchive, + localizations.folderJunk, + ]); + switch (identityFlag) { + case MailboxFlag.inbox: + return customNames[0]; + case MailboxFlag.drafts: + return customNames[1]; + case MailboxFlag.sent: + return customNames[2]; + case MailboxFlag.trash: + return customNames[3]; + case MailboxFlag.archive: + return customNames[4]; + case MailboxFlag.junk: + return customNames[5]; + // ignore: no_default_cases + default: + return mailbox?.name ?? name; + } + } + } +} + +/// Allows to translate mailbox names +extension MailboxExtensions on Mailbox { + /// Retrieves the translated name + String localizedName(AppLocalizations localizations, Settings settings) => + identityFlag?.localizedName(localizations, settings, this) ?? name; +} + +/// Allows to translate mailbox names +extension MessageSourceExtensions on MessageSource { + /// Retrieves the translated name + String localizedName(AppLocalizations localizations, Settings settings) { + final source = this; + if (source is MailboxMessageSource) { + return source.mailbox.localizedName(localizations, settings); + } + if (source is MultipleMessageSource) { + return source.flag.localizedName(localizations, settings, null); + } + + return source.name ?? source.parentName ?? localizations.folderUnknown; + } +} diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index 13fc8b9..2b84adc 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -100,7 +100,7 @@ class RealSource extends _$RealSource { return MailboxMessageSource.fromMimeSource( source, account.email, - mailbox?.name ?? '', + mailbox ?? source.mailbox, account: account, ); } diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index 613c4bf..5fbbcd0 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -19,6 +19,9 @@ abstract class AsyncMimeSource { /// The mail client associated with this source MailClient get mailClient; + /// Retrieves the mailbox associated with this source + Mailbox get mailbox; + /// The name of this source String get name; @@ -100,8 +103,11 @@ abstract class AsyncMimeSource { Future undoMoveMessages(MoveResult moveResult); /// Adds or removes [flags] to/from the given [messages] - Future store(List messages, List flags, - {StoreAction action = StoreAction.add}); + Future store( + List messages, + List flags, { + StoreAction action = StoreAction.add, + }); /// Adds or removes [flags]to all messages Future storeAll( @@ -297,8 +303,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { final largerMatcher = sequence.isUidSequence ? uidLargerMatcher : sequenceIdLargerMatcher; - final ids = sequence.toList(); - ids.sort((a, b) => b.compareTo(a)); + final ids = sequence.toList()..sort((a, b) => b.compareTo(a)); for (final id in ids) { final mime = cache.removeFirstWhere((m) => equalsMatcher(m, id)); @@ -489,6 +494,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { AsyncMailboxMimeSource(this.mailbox, this.mailClient); /// The mailbox + @override final Mailbox mailbox; @override @@ -766,6 +772,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { final MailSearch mailSearch; /// The mailbox on which the search is done + @override final Mailbox mailbox; /// The parent mime source diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 8468529..2168716 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -630,16 +630,19 @@ class MailboxMessageSource extends MessageSource { MailboxMessageSource.fromMimeSource( this.mimeSource, String description, - String name, { + this.mailbox, { required this.account, super.parent, super.isSearch, }) { _description = description; - _name = name; + _name = mailbox.name; mimeSource.addSubscriber(this); } + /// The associated mailbox + final Mailbox mailbox; + @override final RealAccount account; @@ -726,12 +729,11 @@ class MailboxMessageSource extends MessageSource { @override MessageSource search(MailSearch search) { final searchSource = mimeSource.search(search); - final localizations = locator().localizations; return MailboxMessageSource.fromMimeSource( searchSource, - localizations.searchQueryDescription(name!), - localizations.searchQueryTitle(search.query), + search.query, + mailbox, account: account, parent: this, isSearch: true, @@ -783,7 +785,7 @@ class MultipleMessageSource extends MessageSource { MultipleMessageSource( this.mimeSources, String name, - MailboxFlag? flag, { + this.flag, { required this.account, super.parent, super.isSearch, @@ -793,7 +795,6 @@ class MultipleMessageSource extends MessageSource { _multipleMimeSources.add(_MultipleMimeSource(s)); } _name = name; - _flag = flag; _description = mimeSources.map((s) => s.mailClient.account.name).join(', '); } @@ -810,7 +811,10 @@ class MultipleMessageSource extends MessageSource { /// The integrated mime sources final List mimeSources; final _multipleMimeSources = <_MultipleMimeSource>[]; - MailboxFlag? _flag; + + /// The identity flag of the mailbox + MailboxFlag flag; + final _indicesCache = <_MultipleMessageSourceId>[]; @override @@ -997,7 +1001,7 @@ class MultipleMessageSource extends MessageSource { final searchMessageSource = MultipleMessageSource( searchMimeSources, localizations.searchQueryTitle(search.query), - _flag, + flag, account: account, parent: this, isSearch: true, diff --git a/lib/models/offline_mime_source.dart b/lib/models/offline_mime_source.dart index 4888a5e..d4bb36e 100644 --- a/lib/models/offline_mime_source.dart +++ b/lib/models/offline_mime_source.dart @@ -10,16 +10,16 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { /// Creates a new [OfflineMailboxMimeSource] OfflineMailboxMimeSource({ required MailAccount mailAccount, - required Mailbox mailbox, + required this.mailbox, required PagedCachedMimeSource onlineMimeSource, required OfflineMimeStorage storage, }) : _mailAccount = mailAccount, - _mailbox = mailbox, _onlineMimeSource = onlineMimeSource, _storage = storage; final MailAccount _mailAccount; - final Mailbox _mailbox; + @override + final Mailbox mailbox; final PagedCachedMimeSource _onlineMimeSource; final OfflineMimeStorage _storage; @@ -70,8 +70,11 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { } @override - Future fetchMessagePart(MimeMessage message, - {required String fetchId, Duration? responseTimeout}) => + Future fetchMessagePart( + MimeMessage message, { + required String fetchId, + Duration? responseTimeout, + }) => _onlineMimeSource.fetchMessagePart( message, fetchId: fetchId, @@ -110,19 +113,19 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { ); @override - bool get isArchive => _mailbox.isArchive; + bool get isArchive => mailbox.isArchive; @override - bool get isInbox => _mailbox.isInbox; + bool get isInbox => mailbox.isInbox; @override - bool get isJunk => _mailbox.isJunk; + bool get isJunk => mailbox.isJunk; @override - bool get isSent => _mailbox.isSent; + bool get isSent => mailbox.isSent; @override - bool get isTrash => _mailbox.isTrash; + bool get isTrash => mailbox.isTrash; @override Future> loadMessages(MessageSequence sequence) async { @@ -196,7 +199,7 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { } @override - int get size => _mailbox.messagesExists; + int get size => mailbox.messagesExists; @override Future store( diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index 9c79d24..fe7222b 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -10,6 +10,7 @@ import '../account/provider.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; +import '../mail/model.dart'; import '../models/compose_data.dart'; import '../models/date_sectioned_message_source.dart'; import '../models/message.dart'; @@ -141,6 +142,7 @@ class _MessageSourceScreenState extends ConsumerState @override Widget build(BuildContext context) { // print('parent name: ${widget.messageSource.parentName}'); + final settings = ref.watch(settingsProvider); final theme = Theme.of(context); final localizations = context.text; final source = _sectionedMessageSource.messageSource; @@ -173,8 +175,11 @@ class _MessageSourceScreenState extends ConsumerState }, ) : (PlatformInfo.isCupertino) - ? Text(source.name ?? '') - : BaseTitle(title: source.name ?? '', subtitle: source.description); + ? Text(source.localizedName(localizations, settings)) + : BaseTitle( + title: source.localizedName(localizations, settings), + subtitle: source.description, + ); final appBarActions = [ if (_isInSearchMode && _hasSearchInput) @@ -338,7 +343,8 @@ class _MessageSourceScreenState extends ConsumerState padding: const EdgeInsets.all(8), child: Text( localizations.homeLoading( - source.name ?? source.description ?? ''), + source.name ?? source.description ?? '', + ), ), ), ), @@ -350,6 +356,7 @@ class _MessageSourceScreenState extends ConsumerState return WillPopScope( onWillPop: () { switchVisualization(_Visualization.list); + return Future.value(false); }, child: MessageStack(messageSource: source), @@ -363,8 +370,10 @@ class _MessageSourceScreenState extends ConsumerState onWillPop: () { if (_isInSelectionMode) { leaveSelectionMode(); + return Future.value(false); } + return Future.value(true); }, child: RefreshIndicator( @@ -414,6 +423,7 @@ class _MessageSourceScreenState extends ConsumerState } index--; } + return FutureBuilder( future: _sectionedMessageSource.getElementAt(index), @@ -872,13 +882,17 @@ class _MessageSourceScreenState extends ConsumerState final notification = localizations.multipleMovedToArchive(_selectedMessages.length); await source.moveMessagesToFlag( - _selectedMessages, MailboxFlag.archive, notification); + _selectedMessages, + MailboxFlag.archive, + notification, + ); break; case _MultipleChoice.viewInSafeMode: if (_selectedMessages.isNotEmpty) { - await locator().push(Routes.mailDetails, - arguments: - DisplayMessageArguments(_selectedMessages.first, true)); + await locator().push( + Routes.mailDetails, + arguments: DisplayMessageArguments(_selectedMessages.first, true), + ); } endSelectionMode = false; leaveSelectionMode(); @@ -909,7 +923,8 @@ class _MessageSourceScreenState extends ConsumerState } Future forwardAttachmentsLike( - Future? Function(Message, MessageBuilder) loader) async { + Future? Function(Message, MessageBuilder) loader, + ) async { final builder = MessageBuilder(); final fromAddresses = []; final subjects = []; @@ -944,13 +959,16 @@ class _MessageSourceScreenState extends ConsumerState } final composeFuture = futures.isEmpty ? null : Future.wait(futures); final composeData = ComposeData( - _selectedMessages, builder, ComposeAction.forward, - future: composeFuture); + _selectedMessages, + builder, + ComposeAction.forward, + future: composeFuture, + ); await locator() .push(Routes.mailCompose, arguments: composeData, fade: true); } - Future? addMessageAttachment(Message message, MessageBuilder builder) { + Future addMessageAttachment(Message message, MessageBuilder builder) { final mime = message.mimeMessage; if (mime.mimeData == null) { return message.source.fetchMessageContents(message).then((value) { @@ -959,7 +977,8 @@ class _MessageSourceScreenState extends ConsumerState } else { builder.addMessagePart(mime); } - return null; + + return Future.value(); } Future? addAttachments(Message message, MessageBuilder builder) { diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index 4363d67..151f28a 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -200,7 +200,7 @@ class MailService implements MimeSourceSubscriber { return MailboxMessageSource.fromMimeSource( source, mailClient.account.email, - mailbox.name, + mailbox, account: account, ); } diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index d296323..c58614e 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -170,7 +170,7 @@ class AppDrawer extends ConsumerWidget { ? currentAccount.accounts .map((a) => a.name) .join(', ') - : (currentAccount as RealAccount).email, + : currentAccount.email, style: const TextStyle( fontStyle: FontStyle.italic, fontSize: 14, @@ -199,7 +199,10 @@ class AppDrawer extends ConsumerWidget { ), children: [ for (final account in accounts) - _SelectableAccountTile(account: account), + _SelectableAccountTile( + account: account, + currentAccount: currentAccount, + ), _buildAddAccountTile(context, localizations), ], ) @@ -252,9 +255,13 @@ class AppDrawer extends ConsumerWidget { } class _SelectableAccountTile extends StatelessWidget { - const _SelectableAccountTile({required this.account}); + const _SelectableAccountTile({ + required this.account, + required this.currentAccount, + }); final Account account; + final Account? currentAccount; @override Widget build(BuildContext context) { diff --git a/lib/widgets/mailbox_tree.dart b/lib/widgets/mailbox_tree.dart index a18ad67..74b49fd 100644 --- a/lib/widgets/mailbox_tree.dart +++ b/lib/widgets/mailbox_tree.dart @@ -4,24 +4,36 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; import '../locator.dart'; +import '../mail/model.dart'; import '../mail/provider.dart'; import '../services/icon_service.dart'; +import '../settings/model.dart'; +import '../settings/provider.dart'; +/// Displays a tree of mailboxes class MailboxTree extends ConsumerWidget { + /// Creates a new [MailboxTree] const MailboxTree({ super.key, required this.account, required this.onSelected, }); + /// The associated account final Account account; + + /// Callback when a mailbox is selected final void Function(Mailbox mailbox) onSelected; @override Widget build(BuildContext context, WidgetRef ref) { final mailboxTreeValue = ref.watch(mailboxTreeProvider(account: account)); final currentMailbox = ref.watch(currentMailboxProvider); + final settings = ref.watch(settingsProvider); + final localizations = context.text; return mailboxTreeValue.when( loading: () => Center( @@ -38,7 +50,13 @@ class MailboxTree extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final element in mailboxTreeElements) - _buildMailboxElement(element, 0, currentMailbox), + _buildMailboxElement( + localizations, + settings, + element, + 0, + currentMailbox, + ), ], ); }, @@ -46,6 +64,8 @@ class MailboxTree extends ConsumerWidget { } Widget _buildMailboxElement( + AppLocalizations localizations, + Settings settings, TreeElement element, final int level, Mailbox? current, @@ -57,7 +77,7 @@ class MailboxTree extends ConsumerWidget { final title = Padding( padding: EdgeInsets.only(left: level * 8.0), - child: Text(mailbox.name), + child: Text(mailbox.localizedName(localizations, settings)), ); final children = element.children; if (children == null) { @@ -77,7 +97,13 @@ class MailboxTree extends ConsumerWidget { title: title, children: [ for (final childElement in children) - _buildMailboxElement(childElement, level + 1, current), + _buildMailboxElement( + localizations, + settings, + childElement, + level + 1, + current, + ), ], ), ); From 613e38134af56aea3f3dd6e3907f8b0a91d5481e Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 11:49:11 +0200 Subject: [PATCH 10/95] chore: move notification service --- lib/locator.dart | 4 +- lib/mail/provider.dart | 94 +- lib/mail/provider.g.dart | 1072 ++++++++--------- lib/models/async_mime_source.dart | 9 + lib/models/message_source.dart | 3 +- lib/notification/model.dart | 64 + .../model.g.dart} | 2 +- .../service.dart} | 50 +- lib/routes.dart | 2 +- lib/screens/message_details_screen.dart | 2 +- ...ssage_details_screen_for_notification.dart | 2 +- lib/screens/message_source_screen.dart | 2 +- lib/services/background_service.dart | 4 +- lib/services/mail_service.dart | 2 +- lib/widgets/message_actions.dart | 2 +- test/model/fake_mime_source.dart | 15 +- test/model/multiple_message_source_test.dart | 18 +- 17 files changed, 691 insertions(+), 656 deletions(-) create mode 100644 lib/notification/model.dart rename lib/{services/notification_service.g.dart => notification/model.g.dart} (96%) rename lib/{services/notification_service.dart => notification/service.dart} (84%) diff --git a/lib/locator.dart b/lib/locator.dart index 7b8f76e..f9d0a15 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -1,6 +1,7 @@ import 'package:get_it/get_it.dart'; import 'models/async_mime_source_factory.dart'; +import 'notification/service.dart'; import 'services/app_service.dart'; import 'services/background_service.dart'; import 'services/biometrics_service.dart'; @@ -11,7 +12,6 @@ import 'services/key_service.dart'; import 'services/location_service.dart'; import 'services/mail_service.dart'; import 'services/navigation_service.dart'; -import 'services/notification_service.dart'; import 'services/providers.dart'; import 'services/scaffold_messenger_service.dart'; @@ -30,7 +30,7 @@ void setupLocator() { ..registerLazySingleton(ScaffoldMessengerService.new) ..registerLazySingleton(DateService.new) ..registerSingleton(IconService()) - ..registerLazySingleton(NotificationService.new) + ..registerLazySingleton(() => NotificationService.instance) ..registerLazySingleton(BackgroundService.new) ..registerLazySingleton(AppService.new) ..registerLazySingleton(LocationService.new) diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index 2b84adc..1b67303 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -7,7 +7,8 @@ import '../account/provider.dart'; import '../models/async_mime_source.dart'; import '../models/message.dart'; import '../models/message_source.dart'; -import '../services/notification_service.dart'; +import '../notification/model.dart'; +import '../notification/service.dart'; import '../settings/provider.dart'; import 'service.dart'; @@ -86,7 +87,7 @@ class UnifiedSource extends _$UnifiedSource { /// Provides the message source for the given account @Riverpod(keepAlive: true) -class RealSource extends _$RealSource { +class RealSource extends _$RealSource implements MimeSourceSubscriber { @override Future build({ required RealAccount account, @@ -94,8 +95,8 @@ class RealSource extends _$RealSource { }) async { final source = await ref.watch( realMimeSourceProvider(account: account, mailbox: mailbox).future, - ); //..addSubscriber(this); - // TODO(RV): add subscriber to send notification for unseen inbox mails + ) + ..addSubscriber(this); return MailboxMessageSource.fromMimeSource( source, @@ -104,6 +105,37 @@ class RealSource extends _$RealSource { account: account, ); } + + @override + void onMailArrived( + MimeMessage mime, + AsyncMimeSource source, { + int index = 0, + }) { + source.mailClient.lowLevelIncomingMailClient + .logApp('new message: ${mime.decodeSubject()}'); + if (!mime.isSeen && source.isInbox) { + NotificationService.instance + .sendLocalNotificationForMail(mime, source.mailClient.account.email); + } + } + + @override + void onMailCacheInvalidated(AsyncMimeSource source) { + // ignore + } + + @override + void onMailFlagsUpdated(MimeMessage mime, AsyncMimeSource source) { + if (mime.isSeen) { + NotificationService.instance.cancelNotificationForMail(mime); + } + } + + @override + void onMailVanished(MimeMessage mime, AsyncMimeSource source) { + NotificationService.instance.cancelNotificationForMail(mime); + } } //// Loads the mailbox tree for the given account @@ -135,7 +167,7 @@ Future> mailboxTree( } //// Loads the mailbox tree for the given account -@Riverpod(keepAlive: true) +@riverpod Future findMailbox( FindMailboxRef ref, { required Account account, @@ -151,21 +183,19 @@ Future findMailbox( /// Provides the message source for the given account @Riverpod(keepAlive: true) -class RealMimeSource extends _$RealMimeSource { - @override - Future build({ - required RealAccount account, - Mailbox? mailbox, - }) async { - final mailClient = ref.watch( - mailClientSourceProvider(account: account, mailbox: mailbox), - ); +Future realMimeSource( + RealMimeSourceRef ref, { + required RealAccount account, + Mailbox? mailbox, +}) async { + final mailClient = ref.watch( + mailClientSourceProvider(account: account, mailbox: mailbox), + ); - return EmailService.instance.createMimeSource( - mailClient: mailClient, - mailbox: mailbox, - ); - } + return EmailService.instance.createMimeSource( + mailClient: mailClient, + mailbox: mailbox, + ); } /// Provides mail clients @@ -231,20 +261,18 @@ Future singleMessageLoader( } /// Provides mail clients -@Riverpod(keepAlive: false) -class FirstTimeMailClientSource extends _$FirstTimeMailClientSource { - @override - Future build({ - required RealAccount account, - Mailbox? mailbox, - }) => - EmailService.instance.connectFirstTime( - account.mailAccount, - (mailAccount) => ref - .watch(realAccountsProvider.notifier) - .updateMailAccount(account, mailAccount), - ); -} +@riverpod +Future firstTimeMailClientSource( + FirstTimeMailClientSourceRef ref, { + required RealAccount account, + Mailbox? mailbox, +}) => + EmailService.instance.connectFirstTime( + account.mailAccount, + (mailAccount) => ref + .watch(realAccountsProvider.notifier) + .updateMailAccount(account, mailAccount), + ); /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri @riverpod diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart index 0cd8b49..b8ad552 100644 --- a/lib/mail/provider.g.dart +++ b/lib/mail/provider.g.dart @@ -167,7 +167,7 @@ class _MailboxTreeProviderElement extends FutureProviderElement> Account get account => (origin as MailboxTreeProvider).account; } -String _$findMailboxHash() => r'af999b6d27a50cab3b97f91bc09b8a5641deea76'; +String _$findMailboxHash() => r'fb113e28a8bb6904dbdd07a73898bc198afb2dda'; //// Loads the mailbox tree for the given account /// @@ -225,7 +225,7 @@ class FindMailboxFamily extends Family> { //// Loads the mailbox tree for the given account /// /// Copied from [findMailbox]. -class FindMailboxProvider extends FutureProvider { +class FindMailboxProvider extends AutoDisposeFutureProvider { //// Loads the mailbox tree for the given account /// /// Copied from [findMailbox]. @@ -285,7 +285,7 @@ class FindMailboxProvider extends FutureProvider { } @override - FutureProviderElement createElement() { + AutoDisposeFutureProviderElement createElement() { return _FindMailboxProviderElement(this); } @@ -306,7 +306,7 @@ class FindMailboxProvider extends FutureProvider { } } -mixin FindMailboxRef on FutureProviderRef { +mixin FindMailboxRef on AutoDisposeFutureProviderRef { /// The parameter `account` of this provider. Account get account; @@ -314,8 +314,8 @@ mixin FindMailboxRef on FutureProviderRef { String get encodedMailboxPath; } -class _FindMailboxProviderElement extends FutureProviderElement - with FindMailboxRef { +class _FindMailboxProviderElement + extends AutoDisposeFutureProviderElement with FindMailboxRef { _FindMailboxProviderElement(super.provider); @override @@ -325,6 +325,163 @@ class _FindMailboxProviderElement extends FutureProviderElement (origin as FindMailboxProvider).encodedMailboxPath; } +String _$realMimeSourceHash() => r'f9aa67160da33cc3ebb0ae9fd9edfeeaab85ab76'; + +/// Provides the message source for the given account +/// +/// Copied from [realMimeSource]. +@ProviderFor(realMimeSource) +const realMimeSourceProvider = RealMimeSourceFamily(); + +/// Provides the message source for the given account +/// +/// Copied from [realMimeSource]. +class RealMimeSourceFamily extends Family> { + /// Provides the message source for the given account + /// + /// Copied from [realMimeSource]. + const RealMimeSourceFamily(); + + /// Provides the message source for the given account + /// + /// Copied from [realMimeSource]. + RealMimeSourceProvider call({ + required RealAccount account, + Mailbox? mailbox, + }) { + return RealMimeSourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + RealMimeSourceProvider getProviderOverride( + covariant RealMimeSourceProvider provider, + ) { + return call( + account: provider.account, + mailbox: provider.mailbox, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'realMimeSourceProvider'; +} + +/// Provides the message source for the given account +/// +/// Copied from [realMimeSource]. +class RealMimeSourceProvider extends FutureProvider { + /// Provides the message source for the given account + /// + /// Copied from [realMimeSource]. + RealMimeSourceProvider({ + required RealAccount account, + Mailbox? mailbox, + }) : this._internal( + (ref) => realMimeSource( + ref as RealMimeSourceRef, + account: account, + mailbox: mailbox, + ), + from: realMimeSourceProvider, + name: r'realMimeSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$realMimeSourceHash, + dependencies: RealMimeSourceFamily._dependencies, + allTransitiveDependencies: + RealMimeSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + RealMimeSourceProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + required this.mailbox, + }) : super.internal(); + + final RealAccount account; + final Mailbox? mailbox; + + @override + Override overrideWith( + FutureOr Function(RealMimeSourceRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: RealMimeSourceProvider._internal( + (ref) => create(ref as RealMimeSourceRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + FutureProviderElement createElement() { + return _RealMimeSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is RealMimeSourceProvider && + other.account == account && + other.mailbox == mailbox; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin RealMimeSourceRef on FutureProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _RealMimeSourceProviderElement + extends FutureProviderElement with RealMimeSourceRef { + _RealMimeSourceProviderElement(super.provider); + + @override + RealAccount get account => (origin as RealMimeSourceProvider).account; + @override + Mailbox? get mailbox => (origin as RealMimeSourceProvider).mailbox; +} + String _$mailSearchHash() => r'166028850f57246adf47921d461b1ff3b5bc3230'; /// Carries out a search for mail messages @@ -608,43 +765,45 @@ class _SingleMessageLoaderProviderElement (origin as SingleMessageLoaderProvider).payload; } -String _$mailtoHash() => r'392c1cf4d13bff03113b564193f1f1b21099cdac'; +String _$firstTimeMailClientSourceHash() => + r'ae11f3a5ed5cb6329488bd3f9ac3569ac8ad1f36'; -/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// Provides mail clients /// -/// Copied from [mailto]. -@ProviderFor(mailto) -const mailtoProvider = MailtoFamily(); +/// Copied from [firstTimeMailClientSource]. +@ProviderFor(firstTimeMailClientSource) +const firstTimeMailClientSourceProvider = FirstTimeMailClientSourceFamily(); -/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// Provides mail clients /// -/// Copied from [mailto]. -class MailtoFamily extends Family { - /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// Copied from [firstTimeMailClientSource]. +class FirstTimeMailClientSourceFamily + extends Family> { + /// Provides mail clients /// - /// Copied from [mailto]. - const MailtoFamily(); + /// Copied from [firstTimeMailClientSource]. + const FirstTimeMailClientSourceFamily(); - /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri + /// Provides mail clients /// - /// Copied from [mailto]. - MailtoProvider call({ - required Uri mailtoUri, - required MimeMessage originatingMessage, + /// Copied from [firstTimeMailClientSource]. + FirstTimeMailClientSourceProvider call({ + required RealAccount account, + Mailbox? mailbox, }) { - return MailtoProvider( - mailtoUri: mailtoUri, - originatingMessage: originatingMessage, + return FirstTimeMailClientSourceProvider( + account: account, + mailbox: mailbox, ); } @override - MailtoProvider getProviderOverride( - covariant MailtoProvider provider, + FirstTimeMailClientSourceProvider getProviderOverride( + covariant FirstTimeMailClientSourceProvider provider, ) { return call( - mailtoUri: provider.mailtoUri, - originatingMessage: provider.originatingMessage, + account: provider.account, + mailbox: provider.mailbox, ); } @@ -660,175 +819,153 @@ class MailtoFamily extends Family { _allTransitiveDependencies; @override - String? get name => r'mailtoProvider'; + String? get name => r'firstTimeMailClientSourceProvider'; } -/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// Provides mail clients /// -/// Copied from [mailto]. -class MailtoProvider extends AutoDisposeProvider { - /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri +/// Copied from [firstTimeMailClientSource]. +class FirstTimeMailClientSourceProvider + extends AutoDisposeFutureProvider { + /// Provides mail clients /// - /// Copied from [mailto]. - MailtoProvider({ - required Uri mailtoUri, - required MimeMessage originatingMessage, + /// Copied from [firstTimeMailClientSource]. + FirstTimeMailClientSourceProvider({ + required RealAccount account, + Mailbox? mailbox, }) : this._internal( - (ref) => mailto( - ref as MailtoRef, - mailtoUri: mailtoUri, - originatingMessage: originatingMessage, + (ref) => firstTimeMailClientSource( + ref as FirstTimeMailClientSourceRef, + account: account, + mailbox: mailbox, ), - from: mailtoProvider, - name: r'mailtoProvider', + from: firstTimeMailClientSourceProvider, + name: r'firstTimeMailClientSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$mailtoHash, - dependencies: MailtoFamily._dependencies, - allTransitiveDependencies: MailtoFamily._allTransitiveDependencies, - mailtoUri: mailtoUri, - originatingMessage: originatingMessage, + : _$firstTimeMailClientSourceHash, + dependencies: FirstTimeMailClientSourceFamily._dependencies, + allTransitiveDependencies: + FirstTimeMailClientSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, ); - MailtoProvider._internal( + FirstTimeMailClientSourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.mailtoUri, - required this.originatingMessage, + required this.account, + required this.mailbox, }) : super.internal(); - final Uri mailtoUri; - final MimeMessage originatingMessage; + final RealAccount account; + final Mailbox? mailbox; @override Override overrideWith( - MessageBuilder Function(MailtoRef provider) create, + FutureOr Function(FirstTimeMailClientSourceRef provider) + create, ) { return ProviderOverride( origin: this, - override: MailtoProvider._internal( - (ref) => create(ref as MailtoRef), + override: FirstTimeMailClientSourceProvider._internal( + (ref) => create(ref as FirstTimeMailClientSourceRef), from: from, name: null, dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - mailtoUri: mailtoUri, - originatingMessage: originatingMessage, + account: account, + mailbox: mailbox, ), ); } @override - AutoDisposeProviderElement createElement() { - return _MailtoProviderElement(this); + AutoDisposeFutureProviderElement createElement() { + return _FirstTimeMailClientSourceProviderElement(this); } @override bool operator ==(Object other) { - return other is MailtoProvider && - other.mailtoUri == mailtoUri && - other.originatingMessage == originatingMessage; + return other is FirstTimeMailClientSourceProvider && + other.account == account && + other.mailbox == mailbox; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, mailtoUri.hashCode); - hash = _SystemHash.combine(hash, originatingMessage.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); return _SystemHash.finish(hash); } } -mixin MailtoRef on AutoDisposeProviderRef { - /// The parameter `mailtoUri` of this provider. - Uri get mailtoUri; +mixin FirstTimeMailClientSourceRef + on AutoDisposeFutureProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; - /// The parameter `originatingMessage` of this provider. - MimeMessage get originatingMessage; + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; } -class _MailtoProviderElement extends AutoDisposeProviderElement - with MailtoRef { - _MailtoProviderElement(super.provider); +class _FirstTimeMailClientSourceProviderElement + extends AutoDisposeFutureProviderElement + with FirstTimeMailClientSourceRef { + _FirstTimeMailClientSourceProviderElement(super.provider); @override - Uri get mailtoUri => (origin as MailtoProvider).mailtoUri; + RealAccount get account => + (origin as FirstTimeMailClientSourceProvider).account; @override - MimeMessage get originatingMessage => - (origin as MailtoProvider).originatingMessage; + Mailbox? get mailbox => (origin as FirstTimeMailClientSourceProvider).mailbox; } -String _$currentMailboxHash() => r'b11103d25c249597f26cf9342d17fa7e5e192359'; - -/// Provides the locally current active mailbox -/// -/// Copied from [currentMailbox]. -@ProviderFor(currentMailbox) -final currentMailboxProvider = AutoDisposeProvider.internal( - currentMailbox, - name: r'currentMailboxProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$currentMailboxHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef CurrentMailboxRef = AutoDisposeProviderRef; -String _$sourceHash() => r'ed4bfa87f9547328583d2c849f27a43200a6df1f'; - -abstract class _$Source extends BuildlessAsyncNotifier { - late final Account account; - late final Mailbox? mailbox; - - Future build({ - required Account account, - Mailbox? mailbox, - }); -} +String _$mailtoHash() => r'392c1cf4d13bff03113b564193f1f1b21099cdac'; -/// Provides the message source for the given account +/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri /// -/// Copied from [Source]. -@ProviderFor(Source) -const sourceProvider = SourceFamily(); +/// Copied from [mailto]. +@ProviderFor(mailto) +const mailtoProvider = MailtoFamily(); -/// Provides the message source for the given account +/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri /// -/// Copied from [Source]. -class SourceFamily extends Family> { - /// Provides the message source for the given account +/// Copied from [mailto]. +class MailtoFamily extends Family { + /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri /// - /// Copied from [Source]. - const SourceFamily(); + /// Copied from [mailto]. + const MailtoFamily(); - /// Provides the message source for the given account + /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri /// - /// Copied from [Source]. - SourceProvider call({ - required Account account, - Mailbox? mailbox, + /// Copied from [mailto]. + MailtoProvider call({ + required Uri mailtoUri, + required MimeMessage originatingMessage, }) { - return SourceProvider( - account: account, - mailbox: mailbox, + return MailtoProvider( + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, ); } @override - SourceProvider getProviderOverride( - covariant SourceProvider provider, + MailtoProvider getProviderOverride( + covariant MailtoProvider provider, ) { return call( - account: provider.account, - mailbox: provider.mailbox, + mailtoUri: provider.mailtoUri, + originatingMessage: provider.originatingMessage, ); } @@ -844,341 +981,171 @@ class SourceFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'sourceProvider'; + String? get name => r'mailtoProvider'; } -/// Provides the message source for the given account +/// Creates a new [MessageBuilder] based on the given [mailtoUri] uri /// -/// Copied from [Source]. -class SourceProvider extends AsyncNotifierProviderImpl { - /// Provides the message source for the given account +/// Copied from [mailto]. +class MailtoProvider extends AutoDisposeProvider { + /// Creates a new [MessageBuilder] based on the given [mailtoUri] uri /// - /// Copied from [Source]. - SourceProvider({ - required Account account, - Mailbox? mailbox, + /// Copied from [mailto]. + MailtoProvider({ + required Uri mailtoUri, + required MimeMessage originatingMessage, }) : this._internal( - () => Source() - ..account = account - ..mailbox = mailbox, - from: sourceProvider, - name: r'sourceProvider', + (ref) => mailto( + ref as MailtoRef, + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, + ), + from: mailtoProvider, + name: r'mailtoProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$sourceHash, - dependencies: SourceFamily._dependencies, - allTransitiveDependencies: SourceFamily._allTransitiveDependencies, - account: account, - mailbox: mailbox, + : _$mailtoHash, + dependencies: MailtoFamily._dependencies, + allTransitiveDependencies: MailtoFamily._allTransitiveDependencies, + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, ); - SourceProvider._internal( + MailtoProvider._internal( super._createNotifier, { required super.name, required super.dependencies, required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, - required this.account, - required this.mailbox, + required this.mailtoUri, + required this.originatingMessage, }) : super.internal(); - final Account account; - final Mailbox? mailbox; + final Uri mailtoUri; + final MimeMessage originatingMessage; @override - Future runNotifierBuild( - covariant Source notifier, + Override overrideWith( + MessageBuilder Function(MailtoRef provider) create, ) { - return notifier.build( - account: account, - mailbox: mailbox, - ); - } - - @override - Override overrideWith(Source Function() create) { return ProviderOverride( origin: this, - override: SourceProvider._internal( - () => create() - ..account = account - ..mailbox = mailbox, + override: MailtoProvider._internal( + (ref) => create(ref as MailtoRef), from: from, name: null, dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, - account: account, - mailbox: mailbox, + mailtoUri: mailtoUri, + originatingMessage: originatingMessage, ), ); } @override - AsyncNotifierProviderElement createElement() { - return _SourceProviderElement(this); + AutoDisposeProviderElement createElement() { + return _MailtoProviderElement(this); } @override bool operator ==(Object other) { - return other is SourceProvider && - other.account == account && - other.mailbox == mailbox; + return other is MailtoProvider && + other.mailtoUri == mailtoUri && + other.originatingMessage == originatingMessage; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, account.hashCode); - hash = _SystemHash.combine(hash, mailbox.hashCode); + hash = _SystemHash.combine(hash, mailtoUri.hashCode); + hash = _SystemHash.combine(hash, originatingMessage.hashCode); return _SystemHash.finish(hash); } } -mixin SourceRef on AsyncNotifierProviderRef { - /// The parameter `account` of this provider. - Account get account; +mixin MailtoRef on AutoDisposeProviderRef { + /// The parameter `mailtoUri` of this provider. + Uri get mailtoUri; - /// The parameter `mailbox` of this provider. - Mailbox? get mailbox; + /// The parameter `originatingMessage` of this provider. + MimeMessage get originatingMessage; } -class _SourceProviderElement - extends AsyncNotifierProviderElement with SourceRef { - _SourceProviderElement(super.provider); +class _MailtoProviderElement extends AutoDisposeProviderElement + with MailtoRef { + _MailtoProviderElement(super.provider); @override - Account get account => (origin as SourceProvider).account; + Uri get mailtoUri => (origin as MailtoProvider).mailtoUri; @override - Mailbox? get mailbox => (origin as SourceProvider).mailbox; -} - -String _$unifiedSourceHash() => r'99774ff4963680842bdf0e538d5c4b8554bac75c'; - -abstract class _$UnifiedSource - extends BuildlessAsyncNotifier { - late final UnifiedAccount account; - late final Mailbox? mailbox; - - Future build({ - required UnifiedAccount account, - Mailbox? mailbox, - }); + MimeMessage get originatingMessage => + (origin as MailtoProvider).originatingMessage; } -/// Provides the message source for the given account -/// -/// Copied from [UnifiedSource]. -@ProviderFor(UnifiedSource) -const unifiedSourceProvider = UnifiedSourceFamily(); - -/// Provides the message source for the given account -/// -/// Copied from [UnifiedSource]. -class UnifiedSourceFamily extends Family> { - /// Provides the message source for the given account - /// - /// Copied from [UnifiedSource]. - const UnifiedSourceFamily(); - - /// Provides the message source for the given account - /// - /// Copied from [UnifiedSource]. - UnifiedSourceProvider call({ - required UnifiedAccount account, - Mailbox? mailbox, - }) { - return UnifiedSourceProvider( - account: account, - mailbox: mailbox, - ); - } - - @override - UnifiedSourceProvider getProviderOverride( - covariant UnifiedSourceProvider provider, - ) { - return call( - account: provider.account, - mailbox: provider.mailbox, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'unifiedSourceProvider'; -} +String _$currentMailboxHash() => r'b11103d25c249597f26cf9342d17fa7e5e192359'; -/// Provides the message source for the given account +/// Provides the locally current active mailbox /// -/// Copied from [UnifiedSource]. -class UnifiedSourceProvider - extends AsyncNotifierProviderImpl { - /// Provides the message source for the given account - /// - /// Copied from [UnifiedSource]. - UnifiedSourceProvider({ - required UnifiedAccount account, - Mailbox? mailbox, - }) : this._internal( - () => UnifiedSource() - ..account = account - ..mailbox = mailbox, - from: unifiedSourceProvider, - name: r'unifiedSourceProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$unifiedSourceHash, - dependencies: UnifiedSourceFamily._dependencies, - allTransitiveDependencies: - UnifiedSourceFamily._allTransitiveDependencies, - account: account, - mailbox: mailbox, - ); - - UnifiedSourceProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.account, - required this.mailbox, - }) : super.internal(); - - final UnifiedAccount account; - final Mailbox? mailbox; - - @override - Future runNotifierBuild( - covariant UnifiedSource notifier, - ) { - return notifier.build( - account: account, - mailbox: mailbox, - ); - } - - @override - Override overrideWith(UnifiedSource Function() create) { - return ProviderOverride( - origin: this, - override: UnifiedSourceProvider._internal( - () => create() - ..account = account - ..mailbox = mailbox, - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - account: account, - mailbox: mailbox, - ), - ); - } - - @override - AsyncNotifierProviderElement - createElement() { - return _UnifiedSourceProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is UnifiedSourceProvider && - other.account == account && - other.mailbox == mailbox; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, account.hashCode); - hash = _SystemHash.combine(hash, mailbox.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin UnifiedSourceRef on AsyncNotifierProviderRef { - /// The parameter `account` of this provider. - UnifiedAccount get account; - - /// The parameter `mailbox` of this provider. - Mailbox? get mailbox; -} - -class _UnifiedSourceProviderElement - extends AsyncNotifierProviderElement - with UnifiedSourceRef { - _UnifiedSourceProviderElement(super.provider); - - @override - UnifiedAccount get account => (origin as UnifiedSourceProvider).account; - @override - Mailbox? get mailbox => (origin as UnifiedSourceProvider).mailbox; -} +/// Copied from [currentMailbox]. +@ProviderFor(currentMailbox) +final currentMailboxProvider = AutoDisposeProvider.internal( + currentMailbox, + name: r'currentMailboxProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentMailboxHash, + dependencies: null, + allTransitiveDependencies: null, +); -String _$realSourceHash() => r'073627644194316dbd304481cad3a0f9306fcb8f'; +typedef CurrentMailboxRef = AutoDisposeProviderRef; +String _$sourceHash() => r'ed4bfa87f9547328583d2c849f27a43200a6df1f'; -abstract class _$RealSource - extends BuildlessAsyncNotifier { - late final RealAccount account; +abstract class _$Source extends BuildlessAsyncNotifier { + late final Account account; late final Mailbox? mailbox; - Future build({ - required RealAccount account, + Future build({ + required Account account, Mailbox? mailbox, }); } /// Provides the message source for the given account /// -/// Copied from [RealSource]. -@ProviderFor(RealSource) -const realSourceProvider = RealSourceFamily(); +/// Copied from [Source]. +@ProviderFor(Source) +const sourceProvider = SourceFamily(); /// Provides the message source for the given account /// -/// Copied from [RealSource]. -class RealSourceFamily extends Family> { +/// Copied from [Source]. +class SourceFamily extends Family> { /// Provides the message source for the given account /// - /// Copied from [RealSource]. - const RealSourceFamily(); + /// Copied from [Source]. + const SourceFamily(); /// Provides the message source for the given account /// - /// Copied from [RealSource]. - RealSourceProvider call({ - required RealAccount account, + /// Copied from [Source]. + SourceProvider call({ + required Account account, Mailbox? mailbox, }) { - return RealSourceProvider( + return SourceProvider( account: account, mailbox: mailbox, ); } @override - RealSourceProvider getProviderOverride( - covariant RealSourceProvider provider, + SourceProvider getProviderOverride( + covariant SourceProvider provider, ) { return call( account: provider.account, @@ -1198,38 +1165,36 @@ class RealSourceFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'realSourceProvider'; + String? get name => r'sourceProvider'; } /// Provides the message source for the given account /// -/// Copied from [RealSource]. -class RealSourceProvider - extends AsyncNotifierProviderImpl { +/// Copied from [Source]. +class SourceProvider extends AsyncNotifierProviderImpl { /// Provides the message source for the given account /// - /// Copied from [RealSource]. - RealSourceProvider({ - required RealAccount account, + /// Copied from [Source]. + SourceProvider({ + required Account account, Mailbox? mailbox, }) : this._internal( - () => RealSource() + () => Source() ..account = account ..mailbox = mailbox, - from: realSourceProvider, - name: r'realSourceProvider', + from: sourceProvider, + name: r'sourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$realSourceHash, - dependencies: RealSourceFamily._dependencies, - allTransitiveDependencies: - RealSourceFamily._allTransitiveDependencies, + : _$sourceHash, + dependencies: SourceFamily._dependencies, + allTransitiveDependencies: SourceFamily._allTransitiveDependencies, account: account, mailbox: mailbox, ); - RealSourceProvider._internal( + SourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -1240,12 +1205,12 @@ class RealSourceProvider required this.mailbox, }) : super.internal(); - final RealAccount account; + final Account account; final Mailbox? mailbox; @override - Future runNotifierBuild( - covariant RealSource notifier, + Future runNotifierBuild( + covariant Source notifier, ) { return notifier.build( account: account, @@ -1254,10 +1219,10 @@ class RealSourceProvider } @override - Override overrideWith(RealSource Function() create) { + Override overrideWith(Source Function() create) { return ProviderOverride( origin: this, - override: RealSourceProvider._internal( + override: SourceProvider._internal( () => create() ..account = account ..mailbox = mailbox, @@ -1273,14 +1238,13 @@ class RealSourceProvider } @override - AsyncNotifierProviderElement - createElement() { - return _RealSourceProviderElement(this); + AsyncNotifierProviderElement createElement() { + return _SourceProviderElement(this); } @override bool operator ==(Object other) { - return other is RealSourceProvider && + return other is SourceProvider && other.account == account && other.mailbox == mailbox; } @@ -1295,69 +1259,68 @@ class RealSourceProvider } } -mixin RealSourceRef on AsyncNotifierProviderRef { +mixin SourceRef on AsyncNotifierProviderRef { /// The parameter `account` of this provider. - RealAccount get account; + Account get account; /// The parameter `mailbox` of this provider. Mailbox? get mailbox; } -class _RealSourceProviderElement - extends AsyncNotifierProviderElement - with RealSourceRef { - _RealSourceProviderElement(super.provider); +class _SourceProviderElement + extends AsyncNotifierProviderElement with SourceRef { + _SourceProviderElement(super.provider); @override - RealAccount get account => (origin as RealSourceProvider).account; + Account get account => (origin as SourceProvider).account; @override - Mailbox? get mailbox => (origin as RealSourceProvider).mailbox; + Mailbox? get mailbox => (origin as SourceProvider).mailbox; } -String _$realMimeSourceHash() => r'a0faaeb48c887fc8a93351f1b8efb0d01c8fd366'; +String _$unifiedSourceHash() => r'99774ff4963680842bdf0e538d5c4b8554bac75c'; -abstract class _$RealMimeSource - extends BuildlessAsyncNotifier { - late final RealAccount account; +abstract class _$UnifiedSource + extends BuildlessAsyncNotifier { + late final UnifiedAccount account; late final Mailbox? mailbox; - Future build({ - required RealAccount account, + Future build({ + required UnifiedAccount account, Mailbox? mailbox, }); } /// Provides the message source for the given account /// -/// Copied from [RealMimeSource]. -@ProviderFor(RealMimeSource) -const realMimeSourceProvider = RealMimeSourceFamily(); +/// Copied from [UnifiedSource]. +@ProviderFor(UnifiedSource) +const unifiedSourceProvider = UnifiedSourceFamily(); /// Provides the message source for the given account /// -/// Copied from [RealMimeSource]. -class RealMimeSourceFamily extends Family> { +/// Copied from [UnifiedSource]. +class UnifiedSourceFamily extends Family> { /// Provides the message source for the given account /// - /// Copied from [RealMimeSource]. - const RealMimeSourceFamily(); + /// Copied from [UnifiedSource]. + const UnifiedSourceFamily(); /// Provides the message source for the given account /// - /// Copied from [RealMimeSource]. - RealMimeSourceProvider call({ - required RealAccount account, + /// Copied from [UnifiedSource]. + UnifiedSourceProvider call({ + required UnifiedAccount account, Mailbox? mailbox, }) { - return RealMimeSourceProvider( + return UnifiedSourceProvider( account: account, mailbox: mailbox, ); } @override - RealMimeSourceProvider getProviderOverride( - covariant RealMimeSourceProvider provider, + UnifiedSourceProvider getProviderOverride( + covariant UnifiedSourceProvider provider, ) { return call( account: provider.account, @@ -1377,38 +1340,38 @@ class RealMimeSourceFamily extends Family> { _allTransitiveDependencies; @override - String? get name => r'realMimeSourceProvider'; + String? get name => r'unifiedSourceProvider'; } /// Provides the message source for the given account /// -/// Copied from [RealMimeSource]. -class RealMimeSourceProvider - extends AsyncNotifierProviderImpl { +/// Copied from [UnifiedSource]. +class UnifiedSourceProvider + extends AsyncNotifierProviderImpl { /// Provides the message source for the given account /// - /// Copied from [RealMimeSource]. - RealMimeSourceProvider({ - required RealAccount account, + /// Copied from [UnifiedSource]. + UnifiedSourceProvider({ + required UnifiedAccount account, Mailbox? mailbox, }) : this._internal( - () => RealMimeSource() + () => UnifiedSource() ..account = account ..mailbox = mailbox, - from: realMimeSourceProvider, - name: r'realMimeSourceProvider', + from: unifiedSourceProvider, + name: r'unifiedSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$realMimeSourceHash, - dependencies: RealMimeSourceFamily._dependencies, + : _$unifiedSourceHash, + dependencies: UnifiedSourceFamily._dependencies, allTransitiveDependencies: - RealMimeSourceFamily._allTransitiveDependencies, + UnifiedSourceFamily._allTransitiveDependencies, account: account, mailbox: mailbox, ); - RealMimeSourceProvider._internal( + UnifiedSourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -1419,12 +1382,12 @@ class RealMimeSourceProvider required this.mailbox, }) : super.internal(); - final RealAccount account; + final UnifiedAccount account; final Mailbox? mailbox; @override - Future runNotifierBuild( - covariant RealMimeSource notifier, + Future runNotifierBuild( + covariant UnifiedSource notifier, ) { return notifier.build( account: account, @@ -1433,10 +1396,10 @@ class RealMimeSourceProvider } @override - Override overrideWith(RealMimeSource Function() create) { + Override overrideWith(UnifiedSource Function() create) { return ProviderOverride( origin: this, - override: RealMimeSourceProvider._internal( + override: UnifiedSourceProvider._internal( () => create() ..account = account ..mailbox = mailbox, @@ -1452,14 +1415,14 @@ class RealMimeSourceProvider } @override - AsyncNotifierProviderElement + AsyncNotifierProviderElement createElement() { - return _RealMimeSourceProviderElement(this); + return _UnifiedSourceProviderElement(this); } @override bool operator ==(Object other) { - return other is RealMimeSourceProvider && + return other is UnifiedSourceProvider && other.account == account && other.mailbox == mailbox; } @@ -1474,68 +1437,69 @@ class RealMimeSourceProvider } } -mixin RealMimeSourceRef on AsyncNotifierProviderRef { +mixin UnifiedSourceRef on AsyncNotifierProviderRef { /// The parameter `account` of this provider. - RealAccount get account; + UnifiedAccount get account; /// The parameter `mailbox` of this provider. Mailbox? get mailbox; } -class _RealMimeSourceProviderElement - extends AsyncNotifierProviderElement - with RealMimeSourceRef { - _RealMimeSourceProviderElement(super.provider); +class _UnifiedSourceProviderElement + extends AsyncNotifierProviderElement + with UnifiedSourceRef { + _UnifiedSourceProviderElement(super.provider); @override - RealAccount get account => (origin as RealMimeSourceProvider).account; + UnifiedAccount get account => (origin as UnifiedSourceProvider).account; @override - Mailbox? get mailbox => (origin as RealMimeSourceProvider).mailbox; + Mailbox? get mailbox => (origin as UnifiedSourceProvider).mailbox; } -String _$mailClientSourceHash() => r'd9c97325207816d3dadefe6e6afee06707af88b5'; +String _$realSourceHash() => r'3583e95ef8796fe9a9696ece4708c1aacf222964'; -abstract class _$MailClientSource extends BuildlessNotifier { +abstract class _$RealSource + extends BuildlessAsyncNotifier { late final RealAccount account; late final Mailbox? mailbox; - MailClient build({ + Future build({ required RealAccount account, Mailbox? mailbox, }); } -/// Provides mail clients +/// Provides the message source for the given account /// -/// Copied from [MailClientSource]. -@ProviderFor(MailClientSource) -const mailClientSourceProvider = MailClientSourceFamily(); +/// Copied from [RealSource]. +@ProviderFor(RealSource) +const realSourceProvider = RealSourceFamily(); -/// Provides mail clients +/// Provides the message source for the given account /// -/// Copied from [MailClientSource]. -class MailClientSourceFamily extends Family { - /// Provides mail clients +/// Copied from [RealSource]. +class RealSourceFamily extends Family> { + /// Provides the message source for the given account /// - /// Copied from [MailClientSource]. - const MailClientSourceFamily(); + /// Copied from [RealSource]. + const RealSourceFamily(); - /// Provides mail clients + /// Provides the message source for the given account /// - /// Copied from [MailClientSource]. - MailClientSourceProvider call({ + /// Copied from [RealSource]. + RealSourceProvider call({ required RealAccount account, Mailbox? mailbox, }) { - return MailClientSourceProvider( + return RealSourceProvider( account: account, mailbox: mailbox, ); } @override - MailClientSourceProvider getProviderOverride( - covariant MailClientSourceProvider provider, + RealSourceProvider getProviderOverride( + covariant RealSourceProvider provider, ) { return call( account: provider.account, @@ -1555,38 +1519,38 @@ class MailClientSourceFamily extends Family { _allTransitiveDependencies; @override - String? get name => r'mailClientSourceProvider'; + String? get name => r'realSourceProvider'; } -/// Provides mail clients +/// Provides the message source for the given account /// -/// Copied from [MailClientSource]. -class MailClientSourceProvider - extends NotifierProviderImpl { - /// Provides mail clients +/// Copied from [RealSource]. +class RealSourceProvider + extends AsyncNotifierProviderImpl { + /// Provides the message source for the given account /// - /// Copied from [MailClientSource]. - MailClientSourceProvider({ + /// Copied from [RealSource]. + RealSourceProvider({ required RealAccount account, Mailbox? mailbox, }) : this._internal( - () => MailClientSource() + () => RealSource() ..account = account ..mailbox = mailbox, - from: mailClientSourceProvider, - name: r'mailClientSourceProvider', + from: realSourceProvider, + name: r'realSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$mailClientSourceHash, - dependencies: MailClientSourceFamily._dependencies, + : _$realSourceHash, + dependencies: RealSourceFamily._dependencies, allTransitiveDependencies: - MailClientSourceFamily._allTransitiveDependencies, + RealSourceFamily._allTransitiveDependencies, account: account, mailbox: mailbox, ); - MailClientSourceProvider._internal( + RealSourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -1601,8 +1565,8 @@ class MailClientSourceProvider final Mailbox? mailbox; @override - MailClient runNotifierBuild( - covariant MailClientSource notifier, + Future runNotifierBuild( + covariant RealSource notifier, ) { return notifier.build( account: account, @@ -1611,10 +1575,10 @@ class MailClientSourceProvider } @override - Override overrideWith(MailClientSource Function() create) { + Override overrideWith(RealSource Function() create) { return ProviderOverride( origin: this, - override: MailClientSourceProvider._internal( + override: RealSourceProvider._internal( () => create() ..account = account ..mailbox = mailbox, @@ -1630,13 +1594,14 @@ class MailClientSourceProvider } @override - NotifierProviderElement createElement() { - return _MailClientSourceProviderElement(this); + AsyncNotifierProviderElement + createElement() { + return _RealSourceProviderElement(this); } @override bool operator ==(Object other) { - return other is MailClientSourceProvider && + return other is RealSourceProvider && other.account == account && other.mailbox == mailbox; } @@ -1651,7 +1616,7 @@ class MailClientSourceProvider } } -mixin MailClientSourceRef on NotifierProviderRef { +mixin RealSourceRef on AsyncNotifierProviderRef { /// The parameter `account` of this provider. RealAccount get account; @@ -1659,26 +1624,24 @@ mixin MailClientSourceRef on NotifierProviderRef { Mailbox? get mailbox; } -class _MailClientSourceProviderElement - extends NotifierProviderElement - with MailClientSourceRef { - _MailClientSourceProviderElement(super.provider); +class _RealSourceProviderElement + extends AsyncNotifierProviderElement + with RealSourceRef { + _RealSourceProviderElement(super.provider); @override - RealAccount get account => (origin as MailClientSourceProvider).account; + RealAccount get account => (origin as RealSourceProvider).account; @override - Mailbox? get mailbox => (origin as MailClientSourceProvider).mailbox; + Mailbox? get mailbox => (origin as RealSourceProvider).mailbox; } -String _$firstTimeMailClientSourceHash() => - r'b0c0d4b9bf0d46a2f8bd4d2fd272dace35e279cf'; +String _$mailClientSourceHash() => r'd9c97325207816d3dadefe6e6afee06707af88b5'; -abstract class _$FirstTimeMailClientSource - extends BuildlessAutoDisposeAsyncNotifier { +abstract class _$MailClientSource extends BuildlessNotifier { late final RealAccount account; late final Mailbox? mailbox; - Future build({ + MailClient build({ required RealAccount account, Mailbox? mailbox, }); @@ -1686,36 +1649,35 @@ abstract class _$FirstTimeMailClientSource /// Provides mail clients /// -/// Copied from [FirstTimeMailClientSource]. -@ProviderFor(FirstTimeMailClientSource) -const firstTimeMailClientSourceProvider = FirstTimeMailClientSourceFamily(); +/// Copied from [MailClientSource]. +@ProviderFor(MailClientSource) +const mailClientSourceProvider = MailClientSourceFamily(); /// Provides mail clients /// -/// Copied from [FirstTimeMailClientSource]. -class FirstTimeMailClientSourceFamily - extends Family> { +/// Copied from [MailClientSource]. +class MailClientSourceFamily extends Family { /// Provides mail clients /// - /// Copied from [FirstTimeMailClientSource]. - const FirstTimeMailClientSourceFamily(); + /// Copied from [MailClientSource]. + const MailClientSourceFamily(); /// Provides mail clients /// - /// Copied from [FirstTimeMailClientSource]. - FirstTimeMailClientSourceProvider call({ + /// Copied from [MailClientSource]. + MailClientSourceProvider call({ required RealAccount account, Mailbox? mailbox, }) { - return FirstTimeMailClientSourceProvider( + return MailClientSourceProvider( account: account, mailbox: mailbox, ); } @override - FirstTimeMailClientSourceProvider getProviderOverride( - covariant FirstTimeMailClientSourceProvider provider, + MailClientSourceProvider getProviderOverride( + covariant MailClientSourceProvider provider, ) { return call( account: provider.account, @@ -1735,39 +1697,38 @@ class FirstTimeMailClientSourceFamily _allTransitiveDependencies; @override - String? get name => r'firstTimeMailClientSourceProvider'; + String? get name => r'mailClientSourceProvider'; } /// Provides mail clients /// -/// Copied from [FirstTimeMailClientSource]. -class FirstTimeMailClientSourceProvider - extends AutoDisposeAsyncNotifierProviderImpl { +/// Copied from [MailClientSource]. +class MailClientSourceProvider + extends NotifierProviderImpl { /// Provides mail clients /// - /// Copied from [FirstTimeMailClientSource]. - FirstTimeMailClientSourceProvider({ + /// Copied from [MailClientSource]. + MailClientSourceProvider({ required RealAccount account, Mailbox? mailbox, }) : this._internal( - () => FirstTimeMailClientSource() + () => MailClientSource() ..account = account ..mailbox = mailbox, - from: firstTimeMailClientSourceProvider, - name: r'firstTimeMailClientSourceProvider', + from: mailClientSourceProvider, + name: r'mailClientSourceProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$firstTimeMailClientSourceHash, - dependencies: FirstTimeMailClientSourceFamily._dependencies, + : _$mailClientSourceHash, + dependencies: MailClientSourceFamily._dependencies, allTransitiveDependencies: - FirstTimeMailClientSourceFamily._allTransitiveDependencies, + MailClientSourceFamily._allTransitiveDependencies, account: account, mailbox: mailbox, ); - FirstTimeMailClientSourceProvider._internal( + MailClientSourceProvider._internal( super._createNotifier, { required super.name, required super.dependencies, @@ -1782,8 +1743,8 @@ class FirstTimeMailClientSourceProvider final Mailbox? mailbox; @override - Future runNotifierBuild( - covariant FirstTimeMailClientSource notifier, + MailClient runNotifierBuild( + covariant MailClientSource notifier, ) { return notifier.build( account: account, @@ -1792,10 +1753,10 @@ class FirstTimeMailClientSourceProvider } @override - Override overrideWith(FirstTimeMailClientSource Function() create) { + Override overrideWith(MailClientSource Function() create) { return ProviderOverride( origin: this, - override: FirstTimeMailClientSourceProvider._internal( + override: MailClientSourceProvider._internal( () => create() ..account = account ..mailbox = mailbox, @@ -1811,14 +1772,13 @@ class FirstTimeMailClientSourceProvider } @override - AutoDisposeAsyncNotifierProviderElement createElement() { - return _FirstTimeMailClientSourceProviderElement(this); + NotifierProviderElement createElement() { + return _MailClientSourceProviderElement(this); } @override bool operator ==(Object other) { - return other is FirstTimeMailClientSourceProvider && + return other is MailClientSourceProvider && other.account == account && other.mailbox == mailbox; } @@ -1833,8 +1793,7 @@ class FirstTimeMailClientSourceProvider } } -mixin FirstTimeMailClientSourceRef - on AutoDisposeAsyncNotifierProviderRef { +mixin MailClientSourceRef on NotifierProviderRef { /// The parameter `account` of this provider. RealAccount get account; @@ -1842,16 +1801,15 @@ mixin FirstTimeMailClientSourceRef Mailbox? get mailbox; } -class _FirstTimeMailClientSourceProviderElement - extends AutoDisposeAsyncNotifierProviderElement with FirstTimeMailClientSourceRef { - _FirstTimeMailClientSourceProviderElement(super.provider); +class _MailClientSourceProviderElement + extends NotifierProviderElement + with MailClientSourceRef { + _MailClientSourceProviderElement(super.provider); @override - RealAccount get account => - (origin as FirstTimeMailClientSourceProvider).account; + RealAccount get account => (origin as MailClientSourceProvider).account; @override - Mailbox? get mailbox => (origin as FirstTimeMailClientSourceProvider).mailbox; + Mailbox? get mailbox => (origin as MailClientSourceProvider).mailbox; } // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index 5fbbcd0..4c370e0 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -8,9 +8,16 @@ import '../util/indexed_cache.dart'; /// Let other classes get notified about changes in a mime source abstract class MimeSourceSubscriber { + /// Notifies about a single new message void onMailArrived(MimeMessage mime, AsyncMimeSource source, {int index = 0}); + + /// Notifies about a single removed message void onMailVanished(MimeMessage mime, AsyncMimeSource source); + + /// Notifies about a flags change for a single message void onMailFlagsUpdated(MimeMessage mime, AsyncMimeSource source); + + /// Notifies about the required to reload the cache void onMailCacheInvalidated(AsyncMimeSource source); } @@ -238,6 +245,8 @@ abstract class CachedMimeSource extends AsyncMimeSource { /// Creates a new cached mime source CachedMimeSource({int maxCacheSize = IndexedCache.defaultMaxCacheSize}) : cache = IndexedCache(maxCacheSize: maxCacheSize); + + /// The cache for the received mime messages final IndexedCache cache; @override diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 2168716..71c8adc 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -5,8 +5,9 @@ import 'package:flutter/foundation.dart'; import '../account/model.dart'; import '../locator.dart'; import '../logger.dart'; +import '../notification/model.dart'; +import '../notification/service.dart'; import '../services/i18n_service.dart'; -import '../services/notification_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../util/indexed_cache.dart'; import 'async_mime_source.dart'; diff --git a/lib/notification/model.dart b/lib/notification/model.dart new file mode 100644 index 0000000..6de7355 --- /dev/null +++ b/lib/notification/model.dart @@ -0,0 +1,64 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'model.g.dart'; + +/// The result of the notification service initialization +enum NotificationServiceInitResult { + /// App was launched by a notification + appLaunchedByNotification, + + /// App was launched normally + normal, +} + +/// Details to identify a mail message in a notification +@JsonSerializable() +class MailNotificationPayload { + /// Creates a new payload + const MailNotificationPayload({ + required this.guid, + required this.uid, + required this.sequenceId, + required this.accountEmail, + required this.subject, + required this.size, + }); + + /// Creates a new payload from the given [mimeMessage] + MailNotificationPayload.fromMail( + MimeMessage mimeMessage, + this.accountEmail, + ) : uid = mimeMessage.uid ?? 0, + guid = mimeMessage.guid ?? 0, + sequenceId = mimeMessage.sequenceId ?? 0, + subject = mimeMessage.decodeSubject() ?? '', + size = mimeMessage.size ?? 0; + + /// Creates a new payload from the given [json] + factory MailNotificationPayload.fromJson(Map json) => + _$MailNotificationPayloadFromJson(json); + + /// The global unique identifier of the message + final int guid; + + /// The unique identifier of the message + final int uid; + + /// The sequence id of the message + @JsonKey(name: 'id') + final int sequenceId; + + /// The email address of the account + @JsonKey(name: 'account-email') + final String accountEmail; + + /// The subject of the message + final String subject; + + /// The size of the message + final int size; + + /// Creates JSON from this payoad + Map toJson() => _$MailNotificationPayloadToJson(this); +} diff --git a/lib/services/notification_service.g.dart b/lib/notification/model.g.dart similarity index 96% rename from lib/services/notification_service.g.dart rename to lib/notification/model.g.dart index 4498cfb..220a617 100644 --- a/lib/services/notification_service.g.dart +++ b/lib/notification/model.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'notification_service.dart'; +part of 'model.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/services/notification_service.dart b/lib/notification/service.dart similarity index 84% rename from lib/services/notification_service.dart rename to lib/notification/service.dart index 860ea59..7b3f6c8 100644 --- a/lib/services/notification_service.dart +++ b/lib/notification/service.dart @@ -5,17 +5,19 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:go_router/go_router.dart'; -import 'package:json_annotation/json_annotation.dart'; import '../logger.dart'; import '../models/message.dart' as maily; import '../routes.dart'; +import 'model.dart'; -part 'notification_service.g.dart'; +class NotificationService { + NotificationService._(); + static final NotificationService _instance = NotificationService._(); -enum NotificationServiceInitResult { appLaunchedByNotification, normal } + /// Retrieves the instance of the notification service + static NotificationService get instance => _instance; -class NotificationService { static const String _messagePayloadStart = 'msg:'; final _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); @@ -227,43 +229,3 @@ class NotificationService { } } } - -/// Details to identify a mail message in a notification -@JsonSerializable() -class MailNotificationPayload { - /// Creates a new payload - const MailNotificationPayload({ - required this.guid, - required this.uid, - required this.sequenceId, - required this.accountEmail, - required this.subject, - required this.size, - }); - - /// Creates a new payload from the given [mimeMessage] - MailNotificationPayload.fromMail( - MimeMessage mimeMessage, - this.accountEmail, - ) : uid = mimeMessage.uid ?? 0, - guid = mimeMessage.guid ?? 0, - sequenceId = mimeMessage.sequenceId ?? 0, - subject = mimeMessage.decodeSubject() ?? '', - size = mimeMessage.size ?? 0; - - /// Creates a new payload from the given [json] - factory MailNotificationPayload.fromJson(Map json) => - _$MailNotificationPayloadFromJson(json); - - final int guid; - final int uid; - @JsonKey(name: 'id') - final int sequenceId; - @JsonKey(name: 'account-email') - final String accountEmail; - final String subject; - final int size; - - /// Creates JSON from this payoad - Map toJson() => _$MailNotificationPayloadToJson(this); -} diff --git a/lib/routes.dart b/lib/routes.dart index 291c520..7ba4a09 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -10,8 +10,8 @@ import 'package:go_router/go_router.dart'; import 'account/model.dart'; import 'main.dart'; import 'models/models.dart'; +import 'notification/model.dart'; import 'screens/screens.dart'; -import 'services/notification_service.dart'; import 'settings/view/view.dart'; import 'widgets/app_drawer.dart'; diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index 2121e00..f64f28e 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -17,10 +17,10 @@ import '../mail/provider.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; import '../models/message_source.dart'; +import '../notification/service.dart'; import '../routes.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; -import '../services/notification_service.dart'; import '../settings/model.dart'; import '../settings/provider.dart'; import '../util/localized_dialog_helper.dart'; diff --git a/lib/screens/message_details_screen_for_notification.dart b/lib/screens/message_details_screen_for_notification.dart index fe6646c..69a3a89 100644 --- a/lib/screens/message_details_screen_for_notification.dart +++ b/lib/screens/message_details_screen_for_notification.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../mail/provider.dart'; -import '../services/notification_service.dart'; +import '../notification/model.dart'; import 'message_details_screen.dart'; /// Displays the message details for a notification diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index fe7222b..f4b4640 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -16,11 +16,11 @@ import '../models/date_sectioned_message_source.dart'; import '../models/message.dart'; import '../models/message_source.dart'; import '../models/swipe.dart'; +import '../notification/service.dart'; import '../routes.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; import '../services/navigation_service.dart'; -import '../services/notification_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/provider.dart'; import '../util/localized_dialog_helper.dart'; diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index c19fb82..37091a4 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -9,8 +9,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../locator.dart'; import '../models/async_mime_source_factory.dart'; import '../models/background_update_info.dart'; +import '../notification/service.dart'; import 'mail_service.dart'; -import 'notification_service.dart'; class BackgroundService { static const String _keyInboxUids = 'nextUidsInfo'; @@ -128,7 +128,7 @@ class BackgroundService { const AsyncMimeSourceFactory(isOfflineModeSupported: false), ); final accounts = await mailService.loadRealMailAccounts(); - final notificationService = NotificationService(); + final notificationService = NotificationService.instance; await notificationService.init(checkForLaunchDetails: false); // final activeMailNotifications = // await notificationService.getActiveMailNotifications(); diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart index 151f28a..8edbb7e 100644 --- a/lib/services/mail_service.dart +++ b/lib/services/mail_service.dart @@ -13,11 +13,11 @@ import '../models/async_mime_source.dart'; import '../models/async_mime_source_factory.dart'; import '../models/message_source.dart'; import '../models/sender.dart'; +import '../notification/service.dart'; import '../routes.dart'; import '../settings/model.dart'; import '../util/gravatar.dart'; import 'navigation_service.dart'; -import 'notification_service.dart'; import 'providers.dart'; class MailService implements MimeSourceSubscriber { diff --git a/lib/widgets/message_actions.dart b/lib/widgets/message_actions.dart index f853951..2cea274 100644 --- a/lib/widgets/message_actions.dart +++ b/lib/widgets/message_actions.dart @@ -10,11 +10,11 @@ import '../localization/extension.dart'; import '../locator.dart'; import '../models/compose_data.dart'; import '../models/message.dart'; +import '../notification/service.dart'; import '../routes.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; import '../services/navigation_service.dart'; -import '../services/notification_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/model.dart'; import '../settings/provider.dart'; diff --git a/test/model/fake_mime_source.dart b/test/model/fake_mime_source.dart index 961e92f..8b257f9 100644 --- a/test/model/fake_mime_source.dart +++ b/test/model/fake_mime_source.dart @@ -34,11 +34,12 @@ class FakeMimeSource extends PagedCachedMimeSource { final Duration _differencePerMessage; List messages = []; - static List generateMessages( - {required int size, - String name = '', - DateTime? startDate, - Duration? differencePerMessage}) { + static List generateMessages({ + required int size, + String name = '', + DateTime? startDate, + Duration? differencePerMessage, + }) { final messages = []; for (int i = size; --i >= 0;) { messages.add( @@ -268,4 +269,8 @@ class FakeMimeSource extends PagedCachedMimeSource { // TODO: implement sendMessage throw UnimplementedError(); } + + @override + // TODO: implement mailbox + Mailbox get mailbox => throw UnimplementedError(); } diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index 008d300..2146aa3 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -3,7 +3,8 @@ import 'package:enough_mail_app/account/model.dart'; import 'package:enough_mail_app/models/async_mime_source.dart'; import 'package:enough_mail_app/models/message.dart'; import 'package:enough_mail_app/models/message_source.dart'; -import 'package:enough_mail_app/services/notification_service.dart'; +import 'package:enough_mail_app/notification/model.dart'; +import 'package:enough_mail_app/notification/service.dart'; import 'package:enough_mail_app/services/scaffold_messenger_service.dart'; import 'package:enough_mail_app/widgets/cupertino_status_bar.dart'; import 'package:flutter/src/material/scaffold.dart'; @@ -1217,15 +1218,22 @@ class TestNotificationService implements NotificationService { } @override - Future init( - {bool checkForLaunchDetails = true}) { + Future init({ + bool checkForLaunchDetails = true, + }) { // TODO: implement init throw UnimplementedError(); } @override - Future sendLocalNotification(int id, String title, String? text, - {String? payloadText, DateTime? when, bool channelShowBadge = true}) { + Future sendLocalNotification( + int id, + String title, + String? text, { + String? payloadText, + DateTime? when, + bool channelShowBadge = true, + }) { _sendNotifications++; return Future.value(); } From 89690084c08da6061a8dc65addf5feabe6f1f542 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 12:24:40 +0200 Subject: [PATCH 11/95] feat: re-enable message flag updates --- lib/models/async_mime_source.dart | 11 +++++++-- lib/models/message_source.dart | 37 ++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index 4c370e0..0518ca3 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -348,8 +348,15 @@ abstract class CachedMimeSource extends AsyncMimeSource { @override Future onMessageFlagsUpdated(MimeMessage message) { - final existing = - cache.firstWhereOrNull((element) => element.guid == message.guid); + final guid = message.guid; + final sequenceId = message.sequenceId; + final existing = guid != null + ? cache.firstWhereOrNull( + (element) => element.guid == guid, + ) + : cache.firstWhereOrNull( + (element) => element.sequenceId == sequenceId, + ); if (existing != null) { existing.flags = message.flags; } diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 71c8adc..38c158d 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -144,7 +144,7 @@ abstract class MessageSource extends ChangeNotifier final parent = _parentMessageSource; if (parent != null) { final mime = message.mimeMessage; - parent.removeMime(mime); + parent.removeMime(mime, getMimeSource(message)); } if (removed && notify) { notifyListeners(); @@ -155,7 +155,7 @@ abstract class MessageSource extends ChangeNotifier @override void onMailFlagsUpdated(MimeMessage mime, AsyncMimeSource source) { - final message = cache.getWithMime(mime); + final message = cache.getWithMime(mime, source); if (message != null) { message.updateFlags(mime.flags); } @@ -163,7 +163,7 @@ abstract class MessageSource extends ChangeNotifier @override void onMailVanished(MimeMessage mime, AsyncMimeSource source) { - final message = cache.getWithMime(mime); + final message = cache.getWithMime(mime, source); if (message != null) { removeFromCache(message); @@ -414,7 +414,7 @@ abstract class MessageSource extends ChangeNotifier } msg.isSeen = isSeen; final parent = _parentMessageSource; - final parentMsg = parent?.cache.getWithMime(msg.mimeMessage); + final parentMsg = parent?.cache.getWithMime(msg.mimeMessage, source); if (parent != null && parentMsg != null) { return parent.markAsSeen(parentMsg, isSeen); } @@ -430,7 +430,8 @@ abstract class MessageSource extends ChangeNotifier msg.isSeen = isSeen; final parent = _parentMessageSource; if (parent != null) { - final parentMsg = parent.cache.getWithMime(msg.mimeMessage); + final parentMsg = + parent.cache.getWithMime(msg.mimeMessage, getMimeSource(msg)); if (parentMsg != null) { parent.onMarkedAsSeen(parentMsg, isSeen); } @@ -451,7 +452,10 @@ abstract class MessageSource extends ChangeNotifier msg.isFlagged = isFlagged; final parent = _parentMessageSource; if (parent != null) { - final parentMsg = parent.cache.getWithMime(msg.mimeMessage); + final parentMsg = parent.cache.getWithMime( + msg.mimeMessage, + getMimeSource(msg), + ); if (parentMsg != null) { parent.onMarkedAsFlagged(parentMsg, isFlagged); } @@ -541,8 +545,8 @@ abstract class MessageSource extends ChangeNotifier MessageSource search(MailSearch search); - void removeMime(MimeMessage mimeMessage) { - final existingMessage = cache.getWithMime(mimeMessage); + void removeMime(MimeMessage mimeMessage, AsyncMimeSource? mimeSource) { + final existingMessage = cache.getWithMime(mimeMessage, mimeSource); if (existingMessage != null) { removeFromCache(existingMessage); } @@ -688,7 +692,7 @@ class MailboxMessageSource extends MessageSource { if (parent != null) { for (final removedMessage in removedMessages) { final mime = removedMessage.mimeMessage; - parent.removeMime(mime); + parent.removeMime(mime, getMimeSource(removedMessage)); } } @@ -937,7 +941,10 @@ class MultipleMessageSource extends MessageSource { final parent = _parentMessageSource; if (parent != null) { for (final removedMessage in removedMessages) { - parent.removeMime(removedMessage.mimeMessage); + parent.removeMime( + removedMessage.mimeMessage, + getMimeSource(removedMessage), + ); } } final futureResults = await Future.wait(futures); @@ -1303,13 +1310,21 @@ class ListMessageSource extends MessageSource { // } extension _ExtensionsOnMessageIndexedCache on IndexedCache { - Message? getWithMime(MimeMessage mime) { + Message? getWithMime(MimeMessage mime, AsyncMimeSource? mimeSource) { final guid = mime.guid; if (guid != null) { return firstWhereOrNull( (msg) => msg.mimeMessage.guid == guid, ); } + final sequenceId = mime.sequenceId; + if (sequenceId != null) { + return firstWhereOrNull( + (msg) => + msg.mimeMessage.sequenceId == sequenceId && + msg.source.getMimeSource(msg) == mimeSource, + ); + } return null; } From a69cf2d372a95a11bd5e7a9e19a9e2bdf5383161 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 17:10:35 +0200 Subject: [PATCH 12/95] chore: simplify theme handling --- lib/settings/theme/model.dart | 12 ++ lib/settings/theme/provider.dart | 53 ++------- lib/settings/theme/provider.g.dart | 184 +++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 44 deletions(-) create mode 100644 lib/settings/theme/provider.g.dart diff --git a/lib/settings/theme/model.dart b/lib/settings/theme/model.dart index 71e9911..37acbd6 100644 --- a/lib/settings/theme/model.dart +++ b/lib/settings/theme/model.dart @@ -145,6 +145,7 @@ Color _colorFromJson(Map json) { } //// The actually applied theme data +@immutable class ThemeSettingsData { /// Creates the theme data const ThemeSettingsData({ @@ -165,4 +166,15 @@ class ThemeSettingsData { /// The (material) theme mode final ThemeMode themeMode; + + @override + int get hashCode => + darkTheme.hashCode ^ lightTheme.hashCode ^ themeMode.hashCode; + + @override + bool operator ==(Object other) => + other is ThemeSettingsData && + other.darkTheme == darkTheme && + other.lightTheme == lightTheme && + other.themeMode == themeMode; } diff --git a/lib/settings/theme/provider.dart b/lib/settings/theme/provider.dart index 5df03fe..709fdb1 100644 --- a/lib/settings/theme/provider.dart +++ b/lib/settings/theme/provider.dart @@ -5,30 +5,25 @@ import '../../app_lifecycle/provider.dart'; import '../provider.dart'; import 'model.dart'; -/// Provides the settings -final themeProvider = - NotifierProvider(ThemeNotifier.new); +part 'provider.g.dart'; /// Provides the settings -class ThemeNotifier extends Notifier { - /// Creates a [ThemeNotifier] - ThemeNotifier(); - +@Riverpod(keepAlive: true) +class ThemeFinder extends _$ThemeFinder { @override - ThemeSettingsData build() { + ThemeSettingsData build({required BuildContext context}) { final themeSettings = ref.watch( settingsProvider.select((value) => value.themeSettings), ); - final isResumed = ref.watch( - appLifecycleStateProvider - .select((value) => value == AppLifecycleState.resumed), + ref.watch( + appLifecycleStateProvider.select( + (value) => value == AppLifecycleState.resumed, + ), ); - if (!isResumed) { - return state; - } return _fromThemeSettings( themeSettings, + context: context, ); } @@ -56,13 +51,6 @@ class ThemeNotifier extends Notifier { /// The default dark theme static final ThemeData defaultDarkTheme = _generateTheme(Brightness.dark, Colors.green); - ThemeData _lightTheme = defaultLightTheme; - ThemeData get lightTheme => _lightTheme; - ThemeData _darkTheme = defaultDarkTheme; - ThemeData get darkTheme => _darkTheme; - ThemeMode _themeMode = ThemeMode.system; - ThemeMode get themeMode => _themeMode; - Color _colorSchemeSeed = Colors.green; static Brightness _resolveBrightness( ThemeMode mode, @@ -80,12 +68,6 @@ class ThemeNotifier extends Notifier { } } - /// Initializes this theme notifier - void init(BuildContext context) { - final themeSettings = ref.read(settingsProvider).themeSettings; - state = _fromThemeSettings(themeSettings, context: context); - } - static ThemeData _generateTheme(Brightness brightness, Color color) => color is MaterialColor ? ThemeData( @@ -98,21 +80,4 @@ class ThemeNotifier extends Notifier { colorSchemeSeed: color, useMaterial3: true, ); - - void checkForChangedTheme(ThemeSettings settings) { - var isChanged = false; - final mode = settings.getCurrentThemeMode(); - if (mode != _themeMode) { - _themeMode = mode; - isChanged = true; - } - final colorSchemeSeed = settings.colorSchemeSeed; - if (colorSchemeSeed != _colorSchemeSeed) { - _colorSchemeSeed = colorSchemeSeed; - _lightTheme = _generateTheme(Brightness.light, colorSchemeSeed); - _darkTheme = _generateTheme(Brightness.dark, colorSchemeSeed); - isChanged = true; - } - if (isChanged) {} - } } diff --git a/lib/settings/theme/provider.g.dart b/lib/settings/theme/provider.g.dart new file mode 100644 index 0000000..e43b2a2 --- /dev/null +++ b/lib/settings/theme/provider.g.dart @@ -0,0 +1,184 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$themeFinderHash() => r'3c7a361f31581da0b35c5112fc6f959c7c00e154'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ThemeFinder extends BuildlessNotifier { + late final BuildContext context; + + ThemeSettingsData build({ + required BuildContext context, + }); +} + +/// Provides the settings +/// +/// Copied from [ThemeFinder]. +@ProviderFor(ThemeFinder) +const themeFinderProvider = ThemeFinderFamily(); + +/// Provides the settings +/// +/// Copied from [ThemeFinder]. +class ThemeFinderFamily extends Family { + /// Provides the settings + /// + /// Copied from [ThemeFinder]. + const ThemeFinderFamily(); + + /// Provides the settings + /// + /// Copied from [ThemeFinder]. + ThemeFinderProvider call({ + required BuildContext context, + }) { + return ThemeFinderProvider( + context: context, + ); + } + + @override + ThemeFinderProvider getProviderOverride( + covariant ThemeFinderProvider provider, + ) { + return call( + context: provider.context, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'themeFinderProvider'; +} + +/// Provides the settings +/// +/// Copied from [ThemeFinder]. +class ThemeFinderProvider + extends NotifierProviderImpl { + /// Provides the settings + /// + /// Copied from [ThemeFinder]. + ThemeFinderProvider({ + required BuildContext context, + }) : this._internal( + () => ThemeFinder()..context = context, + from: themeFinderProvider, + name: r'themeFinderProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$themeFinderHash, + dependencies: ThemeFinderFamily._dependencies, + allTransitiveDependencies: + ThemeFinderFamily._allTransitiveDependencies, + context: context, + ); + + ThemeFinderProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.context, + }) : super.internal(); + + final BuildContext context; + + @override + ThemeSettingsData runNotifierBuild( + covariant ThemeFinder notifier, + ) { + return notifier.build( + context: context, + ); + } + + @override + Override overrideWith(ThemeFinder Function() create) { + return ProviderOverride( + origin: this, + override: ThemeFinderProvider._internal( + () => create()..context = context, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + context: context, + ), + ); + } + + @override + NotifierProviderElement createElement() { + return _ThemeFinderProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ThemeFinderProvider && other.context == context; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, context.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin ThemeFinderRef on NotifierProviderRef { + /// The parameter `context` of this provider. + BuildContext get context; +} + +class _ThemeFinderProviderElement + extends NotifierProviderElement + with ThemeFinderRef { + _ThemeFinderProviderElement(super.provider); + + @override + BuildContext get context => (origin as ThemeFinderProvider).context; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member From cf7570ba8108d8a6c17f558bfa0c206074827791 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 17:10:54 +0200 Subject: [PATCH 13/95] feat: integrate mail client resume when app is resumed --- lib/mail/provider.dart | 33 ++++++++++++++++++++++++++------- lib/main.dart | 7 +++---- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index 1b67303..228ba00 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -1,9 +1,11 @@ import 'package:collection/collection.dart'; import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/widgets.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../account/model.dart'; import '../account/provider.dart'; +import '../app_lifecycle/provider.dart'; import '../models/async_mime_source.dart'; import '../models/message.dart'; import '../models/message_source.dart'; @@ -201,17 +203,34 @@ Future realMimeSource( /// Provides mail clients @Riverpod(keepAlive: true) class MailClientSource extends _$MailClientSource { + MailClient? _existingClient; + @override MailClient build({ required RealAccount account, Mailbox? mailbox, - }) => - EmailService.instance.createMailClient( - account.mailAccount, - (mailAccount) => ref - .watch(realAccountsProvider.notifier) - .updateMailAccount(account, mailAccount), - ); + }) { + final isResumed = ref.watch(appLifecycleStateProvider.select( + (value) => value == AppLifecycleState.resumed, + )); + final client = _existingClient ?? + EmailService.instance.createMailClient( + account.mailAccount, + (mailAccount) => ref + .watch(realAccountsProvider.notifier) + .updateMailAccount(account, mailAccount), + ); + final existingClient = _existingClient; + if (existingClient != null) { + if (isResumed) { + existingClient.resume(); + } + } else { + _existingClient = client; + } + + return client; + } /// Creates a new mailbox with the given [mailboxName] Future createMailbox( diff --git a/lib/main.dart b/lib/main.dart index 4188140..3177754 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,9 @@ void main() { ); } +/// Runs the app class MailyApp extends HookConsumerWidget { + /// Creates a new app const MailyApp({super.key}); @override @@ -39,7 +41,7 @@ class MailyApp extends HookConsumerWidget { ref.read(appLifecycleStateProvider.notifier).state = current; }); - final themeSettingsData = ref.watch(themeProvider); + final themeSettingsData = ref.watch(themeFinderProvider(context: context)); final languageTag = ref.watch(settingsProvider.select((settings) => settings.languageTag)); @@ -105,9 +107,6 @@ class _InitializationScreen extends ConsumerState { Future _initApp() async { await ref.read(settingsProvider.notifier).init(); - if (context.mounted) { - ref.read(themeProvider.notifier).init(context); - } await ref.read(realAccountsProvider.notifier).init(); final settings = ref.read(settingsProvider); From 72a810d8926f3271658da805a3ad0e4353aa0d6e Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 17:11:11 +0200 Subject: [PATCH 14/95] chore: improve code style --- lib/screens/compose_screen.dart | 50 +++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index 40d63ea..032761e 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -33,15 +33,6 @@ import '../widgets/button_text.dart'; import '../widgets/editor_extensions.dart'; import '../widgets/recipient_input_field.dart'; -class ComposeScreen extends ConsumerStatefulWidget { - const ComposeScreen({super.key, required this.data}); - - final ComposeData data; - - @override - ConsumerState createState() => _ComposeScreenState(); -} - enum _OverflowMenuChoice { showSourceCode, saveAsDraft, @@ -52,6 +43,18 @@ enum _OverflowMenuChoice { enum _Autofocus { to, subject, text } +/// Compose a new email message +class ComposeScreen extends ConsumerStatefulWidget { + /// Creates a new [ComposeScreen] with the given [ComposeData + const ComposeScreen({super.key, required this.data}); + + /// The initial data for composing the message + final ComposeData data; + + @override + ConsumerState createState() => _ComposeScreenState(); +} + class _ComposeScreenState extends ConsumerState { late List _toRecipients; late List _ccRecipients; @@ -270,12 +273,14 @@ class _ComposeScreenState extends ConsumerState { final multipartAlternativeBuilder = mb.hasAttachments ? mb.getPart(MediaSubtype.multipartAlternative, recursive: false) ?? mb.addPart( - mediaSubtype: MediaSubtype.multipartAlternative, - insert: true) + mediaSubtype: MediaSubtype.multipartAlternative, + insert: true, + ) : mb; if (!mb.hasAttachments) { mb.setContentType( - MediaType.fromSubtype(MediaSubtype.multipartAlternative)); + MediaType.fromSubtype(MediaSubtype.multipartAlternative), + ); } final plainTextBuilder = multipartAlternativeBuilder.getTextPlainPart(); if (plainTextBuilder != null) { @@ -388,9 +393,10 @@ class _ComposeScreenState extends ConsumerState { } try { await mailClient.store( - MessageSequence.fromMessage(originalMessage.mimeMessage), - originalMessage.mimeMessage.flags!, - action: StoreAction.replace); + MessageSequence.fromMessage(originalMessage.mimeMessage), + originalMessage.mimeMessage.flags!, + action: StoreAction.replace, + ); } catch (e, s) { if (kDebugMode) { print('Unable to update message flags: $e $s'); // otherwise ignore @@ -402,10 +408,11 @@ class _ComposeScreenState extends ConsumerState { // delete draft message: try { final originalMessage = widget.data.originalMessage!; - final source = originalMessage.source; - source.removeFromCache(originalMessage); - await mailClient.flagMessage(originalMessage.mimeMessage, - isDeleted: true); + originalMessage.source.removeFromCache(originalMessage); + await mailClient.flagMessage( + originalMessage.mimeMessage, + isDeleted: true, + ); } catch (e, s) { if (kDebugMode) { print('Unable to update message flags: $e $s'); // otherwise ignore @@ -485,8 +492,9 @@ class _ComposeScreenState extends ConsumerState { if (_composeMode == ComposeMode.html) PlatformPopupMenuItem<_OverflowMenuChoice>( value: _OverflowMenuChoice.convertToPlainTextEditor, - child: Text(localizations - .composeConvertToPlainTextEditorAction), + child: Text( + localizations.composeConvertToPlainTextEditorAction, + ), ) else PlatformPopupMenuItem<_OverflowMenuChoice>( From 6864bd7a71ddc640c3f965a315e1c9f794ce9dcd Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 20:01:24 +0200 Subject: [PATCH 15/95] chore: re-generate code --- lib/mail/provider.g.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart index b8ad552..7c4ffc2 100644 --- a/lib/mail/provider.g.dart +++ b/lib/mail/provider.g.dart @@ -1635,7 +1635,7 @@ class _RealSourceProviderElement Mailbox? get mailbox => (origin as RealSourceProvider).mailbox; } -String _$mailClientSourceHash() => r'd9c97325207816d3dadefe6e6afee06707af88b5'; +String _$mailClientSourceHash() => r'7ac0dcba26568bfa298f314958e433db9ce41de5'; abstract class _$MailClientSource extends BuildlessNotifier { late final RealAccount account; From 5b45bc3ecd989444a0c3f68510d41e39612313ff Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 16 Oct 2023 20:43:34 +0200 Subject: [PATCH 16/95] scout: experimenting with sender-specific widget --- lib/screens/compose_screen.dart | 175 ++++++++++++++++++++++++-------- 1 file changed, 134 insertions(+), 41 deletions(-) diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index 032761e..e9bf05c 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -8,6 +8,7 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:enough_text_editor/enough_text_editor.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -43,6 +44,95 @@ enum _OverflowMenuChoice { enum _Autofocus { to, subject, text } +/// A dropdown to select the sender +class SenderDropdown extends HookConsumerWidget { + /// Creates a new [SenderDropdown] with the given [onChanged] + const SenderDropdown({ + super.key, + required this.onChanged, + this.from, + }); + + /// Callback when the selected sender changes + final ValueChanged onChanged; + + /// Optional list of from sender addresses + final List? from; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO(RV): consider adding first from as sender + final senders = ref.watch(sendersProvider); + + RealAccount getCurrentAccount() { + final providedCurrentAccount = ref.read(currentAccountProvider); + + return providedCurrentAccount is RealAccount + ? providedCurrentAccount + : (providedCurrentAccount is UnifiedAccount + ? providedCurrentAccount.accounts.first + : ref.read(realAccountsProvider).first); + } + + Sender getInitialSender() { + final from = this.from; + if (from != null && from.isNotEmpty) { + final senderEmail = from.first.email.toLowerCase(); + final sender = senders.firstWhereOrNull( + (s) => s.address.email.toLowerCase() == senderEmail, + ); + if (sender != null) { + return sender; + } + } + final defaultSender = ref.read(settingsProvider).defaultSender; + if (defaultSender != null) { + final senderEmail = defaultSender.email.toLowerCase(); + final sender = senders.firstWhereOrNull( + (s) => s.address.email.toLowerCase() == senderEmail, + ); + if (sender != null) { + return sender; + } + } + final account = getCurrentAccount(); + final senderEmail = account.fromAddress.email.toLowerCase(); + final sender = senders.firstWhereOrNull( + (s) => s.address.email.toLowerCase() == senderEmail, + ); + if (sender != null) { + return sender; + } + + return senders.first; + } + + final senderState = useState(getInitialSender()); + + return PlatformDropdownButton( + items: senders + .map( + (s) => DropdownMenuItem( + value: s, + child: Text( + s.toString(), + overflow: TextOverflow.fade, + ), + ), + ) + .toList(), + onChanged: (s) async { + if (s != null) { + senderState.value = s; + onChanged(s); + } + }, + value: senderState.value, + hint: Text(context.text.composeSenderHint), + ); + } +} + /// Compose a new email message class ComposeScreen extends ConsumerStatefulWidget { /// Creates a new [ComposeScreen] with the given [ComposeData @@ -90,9 +180,9 @@ class _ComposeScreenState extends ConsumerState { : (_subjectController.text.isEmpty) ? _Autofocus.subject : _Autofocus.text; - _senders = ref.watch(sendersProvider); - final realAccounts = ref.watch(realAccountsProvider); - final providedCurrentAccount = ref.watch(currentAccountProvider); + _senders = ref.read(sendersProvider); + final realAccounts = ref.read(realAccountsProvider); + final providedCurrentAccount = ref.read(currentAccountProvider); final currentAccount = providedCurrentAccount is RealAccount ? providedCurrentAccount @@ -522,46 +612,49 @@ class _ComposeScreenState extends ConsumerState { localizations.detailsHeaderFrom, style: Theme.of(context).textTheme.bodySmall, ), - PlatformDropdownButton( - //isExpanded: true, - items: _senders - .map( - (s) => DropdownMenuItem( - value: s, - child: Text( - s.toString(), - overflow: TextOverflow.fade, - ), - ), - ) - .toList(), - onChanged: (s) async { - if (s != null) { - final builder = widget.data.messageBuilder - ..from = [s.address]; - final lastSignature = _signature; - _from = s; - final newSignature = _signature; - if (newSignature != lastSignature) { - await _htmlEditorApi?.replaceAll( - lastSignature, - newSignature, - ); - } - if (_isReadReceiptRequested) { - builder.requestReadReceipt( - recipient: _from.address, - ); - } - setState(() { - _realAccount = s.account; - }); - - await _checkAccountContactManager(_from.account); + SenderDropdown( + from: widget.data.messageBuilder.from, + onChanged: + // PlatformDropdownButton( + // //isExpanded: true, + // items: _senders + // .map( + // (s) => DropdownMenuItem( + // value: s, + // child: Text( + // s.toString(), + // overflow: TextOverflow.fade, + // ), + // ), + // ) + // .toList(), + // onChanged: (s) async { + // if (s != null) { + (s) { + final builder = widget.data.messageBuilder + ..from = [s.address]; + final lastSignature = _signature; + _from = s; + final newSignature = _signature; + if (newSignature != lastSignature) { + _htmlEditorApi?.replaceAll( + lastSignature, + newSignature, + ); + } + if (_isReadReceiptRequested) { + builder.requestReadReceipt( + recipient: _from.address, + ); } + setState(() { + _realAccount = s.account; + }); + + _checkAccountContactManager(_from.account); }, - value: _from, - hint: Text(localizations.composeSenderHint), + // value: _from, + // hint: Text(localizations.composeSenderHint), ), RecipientInputField( contactManager: _from.account.contactManager, From 05151be7b60f2997dfe197137ad58223de30aad6 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Tue, 17 Oct 2023 17:02:24 +0200 Subject: [PATCH 17/95] feat: keep senders stable and use correct context to undo leaving the compose screen --- lib/models/sender.dart | 28 ++++++++++++++++++++++++++-- lib/screens/compose_screen.dart | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/models/sender.dart b/lib/models/sender.dart index c3bac4d..83c92a7 100644 --- a/lib/models/sender.dart +++ b/lib/models/sender.dart @@ -1,13 +1,37 @@ import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; import '../account/model.dart'; +/// Contains information about a sender for composing new messages +@immutable class Sender { - Sender(this.address, this.account, {this.isPlaceHolderForPlusAlias = false}); - MailAddress address; + /// Creates a new sender + Sender( + this.address, + this.account, { + this.isPlaceHolderForPlusAlias = false, + }) : emailLowercase = address.email.toLowerCase(); + + /// The address + final MailAddress address; + + /// The associated account final RealAccount account; + + /// Whether this sender is a placeholder for a plus alias final bool isPlaceHolderForPlusAlias; + /// The lowercase email address for comparisons + final String emailLowercase; + @override String toString() => address.toString(); + + @override + int get hashCode => emailLowercase.hashCode; + + @override + bool operator ==(Object other) => + other is Sender && other.emailLowercase == emailLowercase; } diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index e9bf05c..03f214c 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -875,7 +875,7 @@ class _ComposeScreenState extends ConsumerState { void _returnToCompose() { final currentContext = Routes.navigatorKey.currentContext; if (currentContext != null && currentContext.mounted) { - context.pushNamed( + currentContext.pushNamed( Routes.mailCompose, extra: _resumeComposeData, ); From 04e2549e509b989ed477a60acc1602302d11f36a Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Fri, 20 Oct 2023 07:16:03 +0200 Subject: [PATCH 18/95] chore: rewrite background service, continue with architecture clean up --- .vscode/settings.json | 3 + android/build.gradle | 2 +- lib/account/model.dart | 4 + lib/account/model.g.dart | 10 +- lib/account/provider.dart | 13 + lib/account/provider.g.dart | 18 + lib/background/model.dart | 50 ++ lib/background/provider.dart | 294 +++++++ lib/background/provider.g.dart | 26 + lib/extensions/extension_action_tile.dart | 44 +- lib/extensions/extensions.dart | 24 +- lib/locator.dart | 12 - lib/mail/provider.dart | 4 +- lib/main.dart | 9 +- lib/models/background_update_info.dart | 51 -- lib/models/background_update_info.g.dart | 15 - lib/models/compose_data.dart | 38 +- lib/models/message.dart | 6 - lib/models/message_source.dart | 6 +- lib/models/models.dart | 1 - lib/notification/service.dart | 48 +- lib/routes.dart | 32 +- lib/screens/account_add_screen.dart | 8 +- lib/screens/compose_screen.dart | 144 ++-- lib/screens/location_screen.dart | 23 +- lib/screens/lock_screen.dart | 8 +- lib/screens/message_details_screen.dart | 3 +- lib/screens/message_source_screen.dart | 35 +- lib/services/app_service.dart | 132 +-- lib/services/background_service.dart | 243 ------ lib/services/mail_service.dart | 802 ------------------ lib/services/navigation_service.dart | 105 --- .../view/settings_accounts_screen.dart | 12 +- lib/settings/view/settings_screen.dart | 34 +- lib/settings/view/settings_swipe_screen.dart | 5 +- .../shared_data.dart => share/model.dart} | 24 +- lib/share/provider.dart | 121 +++ lib/share/provider.g.dart | 28 + lib/widgets/attachment_chip.dart | 10 +- lib/widgets/attachment_compose_bar.dart | 43 +- lib/widgets/editor_extensions.dart | 6 +- lib/widgets/menu_with_badge.dart | 36 +- lib/widgets/message_actions.dart | 100 ++- lib/widgets/search_text_field.dart | 15 +- pubspec.yaml | 5 +- test/model/multiple_message_source_test.dart | 4 +- 46 files changed, 982 insertions(+), 1674 deletions(-) create mode 100644 lib/background/model.dart create mode 100644 lib/background/provider.dart create mode 100644 lib/background/provider.g.dart delete mode 100644 lib/models/background_update_info.dart delete mode 100644 lib/models/background_update_info.g.dart delete mode 100644 lib/services/background_service.dart delete mode 100644 lib/services/mail_service.dart delete mode 100644 lib/services/navigation_service.dart rename lib/{models/shared_data.dart => share/model.dart} (87%) create mode 100644 lib/share/provider.dart create mode 100644 lib/share/provider.g.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 4485618..ea93ce0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,8 @@ "cSpell.words": [ "autofocus", "Cupertino", + "finalizer", + "finalizers", "giphy", "Ical", "icalendar", @@ -10,6 +12,7 @@ "Maily", "mocktail", "riverpod", + "uids", "unawaited", "unfocus" ] diff --git a/android/build.gradle b/android/build.gradle index c40a8a0..1ed2a32 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -4,7 +4,7 @@ buildscript { minSdkVersion = 21 // or higher compileSdkVersion = 34 // or higher targetSdkVersion = 34 // or higher - appCompatVersion = "1.2.0" // or higher + appCompatVersion = "1.4.2" // or higher } repositories { google() diff --git a/lib/account/model.dart b/lib/account/model.dart index 0401423..b30d0fe 100644 --- a/lib/account/model.dart +++ b/lib/account/model.dart @@ -79,6 +79,7 @@ class RealAccount extends Account { } /// Does this account have a login error? + @JsonKey(includeToJson: false, includeFromJson: false) bool hasError = false; @override @@ -95,6 +96,7 @@ class RealAccount extends Account { } /// Should this account be excluded from the unified account? + @JsonKey(includeToJson: false, includeFromJson: false) bool get excludeFromUnified => getAttribute(attributeExcludeFromUnified) ?? false; set excludeFromUnified(bool value) => @@ -148,11 +150,13 @@ class RealAccount extends Account { /// Account-specific signature for plain text messages /// /// Compare [signatureHtml] + @JsonKey(includeToJson: false, includeFromJson: false) String? get signaturePlain => _account.attributes[attributeSignaturePlain]; set signaturePlain(String? value) => setAttribute(attributeSignaturePlain, value); /// The name used for sending + @JsonKey(includeToJson: false, includeFromJson: false) String? get userName => _account.userName; set userName(String? value) { _account = _account.copyWith(userName: value); diff --git a/lib/account/model.g.dart b/lib/account/model.g.dart index e619eff..e4d8247 100644 --- a/lib/account/model.g.dart +++ b/lib/account/model.g.dart @@ -11,18 +11,10 @@ RealAccount _$RealAccountFromJson(Map json) => RealAccount( appExtensions: (json['appExtensions'] as List?) ?.map((e) => AppExtension.fromJson(e as Map)) .toList(), - ) - ..hasError = json['hasError'] as bool - ..excludeFromUnified = json['excludeFromUnified'] as bool - ..signaturePlain = json['signaturePlain'] as String? - ..userName = json['userName'] as String?; + ); Map _$RealAccountToJson(RealAccount instance) => { 'mailAccount': instance.mailAccount, - 'hasError': instance.hasError, - 'excludeFromUnified': instance.excludeFromUnified, - 'signaturePlain': instance.signaturePlain, - 'userName': instance.userName, 'appExtensions': instance.appExtensions, }; diff --git a/lib/account/provider.dart b/lib/account/provider.dart index fe116d9..7d95329 100644 --- a/lib/account/provider.dart +++ b/lib/account/provider.dart @@ -160,3 +160,16 @@ bool hasAccountWithError( /// Provides the locally current active account @riverpod Account? currentAccount(CurrentAccountRef ref) => null; + +/// Provides the current real account +@riverpod +RealAccount? currentRealAccount(CurrentRealAccountRef ref) { + final realAccounts = ref.watch(realAccountsProvider); + final providedCurrentAccount = ref.watch(currentAccountProvider); + + return providedCurrentAccount is RealAccount + ? providedCurrentAccount + : (providedCurrentAccount is UnifiedAccount + ? providedCurrentAccount.accounts.first + : (realAccounts.isNotEmpty ? realAccounts.first : null)); +} diff --git a/lib/account/provider.g.dart b/lib/account/provider.g.dart index 733e78b..1f9a88c 100644 --- a/lib/account/provider.g.dart +++ b/lib/account/provider.g.dart @@ -375,6 +375,24 @@ final currentAccountProvider = AutoDisposeProvider.internal( ); typedef CurrentAccountRef = AutoDisposeProviderRef; +String _$currentRealAccountHash() => + r'dd79b65ff2ea824e117c4f13416c6b6993fa4a86'; + +/// Provides the current real account +/// +/// Copied from [currentRealAccount]. +@ProviderFor(currentRealAccount) +final currentRealAccountProvider = AutoDisposeProvider.internal( + currentRealAccount, + name: r'currentRealAccountProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentRealAccountHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CurrentRealAccountRef = AutoDisposeProviderRef; String _$realAccountsHash() => r'665041d146a86069048493163e33d76a4896d3cb'; /// Provides all real email accounts diff --git a/lib/background/model.dart b/lib/background/model.dart new file mode 100644 index 0000000..d8937d9 --- /dev/null +++ b/lib/background/model.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; + +/// Contains information about a known message UIDs for each email account +class BackgroundUpdateInfo { + /// Creates info for the background update + BackgroundUpdateInfo({required Map uidsByEmail}) + : _uidsByEmail = uidsByEmail; + + /// Creates info from the given [json] + factory BackgroundUpdateInfo.fromJson(Map json) { + final uidsJsonText = json['uidsByEmail']; + final uidsByEmail = uidsJsonText is String + ? (jsonDecode(uidsJsonText) as Map).map( + (key, value) => MapEntry(key, value as int), + ) + : {}; + + return BackgroundUpdateInfo(uidsByEmail: uidsByEmail); + } + + /// Creates info from the given [jsonText] + factory BackgroundUpdateInfo.fromJsonText(String? jsonText) => + jsonText == null + ? BackgroundUpdateInfo(uidsByEmail: {}) + : BackgroundUpdateInfo.fromJson(jsonDecode(jsonText)); + + /// Converts this info to JSON + Map toJson() => { + 'uidsByEmail': jsonEncode(_uidsByEmail), + }; + + final Map _uidsByEmail; + + var _isDirty = false; + + /// Has this information been updated since the last persistence? + bool get containsUpdatedEntries => _isDirty; + + /// Updates the entry for the given [email] + void updateForEmail(String email, int nextExpectedUid) { + final uidsByEmail = _uidsByEmail; + if (uidsByEmail[email] != nextExpectedUid) { + uidsByEmail[email] = nextExpectedUid; + _isDirty = true; + } + } + + /// Retrieves the next expected uid + int? nextExpectedUidForEmail(String email) => _uidsByEmail[email]; +} diff --git a/lib/background/provider.dart b/lib/background/provider.dart new file mode 100644 index 0000000..f3409c1 --- /dev/null +++ b/lib/background/provider.dart @@ -0,0 +1,294 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:background_fetch/background_fetch.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../account/provider.dart'; +import '../account/storage.dart'; +import '../app_lifecycle/provider.dart'; +import '../logger.dart'; +import '../mail/provider.dart'; +import '../mail/service.dart'; +import '../notification/service.dart'; +import 'model.dart'; + +part 'provider.g.dart'; + +/// Registers the background service to check for emails regularly +@Riverpod(keepAlive: true) +class Background extends _$Background { + var _isActive = true; + + @override + Future build() { + _isActive = true; + ref.onDispose(() { + _isActive = false; + }); + if (!_isSupported) { + return Future.value(); + } + final isInactive = ref.watch(appLifecycleStateProvider.select( + (value) => value == AppLifecycleState.inactive, + )); + if (isInactive) { + return _saveStateOnPause(); + } + + return Future.value(); + } + + /// Is the background provider supported on the current platform? + static bool get _isSupported => + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + + /// Configures and registers the background service + Future init() async { + if (!_isSupported) { + return; + } + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + startOnBoot: true, + stopOnTerminate: false, + enableHeadless: true, + requiresBatteryNotLow: false, + requiresCharging: false, + requiresStorageNotLow: false, + requiresDeviceIdle: false, + requiredNetworkType: NetworkType.ANY, + ), + (String taskId) async { + logger.d('running background fetch $taskId'); + try { + // await locator().resume(); + await _saveStateOnPause(); + } catch (e, s) { + logger.e( + 'Error: Unable to finish foreground background fetch: $e', + error: e, + stackTrace: s, + ); + } + BackgroundFetch.finish(taskId); + }, + ); + await BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); + logger.d('Registered background fetch'); + } + + Future _saveStateOnPause() async { + logger.d('save state on pause: isActive=$_isActive'); + if (!_isActive) { + return; + } + + final accounts = ref.read(realAccountsProvider); + final mailClients = accounts + .map( + (account) => ref.read(mailClientSourceProvider(account: account)), + ) + .toList(); + final futures = []; + final preferences = await SharedPreferences.getInstance(); + final jsonText = preferences.getString(_keyInboxUids); + final info = BackgroundUpdateInfo.fromJsonText(jsonText); + for (final client in mailClients) { + futures.add(_addNextUidFor(client, info)); + } + await Future.wait(futures); + logger.d('Updated UIDs, new UIDs found: ${info.containsUpdatedEntries}'); + if (info.containsUpdatedEntries) { + final stringValue = jsonEncode(info.toJson()); + logger.d('nextUids: $stringValue'); + await preferences.setString(_keyInboxUids, stringValue); + } + } + + Future _addNextUidFor( + final MailClient mailClient, + final BackgroundUpdateInfo info, + ) async { + try { + var box = mailClient.selectedMailbox; + if (box == null || !box.isInbox) { + await mailClient.connect(); + box = await mailClient.selectInbox(); + } + final uidNext = box.uidNext; + if (uidNext != null) { + info.updateForEmail(mailClient.account.email, uidNext); + } + } catch (e, s) { + logger.e( + 'Error while getting Inbox.nextUids ' + 'for ${mailClient.account.email}: $e', + error: e, + stackTrace: s, + ); + } + } +} + +const String _keyInboxUids = 'nextUidsInfo'; + +Future backgroundFetchHeadlessTask(HeadlessTask task) async { + final taskId = task.taskId; + logger.d( + 'backgroundFetchHeadlessTask with ' + 'taskId $taskId, timeout=${task.timeout}', + ); + if (task.timeout) { + BackgroundFetch.finish(taskId); + + return; + } + try { + await _checkForNewMail(); + } catch (e, s) { + if (kDebugMode) { + print('Error during backgroundFetchHeadlessTask $e $s'); + } + } finally { + BackgroundFetch.finish(taskId); + } +} + +Future _checkForNewMail() async { + logger.d('background check at ${DateTime.now()}'); + final preferences = await SharedPreferences.getInstance(); + + final inboxUidsText = preferences.getString(_keyInboxUids); + if (inboxUidsText == null || inboxUidsText.isEmpty) { + logger.w('WARNING: no previous UID infos found, exiting.'); + + return; + } + + final info = BackgroundUpdateInfo.fromJsonText(inboxUidsText); + const storage = AccountStorage(); + final accounts = await storage.loadAccounts(); + final mailClients = accounts.map( + (account) => + EmailService.instance.createMailClient(account.mailAccount, null), + ); + final notificationService = NotificationService.instance; + await notificationService.init(checkForLaunchDetails: false); + // final activeMailNotifications = + // await notificationService.getActiveMailNotifications(); + // print('background: got ' + // 'activeMailNotifications=$activeMailNotifications'); + final futures = []; + for (final mailClient in mailClients) { + final previousUidNext = + info.nextExpectedUidForEmail(mailClient.account.email) ?? 0; + futures.add( + _loadNewMessage( + mailClient, + previousUidNext, + notificationService, + info, + // activeMailNotifications + // .where((n) => n.accountEmail == accountEmail) + // .toList()), + ), + ); + } + await Future.wait(futures); + if (info.containsUpdatedEntries) { + final serialized = jsonEncode(info.toJson()); + await preferences.setString(_keyInboxUids, serialized); + } +} + +Future _loadNewMessage( + MailClient mailClient, + int previousUidNext, + NotificationService notificationService, + BackgroundUpdateInfo info, + // List activeNotifications, +) async { + try { + // ignore: avoid_print + print('${mailClient.account.name} A: background fetch connecting'); + await mailClient.connect(); + final inbox = await mailClient.selectInbox(); + final uidNext = inbox.uidNext; + if (uidNext == previousUidNext || uidNext == null) { + // print( + // 'no change for ${account.name}, activeNotifications=$activeNotifications'); + // check outdated notifications that should be removed because the message is deleted or read elsewhere: + // if (activeNotifications.isNotEmpty) { + // final uids = activeNotifications.map((n) => n.uid).toList(); + // final sequence = + // MessageSequence.fromIds(uids as List, isUid: true); + // final mimeMessages = await mailClient.fetchMessageSequence(sequence, + // fetchPreference: FetchPreference.envelope); + // for (final mimeMessage in mimeMessages) { + // if (mimeMessage.isSeen) { + // notificationService.cancelNotificationForMail( + // mimeMessage, mailClient); + // } + // uids.remove(mimeMessage.uid); + // } + // // remove notifications for messages that have been deleted: + // final email = mailClient.account.email ?? ''; + // final mailboxName = mailClient.selectedMailbox?.name ?? ''; + // final mailboxValidity = mailClient.selectedMailbox?.uidValidity ?? 0; + // for (final uid in uids) { + // final guid = MimeMessage.calculateGuid( + // email: email, + // mailboxName: mailboxName, + // mailboxUidValidity: mailboxValidity, + // messageUid: uid, + // ); + // notificationService.cancelNotification(guid); + // } + // } + } else { + if (kDebugMode) { + print( + 'new uidNext=$uidNext, previous=$previousUidNext ' + 'for ${mailClient.account.name} uidValidity=${inbox.uidValidity}', + ); + } + final sequence = MessageSequence.fromRangeToLast( + // special care when uidnext of the account was not known before: + // do not load _all_ messages + previousUidNext == 0 + ? max(previousUidNext, uidNext - 10) + : previousUidNext, + isUidSequence: true, + ); + info.updateForEmail(mailClient.account.email, uidNext); + final mimeMessages = await mailClient.fetchMessageSequence( + sequence, + fetchPreference: FetchPreference.envelope, + ); + for (final mimeMessage in mimeMessages) { + if (!mimeMessage.isSeen) { + await notificationService.sendLocalNotificationForMail( + mimeMessage, + mailClient.account.email, + ); + } + } + } + + await mailClient.disconnect(); + } catch (e, s) { + logger.e( + 'Unable to process background operation ' + 'for ${mailClient.account.name}: $e', + error: e, + stackTrace: s, + ); + } +} diff --git a/lib/background/provider.g.dart b/lib/background/provider.g.dart new file mode 100644 index 0000000..4f0e6af --- /dev/null +++ b/lib/background/provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$backgroundHash() => r'cd72738cc7ca6dda23ff242116fd23079ea760bb'; + +/// Registers the background service to check for emails regularly +/// +/// Copied from [Background]. +@ProviderFor(Background) +final backgroundProvider = AsyncNotifierProvider.internal( + Background.new, + name: r'backgroundProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$backgroundHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Background = AsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/extensions/extension_action_tile.dart b/lib/extensions/extension_action_tile.dart index 6731354..400a719 100644 --- a/lib/extensions/extension_action_tile.dart +++ b/lib/extensions/extension_action_tile.dart @@ -2,14 +2,13 @@ import 'dart:io'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart' hide WebViewConfiguration; import '../account/model.dart'; import '../localization/extension.dart'; -import '../locator.dart'; import '../models/models.dart'; import '../routes.dart'; -import '../services/navigation_service.dart'; import 'extensions.dart'; class ExtensionActionTile extends StatelessWidget { @@ -50,32 +49,41 @@ class ExtensionActionTile extends StatelessWidget { @override Widget build(BuildContext context) { final languageCode = context.text.localeName; + final icon = actionDescription.icon; return PlatformListTile( - leading: actionDescription.icon == null + leading: icon == null ? null : Image.network( - actionDescription.icon!, + icon, height: 24, width: 24, ), - title: Text(actionDescription.getLabel(languageCode)!), + title: Text(actionDescription.getLabel(languageCode) ?? ''), onTap: () { - final url = actionDescription.action!.url; - switch (actionDescription.action!.mechanism) { + final action = actionDescription.action; + if (action == null) { + return; + } + + final url = action.url; + switch (action.mechanism) { case AppExtensionActionMechanism.inApp: - final navService = locator(); - if (!(Platform.isIOS || Platform.isMacOS)) { - // close app drawer: - navService.pop(); + final context = Routes.navigatorKey.currentContext; + if (context != null) { + if (!(Platform.isIOS || Platform.isMacOS)) { + // close app drawer: + context.pop(); + } + context.pushNamed( + Routes.webview, + extra: WebViewConfiguration( + actionDescription.getLabel(languageCode), + Uri.parse(url), + ), + ); } - navService.push( - Routes.webview, - arguments: WebViewConfiguration( - actionDescription.getLabel(languageCode), - Uri.parse(url), - ), - ); + break; case AppExtensionActionMechanism.external: launchUrl(Uri.parse(url)); diff --git a/lib/extensions/extensions.dart b/lib/extensions/extensions.dart index 5f0d382..8084f4d 100644 --- a/lib/extensions/extensions.dart +++ b/lib/extensions/extensions.dart @@ -54,11 +54,12 @@ class AppExtension { if (map == null) { return null; } - var sign = map[languageCode]; - if (sign == null && languageCode != 'en') { - sign = map['en']; + var signature = map[languageCode]; + if (signature == null && languageCode != 'en') { + signature = map['en']; } - return sign; + + return signature; } Map toJson() => _$AppExtensionToJson(this); @@ -68,8 +69,14 @@ class AppExtension { static Future> loadFor(MailAccount mailAccount) async { final domains = >{}; _addEmail(mailAccount.email, domains); - _addHostname(mailAccount.incoming.serverConfig.hostname!, domains); - _addHostname(mailAccount.outgoing.serverConfig.hostname!, domains); + final incomingHostname = mailAccount.incoming.serverConfig.hostname; + if (incomingHostname != null) { + _addHostname(incomingHostname, domains); + } + final outgoingHostname = mailAccount.outgoing.serverConfig.hostname; + if (outgoingHostname != null) { + _addHostname(outgoingHostname, domains); + } final allExtensions = await Future.wait(domains.values); final appExtensions = []; for (final ext in allExtensions) { @@ -77,11 +84,14 @@ class AppExtension { appExtensions.add(ext); } } + return appExtensions; } static void _addEmail( - String email, Map> domains) { + String email, + Map> domains, + ) { _addDomain(email.substring(email.indexOf('@') + 1), domains); } diff --git a/lib/locator.dart b/lib/locator.dart index f9d0a15..61e0f97 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -1,17 +1,13 @@ import 'package:get_it/get_it.dart'; -import 'models/async_mime_source_factory.dart'; import 'notification/service.dart'; import 'services/app_service.dart'; -import 'services/background_service.dart'; import 'services/biometrics_service.dart'; import 'services/date_service.dart'; import 'services/i18n_service.dart'; import 'services/icon_service.dart'; import 'services/key_service.dart'; import 'services/location_service.dart'; -import 'services/mail_service.dart'; -import 'services/navigation_service.dart'; import 'services/providers.dart'; import 'services/scaffold_messenger_service.dart'; @@ -19,19 +15,11 @@ GetIt locator = GetIt.instance; void setupLocator() { locator - ..registerLazySingleton(NavigationService.new) - ..registerLazySingleton( - () => MailService( - mimeSourceFactory: - const AsyncMimeSourceFactory(isOfflineModeSupported: false), - ), - ) ..registerLazySingleton(I18nService.new) ..registerLazySingleton(ScaffoldMessengerService.new) ..registerLazySingleton(DateService.new) ..registerSingleton(IconService()) ..registerLazySingleton(() => NotificationService.instance) - ..registerLazySingleton(BackgroundService.new) ..registerLazySingleton(AppService.new) ..registerLazySingleton(LocationService.new) ..registerLazySingleton(KeyService.new) diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index 228ba00..5bb0026 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -130,13 +130,13 @@ class RealSource extends _$RealSource implements MimeSourceSubscriber { @override void onMailFlagsUpdated(MimeMessage mime, AsyncMimeSource source) { if (mime.isSeen) { - NotificationService.instance.cancelNotificationForMail(mime); + NotificationService.instance.cancelNotificationForMime(mime); } } @override void onMailVanished(MimeMessage mime, AsyncMimeSource source) { - NotificationService.instance.cancelNotificationForMail(mime); + NotificationService.instance.cancelNotificationForMime(mime); } } diff --git a/lib/main.dart b/lib/main.dart index 3177754..192715b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,16 +8,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'account/provider.dart'; import 'app_lifecycle/provider.dart'; +import 'background/provider.dart'; import 'localization/app_localizations.g.dart'; import 'locator.dart'; import 'logger.dart'; import 'routes.dart'; import 'screens/screens.dart'; -import 'services/background_service.dart'; import 'services/i18n_service.dart'; import 'services/scaffold_messenger_service.dart'; import 'settings/provider.dart'; import 'settings/theme/provider.dart'; +import 'share/provider.dart'; // AppStyles appStyles = AppStyles.instance; void main() { @@ -44,6 +45,8 @@ class MailyApp extends HookConsumerWidget { final themeSettingsData = ref.watch(themeFinderProvider(context: context)); final languageTag = ref.watch(settingsProvider.select((settings) => settings.languageTag)); + ref.watch(incomingShareProvider); + ref.watch(backgroundProvider); final app = PlatformSnackApp.router( supportedLocales: AppLocalizations.supportedLocales, @@ -160,9 +163,7 @@ class _InitializationScreen extends ConsumerState { // unawaited(locator() // .push(Routes.welcome, fade: true, replace: true)); // } - if (BackgroundService.isSupported) { - await locator().init(); - } + await ref.read(backgroundProvider.notifier).init(); // final usedContext = Routes.navigatorKey.currentContext ?? context; // if (usedContext.mounted) { // usedContext.pushReplacement(Routes.home); diff --git a/lib/models/background_update_info.dart b/lib/models/background_update_info.dart deleted file mode 100644 index 6eaf6c9..0000000 --- a/lib/models/background_update_info.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:enough_mail/enough_mail.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'background_update_info.g.dart'; - -@JsonSerializable() -class BackgroundUpdateInfo { - BackgroundUpdateInfo({Map? uidsByEmail}) - : _uidsByEmail = uidsByEmail; - - factory BackgroundUpdateInfo.fromJson(Map json) => - _$BackgroundUpdateInfoFromJson(json); - - Map toJson() => _$BackgroundUpdateInfoToJson(this); - - @JsonKey(name: 'uidsByEmail') - Map? _uidsByEmail; - - @JsonKey(ignore: true) - var _isDirty = false; - - /// Has this information been updated since the last persistence? - bool get isDirty => _isDirty; - - void updateForClient(MailClient mailClient, int nextExpectedUid) => - updateForEmail(mailClient.account.email, nextExpectedUid); - - void updateForEmail(String email, int nextExpectedUid) { - final uidsByEmail = _uidsByEmail ?? {}; - uidsByEmail[email] = nextExpectedUid; - _isDirty = true; - _uidsByEmail = uidsByEmail; - } - - /// Retrieves the next expected uid - int? nextExpectedUidForClient(MailClient mailClient) => - nextExpectedUidForEmail(mailClient.account.email); - - /// Retrieves the next expected uid - int? nextExpectedUidForAccount(MailAccount account) => - nextExpectedUidForEmail(account.email); - - /// Retrieves the next expected uid - int? nextExpectedUidForEmail(String email) { - final uidsByEmail = _uidsByEmail; - if (uidsByEmail == null) { - return null; - } - return uidsByEmail[email]; - } -} diff --git a/lib/models/background_update_info.g.dart b/lib/models/background_update_info.g.dart deleted file mode 100644 index c3ca69b..0000000 --- a/lib/models/background_update_info.g.dart +++ /dev/null @@ -1,15 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'background_update_info.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -BackgroundUpdateInfo _$BackgroundUpdateInfoFromJson( - Map json) => - BackgroundUpdateInfo(); - -Map _$BackgroundUpdateInfoToJson( - BackgroundUpdateInfo instance) => - {}; diff --git a/lib/models/compose_data.dart b/lib/models/compose_data.dart index 42e7c1a..56b37e5 100644 --- a/lib/models/compose_data.dart +++ b/lib/models/compose_data.dart @@ -1,4 +1,5 @@ import 'package:enough_mail/enough_mail.dart'; + import 'message.dart'; enum ComposeAction { answer, forward, newMessage } @@ -8,7 +9,6 @@ enum ComposeMode { plainText, html } typedef MessageFinalizer = void Function(MessageBuilder messageBuilder); class ComposeData { - ComposeData( this.originalMessages, this.messageBuilder, @@ -18,8 +18,15 @@ class ComposeData { this.finalizers, this.composeMode = ComposeMode.html, }); - Message? get originalMessage => - (originalMessages?.isNotEmpty ?? false) ? originalMessages!.first : null; + + Message? get originalMessage { + final originalMessages = this.originalMessages; + + return (originalMessages != null && originalMessages.isNotEmpty) + ? originalMessages.first + : null; + } + final List? originalMessages; final MessageBuilder messageBuilder; final ComposeAction action; @@ -28,28 +35,35 @@ class ComposeData { final ComposeMode composeMode; List? finalizers; - ComposeData resume(String text, {ComposeMode? composeMode}) => ComposeData(originalMessages, messageBuilder, action, + ComposeData resume(String text, {ComposeMode? composeMode}) => ComposeData( + originalMessages, + messageBuilder, + action, resumeText: text, finalizers: finalizers, - composeMode: composeMode ?? this.composeMode); + composeMode: composeMode ?? this.composeMode, + ); /// Adds a finalizer /// /// A finalizer will be called before generating the final message. - /// This can be used to update the message builder depending on the chosen sender or recipients, etc. + /// + /// This can be used to update the message builder depending on the + /// chosen sender or recipients, etc. void addFinalizer(MessageFinalizer finalizer) { - finalizers ??= []; - finalizers!.add(finalizer); + final finalizers = (this.finalizers ?? []) + ..add(finalizer); + this.finalizers = finalizers; } /// Finalizes the message builder. /// /// Compare [addFinalizer] void finalize() { - final callbacks = finalizers; - if (callbacks != null) { - for (final callback in callbacks) { - callback(messageBuilder); + final finalizers = this.finalizers; + if (finalizers != null) { + for (final finalizer in finalizers) { + finalizer(messageBuilder); } } } diff --git a/lib/models/message.dart b/lib/models/message.dart index 52bcdc3..4cb5737 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -315,9 +315,3 @@ extension NewsLetter on MimeMessage { return client.sendMessage(message, appendToSent: false); } } - -class DisplayMessageArguments { - const DisplayMessageArguments(this.message, this.blockExternalContent); - final Message message; - final bool blockExternalContent; -} diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 38c158d..a8651f2 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -307,7 +307,7 @@ abstract class MessageSource extends ChangeNotifier NotificationService notificationService, { bool notify = true, }) { - notificationService.cancelNotificationForMailMessage(message); + notificationService.cancelNotificationForMessage(message); removeFromCache(message, notify: notify); } @@ -407,7 +407,7 @@ abstract class MessageSource extends ChangeNotifier if (source != null) { onMarkedAsSeen(msg, isSeen); if (isSeen) { - locator().cancelNotificationForMailMessage(msg); + locator().cancelNotificationForMessage(msg); } return source.store([msg.mimeMessage], [MessageFlags.seen]); @@ -467,7 +467,7 @@ abstract class MessageSource extends ChangeNotifier for (final msg in messages) { onMarkedAsSeen(msg, isSeen); if (isSeen) { - notificationService.cancelNotificationForMailMessage(msg); + notificationService.cancelNotificationForMessage(msg); } } diff --git a/lib/models/models.dart b/lib/models/models.dart index 0f8ae89..b68916a 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -8,6 +8,5 @@ export 'message_date_section.dart'; export 'message_source.dart'; export 'search.dart'; export 'sender.dart'; -export 'shared_data.dart'; export 'swipe.dart'; export 'web_view_configuration.dart'; diff --git a/lib/notification/service.dart b/lib/notification/service.dart index 7b3f6c8..c6045dd 100644 --- a/lib/notification/service.dart +++ b/lib/notification/service.dart @@ -136,28 +136,38 @@ class NotificationService { MimeMessage mimeMessage, String accountEmail, ) { - if (kDebugMode) { - logger.d( - 'sending notification for mime ${mimeMessage.decodeSubject()}' - ' with GUID ${mimeMessage.guid}', - ); - } - final notificationId = mimeMessage.guid!; - var from = mimeMessage.from?.isNotEmpty ?? false - ? mimeMessage.from!.first.personalName - : mimeMessage.sender?.personalName; - if (from == null || from.isEmpty) { - from = mimeMessage.from?.isNotEmpty ?? false - ? mimeMessage.from!.first.email + logger.d( + 'sending notification for mime ${mimeMessage.decodeSubject()}' + ' with GUID ${mimeMessage.guid}', + ); + String retrieveFromName() { + final mimeFrom = mimeMessage.from; + final personalName = mimeFrom != null && mimeFrom.isNotEmpty + ? mimeFrom.first.personalName + : mimeMessage.sender?.personalName; + if (personalName != null && personalName.isNotEmpty) { + return personalName; + } + final email = mimeFrom != null && mimeFrom.isNotEmpty + ? mimeFrom.first.email : mimeMessage.sender?.email; + if (email != null && email.isNotEmpty) { + return email; + } + + return ''; } + + final notificationId = mimeMessage.guid!; + final from = retrieveFromName(); + final subject = mimeMessage.decodeSubject(); final payload = MailNotificationPayload.fromMail(mimeMessage, accountEmail); final payloadText = _messagePayloadStart + jsonEncode(payload.toJson()); return sendLocalNotification( notificationId, - from!, + from, subject, payloadText: payloadText, when: mimeMessage.decodeDate(), @@ -172,7 +182,7 @@ class NotificationService { // return (email?.hashCode ?? 0) + uid; // } - Future sendLocalNotification( + Future sendLocalNotification( int id, String title, String? text, { @@ -180,6 +190,7 @@ class NotificationService { DateTime? when, bool channelShowBadge = true, }) async { + logger.d('sendLocalNotification: $id: $title $text'); AndroidNotificationDetails? androidPlatformChannelSpecifics; DarwinNotificationDetails? iosPlatformChannelSpecifics; if (Platform.isAndroid) { @@ -208,11 +219,10 @@ class NotificationService { .show(id, title, text, platformChannelSpecifics, payload: payloadText); } - void cancelNotificationForMailMessage(maily.Message message) { - cancelNotificationForMail(message.mimeMessage); - } + void cancelNotificationForMessage(maily.Message message) => + cancelNotificationForMime(message.mimeMessage); - void cancelNotificationForMail(MimeMessage mimeMessage) { + void cancelNotificationForMime(MimeMessage mimeMessage) { final guid = mimeMessage.guid; if (guid != null) { cancelNotification(guid); diff --git a/lib/routes.dart b/lib/routes.dart index 7ba4a09..4b41fe0 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -15,8 +15,11 @@ import 'screens/screens.dart'; import 'settings/view/view.dart'; import 'widgets/app_drawer.dart'; +/// Defines app navigation routes class Routes { Routes._(); + + /// The root route static const String _root = '/'; /// Displays either the welcome screen or the mail screen @@ -100,12 +103,20 @@ class Routes { /// /// extra: [InteractiveMediaWidget] static const String interactiveMedia = '/interactiveMedia'; + + /// Allows to pick a location + /// + /// Pops the [Uint8List] after selecting a location static const String locationPicker = '/locationPicker'; /// Displays the source code of a message /// /// extra: [MimeMessage] static const String sourceCode = '/sourceCode'; + + /// Displays the web view based on the given configuration + /// + /// extra: [WebViewConfiguration] static const String webview = '/webview'; static const String appDrawer = '/appDrawer'; static const String lockScreen = '/lock'; @@ -349,6 +360,22 @@ class Routes { : const HomeScreen(); }, ), + GoRoute( + name: webview, + path: webview, + builder: (context, state) { + final configuration = state.extra; + + return configuration is WebViewConfiguration + ? WebViewScreen(configuration: configuration) + : const HomeScreen(); + }, + ), + GoRoute( + name: locationPicker, + path: locationPicker, + builder: (context, state) => const LocationScreen(), + ), ], ); } @@ -415,11 +442,6 @@ class AppRouter { case Routes.mailDetails: if (arguments is Message) { page = MessageDetailsScreen(message: arguments); - } else if (arguments is DisplayMessageArguments) { - page = MessageDetailsScreen( - message: arguments.message, - blockExternalContent: arguments.blockExternalContent, - ); } else { page = const WelcomeScreen(); } diff --git a/lib/screens/account_add_screen.dart b/lib/screens/account_add_screen.dart index 7f9abb0..3247691 100644 --- a/lib/screens/account_add_screen.dart +++ b/lib/screens/account_add_screen.dart @@ -173,14 +173,14 @@ class _AccountAddScreenState extends ConsumerState { Future _onStepProgressed(int step) async { _progressedSteps = step; - switch (step) { - case _stepEmail + 1: + switch (step - 1) { + case _stepEmail: await _discover(_emailController.text); break; - case _stepPassword + 1: + case _stepPassword: await _verifyAccount(); break; - case _stepAccountSetup + 1: + case _stepAccountSetup: await _finalizeAccount(); break; } diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index 03f214c..4db581c 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -21,12 +21,11 @@ import '../locator.dart'; import '../mail/provider.dart'; import '../models/compose_data.dart'; import '../models/sender.dart'; -import '../models/shared_data.dart'; import '../routes.dart'; -import '../services/app_service.dart'; -import '../services/i18n_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/provider.dart'; +import '../share/model.dart'; +import '../share/provider.dart'; import '../util/localized_dialog_helper.dart'; import '../widgets/app_drawer.dart'; import '../widgets/attachment_compose_bar.dart'; @@ -64,16 +63,6 @@ class SenderDropdown extends HookConsumerWidget { // TODO(RV): consider adding first from as sender final senders = ref.watch(sendersProvider); - RealAccount getCurrentAccount() { - final providedCurrentAccount = ref.read(currentAccountProvider); - - return providedCurrentAccount is RealAccount - ? providedCurrentAccount - : (providedCurrentAccount is UnifiedAccount - ? providedCurrentAccount.accounts.first - : ref.read(realAccountsProvider).first); - } - Sender getInitialSender() { final from = this.from; if (from != null && from.isNotEmpty) { @@ -95,13 +84,15 @@ class SenderDropdown extends HookConsumerWidget { return sender; } } - final account = getCurrentAccount(); - final senderEmail = account.fromAddress.email.toLowerCase(); - final sender = senders.firstWhereOrNull( - (s) => s.address.email.toLowerCase() == senderEmail, - ); - if (sender != null) { - return sender; + final account = ref.read(currentRealAccountProvider); + if (account != null) { + final senderEmail = account.fromAddress.email.toLowerCase(); + final sender = senders.firstWhereOrNull( + (s) => s.address.email.toLowerCase() == senderEmail, + ); + if (sender != null) { + return sender; + } } return senders.first; @@ -167,7 +158,7 @@ class _ComposeScreenState extends ConsumerState { @override void initState() { - locator().onSharedData = _onSharedData; + onSharedData = _onSharedData; _composeMode = widget.data.composeMode; final mb = widget.data.messageBuilder; _toRecipients = mb.to ?? []; @@ -181,14 +172,7 @@ class _ComposeScreenState extends ConsumerState { ? _Autofocus.subject : _Autofocus.text; _senders = ref.read(sendersProvider); - final realAccounts = ref.read(realAccountsProvider); - final providedCurrentAccount = ref.read(currentAccountProvider); - - final currentAccount = providedCurrentAccount is RealAccount - ? providedCurrentAccount - : (providedCurrentAccount is UnifiedAccount - ? providedCurrentAccount.accounts.first - : realAccounts.first); + final currentAccount = ref.read(currentRealAccountProvider)!; _realAccount = currentAccount; final defaultSender = ref.read(settingsProvider).defaultSender; mb.from ??= [defaultSender ?? currentAccount.fromAddress]; @@ -204,7 +188,7 @@ class _ComposeScreenState extends ConsumerState { } if (from == null) { from = Sender(mb.from!.first, currentAccount); - _senders.insert(0, from); + _senders = [from, ..._senders]; } _from = from; _checkAccountContactManager(_from.account); @@ -227,7 +211,7 @@ class _ComposeScreenState extends ConsumerState { void dispose() { _subjectController.dispose(); _plainTextController.dispose(); - locator().onSharedData = null; + onSharedData = null; super.dispose(); } @@ -254,8 +238,7 @@ class _ComposeScreenState extends ConsumerState { } } else { const blockExternalImages = false; - final emptyMessageText = - locator().localizations.composeEmptyMessage; + final emptyMessageText = context.text.composeEmptyMessage; const maxImageWidth = 300; if (widget.data.action == ComposeAction.newMessage) { // continue with draft: @@ -612,49 +595,50 @@ class _ComposeScreenState extends ConsumerState { localizations.detailsHeaderFrom, style: Theme.of(context).textTheme.bodySmall, ), - SenderDropdown( - from: widget.data.messageBuilder.from, - onChanged: - // PlatformDropdownButton( - // //isExpanded: true, - // items: _senders - // .map( - // (s) => DropdownMenuItem( - // value: s, - // child: Text( - // s.toString(), - // overflow: TextOverflow.fade, - // ), - // ), - // ) - // .toList(), - // onChanged: (s) async { - // if (s != null) { - (s) { - final builder = widget.data.messageBuilder - ..from = [s.address]; - final lastSignature = _signature; - _from = s; - final newSignature = _signature; - if (newSignature != lastSignature) { - _htmlEditorApi?.replaceAll( - lastSignature, - newSignature, - ); + // SenderDropdown( + // from: widget.data.messageBuilder.from, + // onChanged: + PlatformDropdownButton( + //isExpanded: true, + items: _senders + .map( + (s) => DropdownMenuItem( + value: s, + child: Text( + s.toString(), + overflow: TextOverflow.fade, + ), + ), + ) + .toList(), + onChanged: (s) async { + if (s != null) { + // (s) { + final builder = widget.data.messageBuilder + ..from = [s.address]; + final lastSignature = _signature; + _from = s; + final newSignature = _signature; + if (newSignature != lastSignature) { + await _htmlEditorApi?.replaceAll( + lastSignature, + newSignature, + ); + } + if (_isReadReceiptRequested) { + builder.requestReadReceipt( + recipient: _from.address, + ); + } + setState(() { + _realAccount = s.account; + }); + + await _checkAccountContactManager(_from.account); } - if (_isReadReceiptRequested) { - builder.requestReadReceipt( - recipient: _from.address, - ); - } - setState(() { - _realAccount = s.account; - }); - - _checkAccountContactManager(_from.account); }, - // value: _from, - // hint: Text(localizations.composeSenderHint), + value: _from, + hint: Text(localizations.composeSenderHint), ), RecipientInputField( contactManager: _from.account.contactManager, @@ -703,7 +687,7 @@ class _ComposeScreenState extends ConsumerState { ), const Divider( color: Colors.grey, - ) + ), ], ], ), @@ -791,7 +775,7 @@ class _ComposeScreenState extends ConsumerState { Future _saveAsDraft() async { context.pop(); - final localizations = locator().localizations; + final localizations = context.text; final mailClient = _getMailClient(); final mime = await _buildMimeMessage(mailClient); try { @@ -883,8 +867,12 @@ class _ComposeScreenState extends ConsumerState { } Future _checkAccountContactManager(RealAccount account) async { - account.contactManager ??= - await ref.read(contactsLoaderProvider(account: account).future); + final contactManager = account.contactManager; + if (contactManager == null) { + account.contactManager = + await ref.read(contactsLoaderProvider(account: account).future); + setState(() {}); + } } Future _onSharedData(List sharedData) { diff --git a/lib/screens/location_screen.dart b/lib/screens/location_screen.dart index 260481f..edc1631 100644 --- a/lib/screens/location_screen.dart +++ b/lib/screens/location_screen.dart @@ -5,14 +5,15 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:go_router/go_router.dart'; import 'package:latlng/latlng.dart'; import 'package:location/location.dart'; import 'package:map/map.dart'; import '../localization/extension.dart'; import '../locator.dart'; +import '../routes.dart'; import '../services/location_service.dart'; -import '../services/navigation_service.dart'; import 'base.dart'; class LocationScreen extends StatefulWidget { @@ -29,7 +30,7 @@ class _LocationScreenState extends State { late MapController _controller; Future? _findLocation; late Offset _dragStart; - double _scaleStart = 1; + var _scaleStart = 1.0; @override void initState() { @@ -74,18 +75,30 @@ class _LocationScreenState extends State { Future _onLocationSelected() async { final context = _repaintBoundaryKey.currentContext; if (context == null) { - locator().pop(); + final currentContext = Routes.navigatorKey.currentContext; + if (currentContext != null) { + currentContext.pop(); + } + + return; + } + final boundary = context.findRenderObject(); + if (boundary is! RenderRepaintBoundary) { + context.pop(); + return; } - final boundary = context.findRenderObject()! as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 3); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); final pngBytes = byteData?.buffer.asUint8List(); - locator().pop(pngBytes); + if (context.mounted) { + context.pop(pngBytes); + } } Widget _buildMap(BuildContext context, double latitude, double longitude) { final size = MediaQuery.of(context).size; + return MapLayout( controller: _controller, builder: (context, transformer) => GestureDetector( diff --git a/lib/screens/lock_screen.dart b/lib/screens/lock_screen.dart index fd528e3..5181160 100644 --- a/lib/screens/lock_screen.dart +++ b/lib/screens/lock_screen.dart @@ -1,12 +1,12 @@ import 'package:enough_platform_widgets/platform.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../services/biometrics_service.dart'; -import '../services/navigation_service.dart'; import 'base.dart'; class LockScreen extends StatelessWidget { @@ -45,9 +45,9 @@ class LockScreen extends StatelessWidget { ); Future _authenticate(BuildContext context) async { - final didAuthencate = await locator().authenticate(); - if (didAuthencate) { - locator().pop(); + final didAuthenticate = await locator().authenticate(); + if (didAuthenticate && context.mounted) { + context.pop(); } } } diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index f64f28e..abbcecb 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -474,8 +474,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { _blockExternalImages = blockExternalImages; }); } - locator() - .cancelNotificationForMailMessage(widget.message); + locator().cancelNotificationForMessage(widget.message); if (_notifyMarkedAsSeen) { widget.message.source.onMarkedAsSeen(widget.message, true); } diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index f4b4640..84d43d1 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -4,6 +4,7 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/provider.dart'; @@ -20,7 +21,6 @@ import '../notification/service.dart'; import '../routes.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; -import '../services/navigation_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/provider.dart'; import '../util/localized_dialog_helper.dart'; @@ -132,8 +132,7 @@ class _MessageSourceScreenState extends ConsumerState } final search = MailSearch(query, SearchQueryType.allTextHeaders); final searchSource = _sectionedMessageSource.messageSource.search(search); - locator() - .push(Routes.messageSource, arguments: searchSource); + context.pushNamed(Routes.messageSource, extra: searchSource); setState(() { _isInSearchMode = false; }); @@ -299,9 +298,9 @@ class _MessageSourceScreenState extends ConsumerState rightAction: PlatformIconButton( //TODO use CupertinoIcons.create once it's not buggy anymore icon: const Icon(CupertinoIcons.pen), - onPressed: () => locator().push( + onPressed: () => context.pushNamed( Routes.mailCompose, - arguments: ComposeData( + extra: ComposeData( null, MessageBuilder(), ComposeAction.newMessage, @@ -888,11 +887,14 @@ class _MessageSourceScreenState extends ConsumerState ); break; case _MultipleChoice.viewInSafeMode: - if (_selectedMessages.isNotEmpty) { - await locator().push( + if (_selectedMessages.isNotEmpty && context.mounted) { + unawaited(context.pushNamed( Routes.mailDetails, - arguments: DisplayMessageArguments(_selectedMessages.first, true), - ); + extra: _selectedMessages.first, + queryParameters: { + Routes.queryParameterBlockExternalContent: 'true', + }, + )); } endSelectionMode = false; leaveSelectionMode(); @@ -964,8 +966,7 @@ class _MessageSourceScreenState extends ConsumerState ComposeAction.forward, future: composeFuture, ); - await locator() - .push(Routes.mailCompose, arguments: composeData, fade: true); + unawaited(context.pushNamed(Routes.mailCompose, extra: composeData)); } Future addMessageAttachment(Message message, MessageBuilder builder) { @@ -1053,7 +1054,7 @@ class _MessageSourceScreenState extends ConsumerState setState(() { _isInSelectionMode = false; }); - locator().pop(); // alert + context.pop(); // alert final source = _sectionedMessageSource.messageSource; final localizations = locator().localizations; final account = widget.messageSource.account; @@ -1095,12 +1096,14 @@ class _MessageSourceScreenState extends ConsumerState //message.updateMime(mime); final builder = MessageBuilder.prepareFromDraft(mime); final data = ComposeData([message], builder, ComposeAction.newMessage); - await locator() - .push(Routes.mailCompose, arguments: data); + if (context.mounted) { + unawaited(context.pushNamed(Routes.mailCompose, extra: data)); + } } else { // move to mail details: - await locator() - .push(Routes.mailDetails, arguments: message); + if (context.mounted) { + unawaited(context.pushNamed(Routes.mailDetails, extra: message)); + } } } } diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart index 045e00b..7e38fce 100644 --- a/lib/services/app_service.dart +++ b/lib/services/app_service.dart @@ -1,28 +1,15 @@ -import 'dart:io'; import 'dart:ui'; -import 'package:enough_mail/enough_mail.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - import '../locator.dart'; import '../logger.dart'; -import '../models/compose_data.dart'; -import '../models/shared_data.dart'; -import '../routes.dart'; import '../settings/model.dart'; -import 'background_service.dart'; import 'biometrics_service.dart'; -import 'mail_service.dart'; -import 'navigation_service.dart'; /// Handles app life cycle events class AppService { /// Creates a new [AppService] AppService(); - static const _platform = MethodChannel('app.channel.shared.data'); - /// The current [AppLifecycleState] AppLifecycleState appLifecycleState = AppLifecycleState.resumed; @@ -36,7 +23,6 @@ class AppService { } bool get isInBackground => appLifecycleState != AppLifecycleState.resumed; - Future Function(List sharedData)? onSharedData; DateTime? _lastPausedTimeStamp; /// Handles when app life cycle has changed @@ -49,47 +35,45 @@ class AppService { switch (state) { case AppLifecycleState.resumed: //locator().checkForChangedTheme(); - final futures = [checkForShare(), locator().resume()]; if (settings.enableBiometricLock) { if (_ignoreBiometricsCheckAtNextResume) { _ignoreBiometricsCheckAtNextResume = false; // double check time stamp, // everything more than a minute requires a check - if (_ignoreBiometricsCheckAtNextResumeTS - .isAfter(DateTime.now().subtract(const Duration(minutes: 1)))) { - await Future.wait(futures); - + if (_ignoreBiometricsCheckAtNextResumeTS.isAfter( + DateTime.now().subtract( + const Duration(minutes: 1), + ), + )) { return; } } if (settings.lockTimePreference .requiresAuthorization(_lastPausedTimeStamp)) { - final navService = locator(); - if (navService.currentRouteName != Routes.lockScreen) { - await navService.push(Routes.lockScreen); - } + // final navService = locator(); + // if (navService.currentRouteName != Routes.lockScreen) { + // await navService.push(Routes.lockScreen); + // } final bool didAuthenticate = await locator().authenticate(); - if (!didAuthenticate) { - await Future.wait(futures); - if (navService.currentRouteName != Routes.lockScreen) { - await navService.push(Routes.lockScreen); - } + // if (!didAuthenticate) { + // if (navService.currentRouteName != Routes.lockScreen) { + // await navService.push(Routes.lockScreen); + // } - return; - } else if (navService.currentRouteName == Routes.lockScreen) { - navService.pop(); - } + // return; + // } else if (navService.currentRouteName == Routes.lockScreen) { + // navService.pop(); + // } } } - await Future.wait(futures); break; case AppLifecycleState.inactive: // TODO: Check if AppLifecycleState.inactive needs to be handled break; case AppLifecycleState.paused: _lastPausedTimeStamp = DateTime.now(); - await locator().saveStateOnPause(); + //await locator().saveStateOnPause(); break; case AppLifecycleState.detached: // TODO: Check if AppLifecycleState.detached needs to be handled @@ -99,84 +83,4 @@ class AppService { break; } } - - /// Checks if the app has been started by a shared data - Future checkForShare() async { - if (Platform.isAndroid) { - final shared = await _platform.invokeMethod('getSharedData'); - //print('checkForShare: received data: $shared'); - if (shared != null) { - await composeWithSharedData(shared); - } - } - } - - Future> _collectSharedData( - Map shared, - ) async { - final sharedData = []; - final String? mimeTypeText = shared['mimeType']; - final mediaType = (mimeTypeText == null || mimeTypeText.contains('*')) - ? null - : MediaType.fromText(mimeTypeText); - final int? length = shared['length']; - final String? text = shared['text']; - if (kDebugMode) { - print('share text: "$text"'); - } - if (length != null && length > 0) { - for (var i = 0; i < length; i++) { - final String? filename = shared['name.$i']; - final Uint8List? data = shared['data.$i']; - final String? typeName = shared['type.$i']; - final localMediaType = (typeName != 'null') - ? MediaType.fromText(typeName!) - : mediaType ?? MediaType.guessFromFileName(filename!); - sharedData.add(SharedBinary(data, filename, localMediaType)); - if (kDebugMode) { - print( - 'share: loaded ${localMediaType.text} "$filename" with ${data?.length} bytes'); - } - } - } else if (text != null) { - if (text.startsWith('mailto:')) { - final mailto = Uri.parse(text); - sharedData.add(SharedMailto(mailto)); - } else { - sharedData.add(SharedText(text, mediaType, subject: shared['subject'])); - } - } - - return sharedData; - } - - /// Composes a new message with shared data - Future composeWithSharedData(Map shared) async { - final sharedData = await _collectSharedData(shared); - if (sharedData.isEmpty) { - return; - } - final callback = onSharedData; - if (callback != null) { - return callback(sharedData); - } else { - MessageBuilder builder; - final firstData = sharedData.first; - if (firstData is SharedMailto) { - builder = MessageBuilder.prepareMailtoBasedMessage( - firstData.mailto, - locator().currentAccount!.fromAddress, - ); - } else { - builder = MessageBuilder(); - for (final data in sharedData) { - await data.addToMessageBuilder(builder); - } - } - final composeData = ComposeData(null, builder, ComposeAction.newMessage); - - return locator() - .push(Routes.mailCompose, arguments: composeData, fade: true); - } - } } diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart deleted file mode 100644 index 37091a4..0000000 --- a/lib/services/background_service.dart +++ /dev/null @@ -1,243 +0,0 @@ -import 'dart:convert'; -import 'dart:math'; - -import 'package:background_fetch/background_fetch.dart'; -import 'package:enough_mail/enough_mail.dart'; -import 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../locator.dart'; -import '../models/async_mime_source_factory.dart'; -import '../models/background_update_info.dart'; -import '../notification/service.dart'; -import 'mail_service.dart'; - -class BackgroundService { - static const String _keyInboxUids = 'nextUidsInfo'; - - static bool get isSupported => - defaultTargetPlatform == TargetPlatform.android || - defaultTargetPlatform == TargetPlatform.iOS; - - Future init() async { - await BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 15, - startOnBoot: true, - stopOnTerminate: false, - enableHeadless: true, - requiresBatteryNotLow: false, - requiresCharging: false, - requiresStorageNotLow: false, - requiresDeviceIdle: false, - requiredNetworkType: NetworkType.ANY, - ), - (String taskId) async { - try { - await locator().resume(); - } catch (e, s) { - if (kDebugMode) { - print('Error: Unable to finish foreground background fetch: $e $s'); - } - } - BackgroundFetch.finish(taskId); - }, - BackgroundFetch.finish, - ); - await BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); - } - - static Future backgroundFetchHeadlessTask(HeadlessTask task) async { - final taskId = task.taskId; - if (kDebugMode) { - print( - 'backgroundFetchHeadlessTask with taskId $taskId, timeout=${task.timeout}'); - } - if (task.timeout) { - BackgroundFetch.finish(taskId); - return; - } - try { - await checkForNewMail(); - } catch (e, s) { - if (kDebugMode) { - print('Error during backgroundFetchHeadlessTask $e $s'); - } - } finally { - BackgroundFetch.finish(taskId); - } - } - - Future saveStateOnPause() async { - final mailClients = locator().getMailClients(); - final futures = []; - final info = BackgroundUpdateInfo(); - for (final client in mailClients) { - futures.add(addNextUidFor(client, info)); - } - await Future.wait(futures); - final stringValue = jsonEncode(info.toJson()); - if (kDebugMode) { - print('nextUids: $stringValue'); - } - final preferences = await SharedPreferences.getInstance(); - await preferences.setString(_keyInboxUids, stringValue); - } - - Future addNextUidFor( - final MailClient mailClient, final BackgroundUpdateInfo info) async { - try { - var box = mailClient.selectedMailbox; - if (box == null || !box.isInbox) { - final connected = - await locator().connectAccount(mailClient.account); - if (connected == null) { - return; - } - box = await connected.selectInbox(); - } - final uidNext = box.uidNext; - if (uidNext != null) { - info.updateForClient(mailClient, uidNext); - } - } catch (e, s) { - if (kDebugMode) { - print( - 'Error while getting Inbox.nextUids for ${mailClient.account.email}: $e $s'); - } - } - } - - static Future checkForNewMail() async { - if (kDebugMode) { - print('background check at ${DateTime.now()}'); - } - final prefs = await SharedPreferences.getInstance(); - - final prefsValue = prefs.getString(_keyInboxUids); - if (prefsValue == null || prefsValue.isEmpty) { - if (kDebugMode) { - print('WARNING: no previous UID infos found, exiting.'); - } - return; - } - - final info = BackgroundUpdateInfo.fromJson(jsonDecode(prefsValue)); - final mailService = MailService( - mimeSourceFactory: - const AsyncMimeSourceFactory(isOfflineModeSupported: false), - ); - final accounts = await mailService.loadRealMailAccounts(); - final notificationService = NotificationService.instance; - await notificationService.init(checkForLaunchDetails: false); - // final activeMailNotifications = - // await notificationService.getActiveMailNotifications(); - // print('background: got activeMailNotifications=$activeMailNotifications'); - final futures = []; - for (final account in accounts) { - final previousUidNext = - info.nextExpectedUidForAccount(account.mailAccount) ?? 0; - futures.add( - loadNewMessage( - mailService, - account.mailAccount, - previousUidNext, - notificationService, - info, - // activeMailNotifications - // .where((n) => n.accountEmail == accountEmail) - // .toList()), - ), - ); - } - await Future.wait(futures); - if (info.isDirty) { - final serialized = jsonEncode(info.toJson()); - await prefs.setString(_keyInboxUids, serialized); - } - } - - static Future loadNewMessage( - MailService mailService, - MailAccount account, - int previousUidNext, - NotificationService notificationService, - BackgroundUpdateInfo info, - // List activeNotifications, - ) async { - try { - print('${account.name} A: background fetch connecting'); - final mailClient = await mailService.connectAccount(account); - if (mailClient == null) { - return; - } - final inbox = await mailClient.selectInbox(); - final uidNext = inbox.uidNext; - if (uidNext == previousUidNext || uidNext == null) { - // print( - // 'no change for ${account.name}, activeNotifications=$activeNotifications'); - // check outdated notifications that should be removed because the message is deleted or read elsewhere: - // if (activeNotifications.isNotEmpty) { - // final uids = activeNotifications.map((n) => n.uid).toList(); - // final sequence = - // MessageSequence.fromIds(uids as List, isUid: true); - // final mimeMessages = await mailClient.fetchMessageSequence(sequence, - // fetchPreference: FetchPreference.envelope); - // for (final mimeMessage in mimeMessages) { - // if (mimeMessage.isSeen) { - // notificationService.cancelNotificationForMail( - // mimeMessage, mailClient); - // } - // uids.remove(mimeMessage.uid); - // } - // // remove notifications for messages that have been deleted: - // final email = mailClient.account.email ?? ''; - // final mailboxName = mailClient.selectedMailbox?.name ?? ''; - // final mailboxValidity = mailClient.selectedMailbox?.uidValidity ?? 0; - // for (final uid in uids) { - // final guid = MimeMessage.calculateGuid( - // email: email, - // mailboxName: mailboxName, - // mailboxUidValidity: mailboxValidity, - // messageUid: uid, - // ); - // notificationService.cancelNotification(guid); - // } - // } - } else { - if (kDebugMode) { - print( - 'new uidNext=$uidNext, previous=$previousUidNext for ${account.name} uidValidity=${inbox.uidValidity}'); - } - final sequence = MessageSequence.fromRangeToLast( - // special care when uidnext of the account was not known before: - // do not load _all_ messages - previousUidNext == 0 - ? max(previousUidNext, uidNext - 10) - : previousUidNext, - isUidSequence: true, - ); - info.updateForClient(mailClient, uidNext); - final mimeMessages = await mailClient.fetchMessageSequence(sequence, - fetchPreference: FetchPreference.envelope); - for (final mimeMessage in mimeMessages) { - if (!mimeMessage.isSeen) { - await notificationService.sendLocalNotificationForMail( - mimeMessage, - mailClient.account.email, - ); - } - } - } - - await mailClient.disconnect(); - } catch (e, s) { - if (kDebugMode) { - print( - 'Unable to process background operation ' - 'for ${account.name}: $e $s', - ); - } - } - } -} diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart deleted file mode 100644 index 8edbb7e..0000000 --- a/lib/services/mail_service.dart +++ /dev/null @@ -1,802 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart' show IterableExtension; -import 'package:enough_mail/enough_mail.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../account/model.dart'; -import '../events/app_event_bus.dart'; -import '../localization/app_localizations.g.dart'; -import '../locator.dart'; -import '../models/async_mime_source.dart'; -import '../models/async_mime_source_factory.dart'; -import '../models/message_source.dart'; -import '../models/sender.dart'; -import '../notification/service.dart'; -import '../routes.dart'; -import '../settings/model.dart'; -import '../util/gravatar.dart'; -import 'navigation_service.dart'; -import 'providers.dart'; - -class MailService implements MimeSourceSubscriber { - MailService({required AsyncMimeSourceFactory mimeSourceFactory}) - : _mimeSourceFactory = mimeSourceFactory; - final AsyncMimeSourceFactory _mimeSourceFactory; - - static const _clientId = Id(name: 'Maily', version: '1.0'); - MessageSource? messageSource; - Account? _currentAccount; - Account? get currentAccount => _currentAccount; - final accounts = []; - UnifiedAccount? unifiedAccount; - - List? _accountsWithErrors; - bool get hasUnifiedAccount => unifiedAccount != null; - - static const String _keyAccounts = 'accts'; - final _storage = const FlutterSecureStorage(); - final _mailClientsPerAccount = {}; - final _mailboxesPerAccount = >{}; - late AppLocalizations _localizations; - AppLocalizations get localizations => _localizations; - late Settings _settings; - - List get accountsWithoutErrors { - final withErrors = _accountsWithErrors; - if (withErrors == null) { - return accounts; - } - - return accounts.where((account) => !withErrors.contains(account)).toList(); - } - - List get accountsWithErrors { - final withErrors = _accountsWithErrors; - - return withErrors ?? []; - } - - set localizations(AppLocalizations value) { - if (value != _localizations) { - _localizations = value; - if (unifiedAccount != null) { - unifiedAccount!.name = value.unifiedAccountName; - final mailboxes = _mailboxesPerAccount[unifiedAccount]! - .root - .children! - .map((c) => c.value); - for (final mailbox in mailboxes) { - String? name; - if (mailbox!.isInbox) { - name = value.unifiedFolderInbox; - } else if (mailbox.isDrafts) { - name = value.unifiedFolderDrafts; - } else if (mailbox.isTrash) { - name = value.unifiedFolderTrash; - } else if (mailbox.isSent) { - name = value.unifiedFolderSent; - } else if (mailbox.isArchive) { - name = value.unifiedFolderArchive; - } else if (mailbox.isJunk) { - name = value.unifiedFolderJunk; - } - if (name != null) { - mailbox.name = name; - } - } - } - } - } - - Future init(AppLocalizations localizations, Settings settings) async { - _settings = settings; - _localizations = localizations; - await _mimeSourceFactory.init(); - await _loadAccounts(); - messageSource = await _initMessageSource(); - } - - Future _loadAccounts() async { - final realAccounts = await loadRealMailAccounts(); - for (final realAccount in realAccounts) { - accounts.add(realAccount); - } - - _createUnifiedAccount(); - } - - Future> loadRealMailAccounts() async { - final jsonText = await _storage.read(key: _keyAccounts); - if (jsonText == null) { - return []; - } - final accountsJson = jsonDecode(jsonText) as List; - try { - // ignore: unnecessary_lambdas - return accountsJson.map((json) => RealAccount.fromJson(json)).toList(); - } catch (e) { - if (kDebugMode) { - print('Unable to parse accounts: $e'); - print(jsonText); - } - return []; - } - } - - Future search(MailSearch search) async { - final currentSource = messageSource; - if (currentSource != null && currentSource.supportsSearching) { - return currentSource.search(search); - } - final Account account = currentAccount ?? unifiedAccount ?? accounts.first; - final source = await _createMessageSource(null, account); - messageSource = source; - - return source.search(search); - } - - void _createUnifiedAccount() { - final mailAccountsForUnified = accounts.where((account) => - account is RealAccount && - !account.hasAttribute(RealAccount.attributeExcludeFromUnified)); - if (mailAccountsForUnified.length > 1) { - unifiedAccount = UnifiedAccount( - List.from(mailAccountsForUnified), - ); - final mailboxes = [ - Mailbox.virtual(_localizations.unifiedFolderInbox, [MailboxFlag.inbox]), - Mailbox.virtual( - _localizations.unifiedFolderDrafts, [MailboxFlag.drafts]), - Mailbox.virtual(_localizations.unifiedFolderSent, [MailboxFlag.sent]), - Mailbox.virtual(_localizations.unifiedFolderTrash, [MailboxFlag.trash]), - Mailbox.virtual( - _localizations.unifiedFolderArchive, [MailboxFlag.archive]), - Mailbox.virtual(_localizations.unifiedFolderJunk, [MailboxFlag.junk]), - ]; - final tree = Tree(Mailbox.virtual('', [])) - ..populateFromList(mailboxes, (child) => null); - _mailboxesPerAccount[unifiedAccount!] = tree; - } - } - - Future? _initMessageSource() { - final account = - unifiedAccount ?? ((accounts.isNotEmpty) ? accounts.first : null); - if (account != null) { - _currentAccount = account; - return _createMessageSource(null, account); - } - return null; - } - - Future _createMessageSource( - Mailbox? mailbox, - Account account, - ) async { - if (account is UnifiedAccount) { - final mimeSources = await _getUnifiedMimeSources(mailbox, account); - - return MultipleMessageSource( - account: account, - mimeSources, - mailbox == null ? _localizations.unifiedFolderInbox : mailbox.name, - mailbox?.flags.first ?? MailboxFlag.inbox, - ); - } else if (account is RealAccount) { - final mailClient = await _getClientAndStopPolling(account); - if (mailClient != null) { - if (mailbox == null) { - mailbox = await mailClient.selectInbox(); - } else { - await mailClient.selectMailbox(mailbox); - } - final source = _mimeSourceFactory.createMailboxMimeSource( - mailClient, - mailbox, - )..addSubscriber(this); - - return MailboxMessageSource.fromMimeSource( - source, - mailClient.account.email, - mailbox, - account: account, - ); - } - throw StateError('Unable to login for : ${account.key}'); - } else { - throw StateError('Unknown account type: ${account.runtimeType}'); - } - } - - Future> _getUnifiedMimeSources( - Mailbox? mailbox, - UnifiedAccount unifiedAccount, - ) async { - Future selectMailbox( - MailboxFlag flag, - RealAccount account, - ) async { - final client = await _getClientAndStopPolling(account); - if (client == null) { - _accountsWithErrors ??= []; - _accountsWithErrors!.add(account); - return null; - } - Mailbox? accountMailbox = client.getMailbox(flag); - if (accountMailbox == null) { - if (client.isConnected) { - await client.listMailboxes(); - accountMailbox = client.getMailbox(flag); - } - if (accountMailbox == null) { - if (kDebugMode) { - print( - 'unable to find mailbox with $flag in account ${client.account.name}'); - } - return null; - } - } - await client.selectMailbox(accountMailbox); - accountsWithErrors.remove(account); - return _mimeSourceFactory.createMailboxMimeSource(client, accountMailbox) - ..addSubscriber(this); - } - - Future> resolveFutures( - List> unresolvedFutures) async { - final results = await Future.wait(unresolvedFutures); - final mimeSources = - List.from(results.where((source) => source != null)); - return mimeSources; - } - - final futures = >[]; - final flag = mailbox?.flags.first ?? MailboxFlag.inbox; - for (final subAccount in unifiedAccount.accounts) { - futures.add(selectMailbox(flag, subAccount)); - } - return resolveFutures(futures); - } - - Future _getClientAndStopPolling(RealAccount account) async { - try { - final client = await getClientFor(account); - await client.stopPollingIfNeeded(); - if (!client.isConnected) { - await client.connect(); - } - return client; - } catch (e, s) { - if (kDebugMode) { - print('Unable to get client for ${account.email}: $e $s'); - } - return null; - } - } - - void _addGravatar(RealAccount account) { - final url = Gravatar.imageUrl( - account.email, - size: 400, - defaultImage: GravatarImage.retro, - ); - account.mailAccount.attributes[RealAccount.attributeGravatarImageUrl] = url; - } - - Future addAccount( - RealAccount newAccount, - MailClient mailClient, - ) async { - // TODO(RV): check if other account with the same name already exists - final existing = accounts.firstWhereOrNull((account) => - account is RealAccount && account.email == newAccount.email); - if (existing != null) { - await removeAccount(existing as RealAccount); - } - newAccount = await _checkForAddingSentMessages(newAccount); - _currentAccount = newAccount; - accounts.add(newAccount); - await _loadMailboxesFor(mailClient); - _mailClientsPerAccount[newAccount] = mailClient; - _addGravatar(newAccount); - if (!newAccount.hasAttribute(RealAccount.attributeExcludeFromUnified)) { - final unified = unifiedAccount; - if (unified != null) { - unified.accounts.add(newAccount); - } else { - _createUnifiedAccount(); - } - } - final source = await getMessageSourceFor(newAccount); - messageSource = source; - await saveAccounts(); - - return true; - } - - List getSenders() { - final senders = []; - for (final account in accounts) { - if (account is! RealAccount) { - continue; - } - senders.add(Sender(account.fromAddress, account)); - for (final alias in account.aliases) { - senders.add(Sender(alias, account)); - } - } - return senders; - } - - MessageBuilder mailto( - Uri mailto, - MimeMessage originatingMessage, - Settings settings, - ) { - final senders = getSenders(); - final searchFor = senders.map((s) => s.address).toList(); - final searchIn = originatingMessage.recipientAddresses - .map((email) => MailAddress('', email)) - .toList(); - var fromAddress = MailAddress.getMatch(searchFor, searchIn); - if (fromAddress == null) { - if (settings.preferredComposeMailAddress != null) { - fromAddress = searchFor.firstWhereOrNull( - (address) => address.email == settings.preferredComposeMailAddress, - ); - } - fromAddress ??= searchFor.first; - } - - return MessageBuilder.prepareMailtoBasedMessage(mailto, fromAddress); - } - - Future reorderAccounts(List newOrder) { - accounts.clear(); - accounts.addAll(newOrder); - return saveAccounts(); - } - - Future saveAccounts() { - final accountsJson = - accounts.whereType().map((a) => a.toJson()).toList(); - final json = jsonEncode(accountsJson); - return _storage.write(key: _keyAccounts, value: json); - } - - Future getClientFor( - RealAccount account, - ) async => - _mailClientsPerAccount[account] ?? await createClientFor(account); - - Future createClientFor( - RealAccount account, { - bool store = true, - }) async { - final client = createMailClient(account.mailAccount); - if (store) { - _mailClientsPerAccount[account] = client; - } - await _connect(client); - await _loadMailboxesFor(client); - - return client; - } - - Future getClientForAccountWithEmail(String? accountEmail) { - final account = getAccountForEmail(accountEmail)!; - return getClientFor(account); - } - - Future getMessageSourceFor( - Account account, { - Mailbox? mailbox, - bool switchToAccount = false, - }) async { - final source = await _createMessageSource(mailbox, account); - if (switchToAccount) { - messageSource = source; - _currentAccount = account; - } - return source; - } - - RealAccount? getAccountFor(MailAccount mailAccount) => - accounts.firstWhereOrNull( - (a) => a is RealAccount && a.mailAccount == mailAccount, - ) as RealAccount?; - - RealAccount? getAccountForEmail(String? accountEmail) => - accounts.firstWhereOrNull( - (a) => a is RealAccount && a.email == accountEmail, - )! as RealAccount; - - void applyFolderNameSettings(Settings settings) { - for (final client in _mailClientsPerAccount.values) { - _setMailboxNames(settings, client); - } - } - - void _setMailboxNames(Settings settings, MailClient client) { - final folderNameSetting = settings.folderNameSetting; - if (client.mailboxes == null) { - return; - } - if (folderNameSetting == FolderNameSetting.server) { - for (final mailbox in client.mailboxes!) { - mailbox.setNameFromPath(); - } - } else { - var names = settings.customFolderNames; - if (names == null || folderNameSetting == FolderNameSetting.localized) { - final l = localizations; - names = [ - l.folderInbox, - l.folderDrafts, - l.folderSent, - l.folderTrash, - l.folderArchive, - l.folderJunk - ]; - } - final boxes = client.mailboxes; - if (boxes != null) { - for (final mailbox in boxes) { - if (mailbox.isInbox) { - mailbox.name = names[0]; - } else if (mailbox.isDrafts) { - mailbox.name = names[1]; - } else if (mailbox.isSent) { - mailbox.name = names[2]; - } else if (mailbox.isTrash) { - mailbox.name = names[3]; - } else if (mailbox.isArchive) { - mailbox.name = names[4]; - } else if (mailbox.isJunk) { - mailbox.name = names[5]; - } - } - } - } - } - - Future _loadMailboxesFor(MailClient client) async { - final account = getAccountFor(client.account); - if (account == null) { - if (kDebugMode) { - print('Unable to find account for ${client.account}'); - } - - return; - } - final mailboxTree = - await client.listMailboxesAsTree(createIntermediate: false); - final settings = _settings; - if (settings.folderNameSetting != FolderNameSetting.server) { - _setMailboxNames(settings, client); - } - - _mailboxesPerAccount[account] = mailboxTree; - } - - Tree? getMailboxTreeFor(Account account) => - _mailboxesPerAccount[account]; - - Future createMailbox( - RealAccount account, - String mailboxName, - Mailbox? parentMailbox, - ) async { - final mailClient = await getClientFor(account); - await mailClient.createMailbox(mailboxName, parentMailbox: parentMailbox); - await _loadMailboxesFor(mailClient); - } - - Future deleteMailbox(RealAccount account, Mailbox mailbox) async { - final mailClient = await getClientFor(account); - await mailClient.deleteMailbox(mailbox); - await _loadMailboxesFor(mailClient); - } - - Future saveAccount(MailAccount? account) { - // print('saving account ${account.name}'); - return saveAccounts(); - } - - void markAccountAsTestedForPlusAlias(RealAccount account) { - account.setAttribute(RealAccount.attributePlusAliasTested, true); - } - - bool hasAccountBeenTestedForPlusAlias(RealAccount account) => - account.hasAttribute(RealAccount.attributePlusAliasTested); - - /// Creates a new random plus alias based on the primary email address of this account. - String generateRandomPlusAlias(RealAccount account) { - final mail = account.email; - final atIndex = mail.lastIndexOf('@'); - if (atIndex == -1) { - throw StateError( - 'unable to create alias based on invalid email <$mail>.'); - } - final random = MessageBuilder.createRandomId(length: 8); - return '${mail.substring(0, atIndex)}+$random${mail.substring(atIndex)}'; - } - - Sender generateRandomPlusAliasSender(Sender sender) { - final email = generateRandomPlusAlias(sender.account); - return Sender(MailAddress(null, email), sender.account); - } - - Future testRemoveAccount(Account account) async { - if (account == currentAccount) { - final nextAccount = hasUnifiedAccount - ? unifiedAccount - : accounts.isNotEmpty - ? accounts.first - : null; - _currentAccount = nextAccount; - if (nextAccount != null) { - messageSource = await _createMessageSource(null, nextAccount); - } else { - messageSource = null; - await locator().push(Routes.welcome, clear: true); - } - } - } - - Future removeAccount(RealAccount account) async { - accounts.remove(account); - _mailboxesPerAccount.remove(account); - _mailClientsPerAccount.remove(account); - final withErrors = _accountsWithErrors; - if (withErrors != null) { - withErrors.remove(account); - } - try { - final client = _mailClientsPerAccount[account]; - await client?.disconnect(); - } catch (e) { - // ignore - } - if (!account.excludeFromUnified) { - // updates the unified account - await excludeAccountFromUnified( - account, - true, - ); - } - if (account == currentAccount) { - final nextAccount = hasUnifiedAccount - ? unifiedAccount - : accounts.isNotEmpty - ? accounts.first - : null; - _currentAccount = nextAccount; - if (nextAccount != null) { - messageSource = await _createMessageSource(null, nextAccount); - } else { - messageSource = null; - await locator().push(Routes.welcome, clear: true); - } - } - - await saveAccounts(); - } - - String? getEmailDomain(String email) { - final startIndex = email.lastIndexOf('@'); - if (startIndex == -1) { - return null; - } - return email.substring(startIndex + 1); - } - - Future connectAccount(MailAccount mailAccount) async { - final mailClient = createMailClient(mailAccount); - await _connect(mailClient); - - return mailClient; - } - - Future connectFirstTime(MailAccount mailAccount) async { - var usedMailAccount = mailAccount; - var mailClient = createMailClient(usedMailAccount); - try { - await _connect(mailClient); - } on MailException { - final email = usedMailAccount.email; - var preferredUserName = - usedMailAccount.incoming.serverConfig.getUserName(email); - if (preferredUserName == null || preferredUserName == email) { - final atIndex = mailAccount.email.lastIndexOf('@'); - preferredUserName = usedMailAccount.email.substring(0, atIndex); - usedMailAccount = - usedMailAccount.copyWithAuthenticationUserName(preferredUserName); - await mailClient.disconnect(); - mailClient = createMailClient(usedMailAccount); - try { - await _connect(mailClient); - } on MailException { - await mailClient.disconnect(); - - return null; - } - } - } - - return ConnectedAccount(usedMailAccount, mailClient); - } - - Future reconnect(RealAccount account) async { - _mailClientsPerAccount.remove(account); - try { - final source = await getMessageSourceFor(account); - final accountsWithErrors = _accountsWithErrors; - if (accountsWithErrors != null) { - accountsWithErrors.remove(account); - } - accountsWithoutErrors.add(account); - //TODO update unified account message source after connecting account - - return true; - } catch (e) { - return false; - } - } - - /// Disconnects the mail client belonging to [account]. - Future disconnect(RealAccount account) async { - final client = await getClientFor(account); - await client.disconnect(); - } - - MailClient createMailClient(MailAccount mailAccount) { - final bool isLogEnabled = kDebugMode || - (mailAccount.attributes[RealAccount.attributeEnableLogging] ?? false); - return MailClient( - mailAccount, - isLogEnabled: isLogEnabled, - logName: mailAccount.name, - eventBus: AppEventBus.eventBus, - clientId: _clientId, - refresh: _refreshToken, - onConfigChanged: saveAccount, - downloadSizeLimit: 32 * 1024, - ); - } - - Future _connect(MailClient client) => client.connect(); - - Future _refreshToken( - MailClient mailClient, OauthToken expiredToken) { - final providerId = expiredToken.provider; - if (providerId == null) { - throw MailException( - mailClient, 'no provider registered for token $expiredToken'); - } - final provider = locator()[providerId]; - if (provider == null) { - throw MailException(mailClient, - 'no provider "$providerId" found - token: $expiredToken'); - } - final oauthClient = provider.oauthClient; - if (oauthClient == null || !oauthClient.isEnabled) { - throw MailException( - mailClient, 'provider $providerId has no valid OAuth configuration'); - } - return oauthClient.refresh(expiredToken); - } - - Future _checkForAddingSentMessages(RealAccount account) { - final mailAccount = account.mailAccount; - final addsSendMailAutomatically = [ - 'outlook.office365.com', - 'imap.gmail.com' - ].contains(mailAccount.incoming.serverConfig.hostname); - - return Future.value( - account.copyWith( - mailAccount: mailAccount.copyWithAttribute( - RealAccount.attributeSentMailAddedAutomatically, - addsSendMailAutomatically, - ), - ), - ); - //TODO later test sending of messages - } - - List getMailClients() { - final mailClients = []; - final existingMailClients = _mailClientsPerAccount.values; - for (final account in accounts) { - if (account is RealAccount) { - var client = existingMailClients.firstWhereOrNull( - (client) => client.account.email == account.mailAccount.email); - client ??= createMailClient(account.mailAccount); - mailClients.add(client); - } - } - - return mailClients; - } - - /// Checks the connection status and resumes the connection if necessary - Future resume() { - final futures = []; - for (final client in _mailClientsPerAccount.values) { - futures.add(client.resume()); - } - if (futures.isEmpty) { - return Future.value(); - } - - return Future.wait(futures); - } - - Future excludeAccountFromUnified( - RealAccount account, - bool exclude, - ) async { - account.excludeFromUnified = exclude; - final unified = unifiedAccount; - if (exclude) { - if (unified != null) { - unified.removeAccount(account); - } - } else { - if (unified == null) { - _createUnifiedAccount(); - } else { - unified.addAccount(account); - } - } - if (currentAccount == unified && unified != null) { - messageSource = await _createMessageSource(null, unified); - } - - return saveAccounts(); - } - - bool hasError(Account? account) { - final accts = _accountsWithErrors; - return accts != null && accts.contains(account); - } - - bool hasAccountsWithErrors() { - final accts = _accountsWithErrors; - return accts != null && accts.isNotEmpty; - } - - @override - void onMailArrived( - MimeMessage mime, - AsyncMimeSource source, { - int index = 0, - }) { - source.mailClient.lowLevelIncomingMailClient - .logApp('new message: ${mime.decodeSubject()}'); - if (!mime.isSeen && source.isInbox) { - locator() - .sendLocalNotificationForMail(mime, source.mailClient.account.email); - } - } - - @override - void onMailCacheInvalidated(AsyncMimeSource source) { - // ignore - } - - @override - void onMailFlagsUpdated(MimeMessage mime, AsyncMimeSource source) { - if (mime.isSeen) { - locator().cancelNotificationForMail(mime); - } - } - - @override - void onMailVanished(MimeMessage mime, AsyncMimeSource source) { - locator().cancelNotificationForMail(mime); - } -} diff --git a/lib/services/navigation_service.dart b/lib/services/navigation_service.dart deleted file mode 100644 index 5f0bcdd..0000000 --- a/lib/services/navigation_service.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:enough_platform_widgets/enough_platform_widgets.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; - -import '../routes.dart'; - -class NavigationService { - GlobalKey get navigatorKey => Routes.navigatorKey; - - BuildContext? get currentContext => navigatorKey.currentContext; - String? get currentRouteName => _currentRouteName; - String? _currentRouteName; - - Future push( - String routeName, { - Object? arguments, - bool replace = false, - bool fade = false, - bool clear = false, - bool containsModals = false, - }) { - _currentRouteName = routeName; - final page = AppRouter.generatePage(routeName, arguments); - final state = navigatorKey.currentState; - if (state == null) { - return Future.value(); - } - Route route; - if (containsModals) { - route = MaterialWithModalsPageRoute(builder: (_) => page); - } else if (fade && !PlatformInfo.isCupertino) { - route = FadeRoute(page: page); - } else { - route = PlatformInfo.isCupertino - ? CupertinoPageRoute(builder: (_) => page) - : MaterialPageRoute(builder: (_) => page); - } - if (clear) { - state.popUntil((route) => false); - } - - return replace ? state.pushReplacement(route) : state.push(route); - } - - // void replace(String oldRouteName, String newRouteName, {Object arguments}) { - // final page = AppRouter.generatePage(newRouteName, arguments); - // final newRoute = MaterialPageRoute(builder: (context) => page); - // final oldRoute = history.getRoute(oldRouteName); - // navigatorKey.currentState.replace(oldRoute: oldRoute, newRoute: newRoute); - // } - - // void replaceBelow(String anchorRouteName, String newRouteName, - // {Object arguments}) { - // final page = AppRouter.generatePage(newRouteName, arguments); - // final newRoute = MaterialPageRoute(builder: (context) => page); - // final anchorRoute = history.getRoute(anchorRouteName); - // navigatorKey.currentState - // .replaceRouteBelow(anchorRoute: anchorRoute, newRoute: newRoute); - // } - - void popUntil(String routeName) { - final state = navigatorKey.currentState; - if (state == null) { - return; - } - // history.popUntil(routeName); - state.popUntil(ModalRoute.withName(routeName)); - _currentRouteName = routeName; - } - - void pop([T? result]) { - final state = navigatorKey.currentState; - if (state == null) { - return; - } - // history.pop(); - state.pop(result); - _currentRouteName = null; - } -} - -class FadeRoute extends PageRouteBuilder { - FadeRoute({required this.page}) - : super( - pageBuilder: ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - ) => - page, - transitionsBuilder: ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) => - FadeTransition( - opacity: animation, - child: child, - ), - ); - - final Widget page; -} diff --git a/lib/settings/view/settings_accounts_screen.dart b/lib/settings/view/settings_accounts_screen.dart index f95b238..e44318f 100644 --- a/lib/settings/view/settings_accounts_screen.dart +++ b/lib/settings/view/settings_accounts_screen.dart @@ -3,16 +3,15 @@ import 'dart:async'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/model.dart'; import '../../account/provider.dart'; import '../../localization/app_localizations.g.dart'; import '../../localization/extension.dart'; -import '../../locator.dart'; import '../../routes.dart'; import '../../screens/base.dart'; -import '../../services/navigation_service.dart'; import '../../widgets/button_text.dart'; /// Allows to select an account for editing and to re-order the accounts @@ -62,14 +61,15 @@ class SettingsAccountsScreen extends HookConsumerWidget { PlatformListTile( leading: Icon(CommonPlatformIcons.account), title: Text(account.name), - onTap: () => locator() - .push(Routes.accountEdit, arguments: account), + onTap: () => context.pushNamed( + Routes.accountEdit, + pathParameters: {Routes.pathParameterEmail: account.email}, + ), ), PlatformListTile( leading: Icon(CommonPlatformIcons.add), title: Text(localizations.drawerEntryAddAccount), - onTap: () => - locator().push(Routes.accountAdd), + onTap: () => context.pushNamed(Routes.accountAdd), ), if (accounts.length > 1) Padding( diff --git a/lib/settings/view/settings_screen.dart b/lib/settings/view/settings_screen.dart index 0ebcdf6..f4c2f38 100644 --- a/lib/settings/view/settings_screen.dart +++ b/lib/settings/view/settings_screen.dart @@ -1,11 +1,10 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../../localization/extension.dart'; -import '../../locator.dart'; import '../../routes.dart'; import '../../screens/base.dart'; -import '../../services/navigation_service.dart'; import '../../util/localized_dialog_helper.dart'; class SettingsScreen extends StatelessWidget { @@ -27,73 +26,69 @@ class SettingsScreen extends StatelessWidget { PlatformListTile( title: Text(localizations.securitySettingsTitle), onTap: () { - locator().push(Routes.settingsSecurity); + context.pushNamed(Routes.settingsSecurity); }, ), PlatformListTile( title: Text(localizations.settingsActionAccounts), onTap: () { - locator().push(Routes.settingsAccounts); + context.pushNamed(Routes.settingsAccounts); }, ), PlatformListTile( title: Text(localizations.swipeSettingTitle), onTap: () { - locator().push(Routes.settingsSwipe); + context.pushNamed(Routes.settingsSwipe); }, ), PlatformListTile( title: Text(localizations.signatureSettingsTitle), onTap: () { - locator() - .push(Routes.settingsSignature, containsModals: true); + context.pushNamed(Routes.settingsSignature); }, ), PlatformListTile( title: Text(localizations.defaultSenderSettingsTitle), onTap: () { - locator() - .push(Routes.settingsDefaultSender); + context.pushNamed(Routes.settingsDefaultSender); }, ), if (!PlatformInfo.isCupertino) PlatformListTile( title: Text(localizations.settingsActionDesign), onTap: () { - locator().push(Routes.settingsDesign); + context.pushNamed(Routes.settingsDesign); }, ), PlatformListTile( title: Text(localizations.languageSettingTitle), onTap: () { - locator().push(Routes.settingsLanguage); + context.pushNamed(Routes.settingsLanguage); }, ), PlatformListTile( title: Text(localizations.settingsFolders), onTap: () { - locator().push(Routes.settingsFolders); + context.pushNamed(Routes.settingsFolders); }, ), PlatformListTile( title: Text(localizations.settingsReadReceipts), onTap: () { - locator() - .push(Routes.settingsReadReceipts); + context.pushNamed(Routes.settingsReadReceipts); }, ), PlatformListTile( title: Text(localizations.replySettingsTitle), onTap: () { - locator() - .push(Routes.settingsReplyFormat); + context.pushNamed(Routes.settingsReplyFormat); }, ), const Divider(), PlatformListTile( title: Text(localizations.settingsActionFeedback), onTap: () { - locator().push(Routes.settingsFeedback); + context.pushNamed(Routes.settingsFeedback); }, ), PlatformListTile( @@ -104,7 +99,7 @@ class SettingsScreen extends StatelessWidget { ), PlatformListTile( onTap: () { - locator().push(Routes.welcome); + context.pushNamed(Routes.welcome); }, title: Text(localizations.settingsActionWelcome), ), @@ -112,8 +107,7 @@ class SettingsScreen extends StatelessWidget { PlatformListTile( title: Text(localizations.settingsDevelopment), onTap: () { - locator() - .push(Routes.settingsDevelopment); + context.pushNamed(Routes.settingsDevelopment); }, ), ], diff --git a/lib/settings/view/settings_swipe_screen.dart b/lib/settings/view/settings_swipe_screen.dart index becc194..33e3832 100644 --- a/lib/settings/view/settings_swipe_screen.dart +++ b/lib/settings/view/settings_swipe_screen.dart @@ -1,13 +1,12 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../localization/extension.dart'; -import '../../locator.dart'; import '../../models/swipe.dart'; import '../../screens/base.dart'; -import '../../services/navigation_service.dart'; import '../../util/localized_dialog_helper.dart'; import '../../widgets/button_text.dart'; import '../provider.dart'; @@ -108,7 +107,7 @@ class _SwipeSetting extends HookConsumerWidget { ], ), onPressed: () { - locator().pop(action); + context.pop(action); }, ), ) diff --git a/lib/models/shared_data.dart b/lib/share/model.dart similarity index 87% rename from lib/models/shared_data.dart rename to lib/share/model.dart index dbced4c..37b352a 100644 --- a/lib/models/shared_data.dart +++ b/lib/share/model.dart @@ -4,10 +4,12 @@ import 'dart:typed_data'; import 'package:enough_html_editor/enough_html_editor.dart'; import 'package:enough_mail/enough_mail.dart'; +/// State of a shared data item enum SharedDataAddState { added, notAdded } +/// Result of adding a shared data item class SharedDataAddResult { - + /// Creates a new [SharedDataAddResult] const SharedDataAddResult(this.state, [this.details]); static const added = SharedDataAddResult(SharedDataAddState.added); static const notAdded = SharedDataAddResult(SharedDataAddState.notAdded); @@ -15,9 +17,12 @@ class SharedDataAddResult { final dynamic details; } +/// Shared data item abstract class SharedData { - + /// Creates a new [SharedData] SharedData(this.mediaType); + + /// The media type of the item, e.g. `image/jpeg` final MediaType mediaType; Future addToMessageBuilder(MessageBuilder builder); @@ -57,6 +62,7 @@ class SharedBinary extends SharedData { Future addToMessageBuilder( MessageBuilder builder) async { builder.addBinary(data!, mediaType, filename: filename); + return SharedDataAddResult.added; } @@ -64,16 +70,24 @@ class SharedBinary extends SharedData { Future addToEditor(HtmlEditorApi editorApi) async { if (mediaType.isImage) { await editorApi.insertImageData( - data!, mediaType.sub.mediaType.toString()); + data!, + mediaType.sub.mediaType.toString(), + ); + return SharedDataAddResult.added; } + return SharedDataAddResult.notAdded; } } class SharedText extends SharedData { - SharedText(this.text, MediaType? mediaType, {this.subject}) - : super(mediaType ?? MediaType.textPlain); + SharedText( + this.text, + MediaType? mediaType, { + this.subject, + }) : super(mediaType ?? MediaType.textPlain); + final String text; final String? subject; diff --git a/lib/share/provider.dart b/lib/share/provider.dart new file mode 100644 index 0000000..5b3da1c --- /dev/null +++ b/lib/share/provider.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../account/provider.dart'; +import '../app_lifecycle/provider.dart'; +import '../models/compose_data.dart'; +import '../routes.dart'; +import 'model.dart'; + +part 'provider.g.dart'; + +/// Callback to register a share handler +typedef SharedDataCallback = Future Function(List sharedData); + +/// Allows to registered shared data callbacks +SharedDataCallback? onSharedData; + +/// Handles incoming shares +@Riverpod(keepAlive: true) +class IncomingShare extends _$IncomingShare { + static const _platform = MethodChannel('app.channel.shared.data'); + + @override + Future build() async { + final isResumed = ref.watch(appLifecycleStateProvider + .select((value) => value == AppLifecycleState.resumed)); + if (isResumed) { + if (Platform.isAndroid) { + final shared = await _platform.invokeMethod('getSharedData'); + //print('checkForShare: received data: $shared'); + if (shared != null) { + await _composeWithSharedData(shared); + } + } + } + } + + Future _composeWithSharedData( + Map shared, + ) async { + final sharedData = await _collectSharedData(shared); + if (sharedData.isEmpty) { + return; + } + final callback = onSharedData; + if (callback != null) { + return callback(sharedData); + } else { + MessageBuilder builder; + final firstData = sharedData.first; + final account = ref.read(currentRealAccountProvider); + if (firstData is SharedMailto && account != null) { + builder = MessageBuilder.prepareMailtoBasedMessage( + firstData.mailto, + account.fromAddress, + ); + } else { + builder = MessageBuilder(); + for (final data in sharedData) { + await data.addToMessageBuilder(builder); + } + } + final composeData = ComposeData(null, builder, ComposeAction.newMessage); + final context = Routes.navigatorKey.currentContext; + if (context != null && context.mounted) { + unawaited(context.pushNamed(Routes.mailCompose, extra: composeData)); + } + } + } + + Future> _collectSharedData( + Map shared, + ) async { + final sharedData = []; + final String? mimeTypeText = shared['mimeType']; + final mediaType = (mimeTypeText == null || mimeTypeText.contains('*')) + ? null + : MediaType.fromText(mimeTypeText); + final int? length = shared['length']; + final String? text = shared['text']; + if (kDebugMode) { + print('share text: "$text"'); + } + if (length != null && length > 0) { + for (var i = 0; i < length; i++) { + final String? filename = shared['name.$i']; + final Uint8List? data = shared['data.$i']; + final String? typeName = shared['type.$i']; + final localMediaType = (typeName != null && typeName != 'null') + ? MediaType.fromText(typeName) + : mediaType ?? + (filename != null + ? MediaType.guessFromFileName(filename) + : MediaType.textPlain); + sharedData.add(SharedBinary(data, filename, localMediaType)); + if (kDebugMode) { + print( + 'share: loaded ${localMediaType.text} "$filename" ' + 'with ${data?.length} bytes', + ); + } + } + } else if (text != null) { + if (text.startsWith('mailto:')) { + final mailto = Uri.parse(text); + sharedData.add(SharedMailto(mailto)); + } else { + sharedData.add(SharedText(text, mediaType, subject: shared['subject'])); + } + } + + return sharedData; + } +} diff --git a/lib/share/provider.g.dart b/lib/share/provider.g.dart new file mode 100644 index 0000000..c8482f2 --- /dev/null +++ b/lib/share/provider.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$incomingShareHash() => r'32114557815ae92e27439dc727c867060f784d2f'; + +/// Handles incoming shares +/// +/// Copied from [IncomingShare]. +@ProviderFor(IncomingShare) +final incomingShareProvider = + AsyncNotifierProvider.internal( + IncomingShare.new, + name: r'incomingShareProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$incomingShareHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$IncomingShare = AsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/widgets/attachment_chip.dart b/lib/widgets/attachment_chip.dart index 2ee5193..37218e6 100644 --- a/lib/widgets/attachment_chip.dart +++ b/lib/widgets/attachment_chip.dart @@ -2,6 +2,7 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail_flutter/enough_mail_flutter.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../localization/extension.dart'; import '../locator.dart'; @@ -11,7 +12,6 @@ import '../routes.dart'; import '../screens/media_screen.dart'; import '../services/i18n_service.dart'; import '../services/icon_service.dart'; -import '../services/navigation_service.dart'; import 'button_text.dart'; import 'ical_interactive_media.dart'; @@ -203,16 +203,16 @@ class _AttachmentChipState extends State { if (mime != null) { final message = Message.embedded(mime, widget.message); - return locator().push( + return context.pushNamed( Routes.mailDetails, - arguments: message, + extra: message, ); } } - return locator().push( + return context.pushNamed( Routes.interactiveMedia, - arguments: media, + extra: media, ); } diff --git a/lib/widgets/attachment_compose_bar.dart b/lib/widgets/attachment_compose_bar.dart index fb7ee12..c00c4f0 100644 --- a/lib/widgets/attachment_compose_bar.dart +++ b/lib/widgets/attachment_compose_bar.dart @@ -4,7 +4,9 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_media/enough_media.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../localization/app_localizations.g.dart'; @@ -15,7 +17,6 @@ import '../models/message.dart'; import '../routes.dart'; import '../services/icon_service.dart'; import '../services/key_service.dart'; -import '../services/navigation_service.dart'; import '../util/http_helper.dart'; import '../util/localized_dialog_helper.dart'; import 'ical_composer.dart'; @@ -168,23 +169,29 @@ class AddAttachmentPopupButton extends ConsumerWidget { changed = await addAttachmentFile(fileType: FileType.audio); break; case 4: // location - final result = - await locator().push(Routes.locationPicker); - if (result != null) { - composeData.messageBuilder.addBinary( - result, - MediaSubtype.imagePng.mediaType, - filename: 'location.jpg', - ); - changed = true; + if (context.mounted) { + final result = + await context.pushNamed(Routes.locationPicker); + if (result != null) { + composeData.messageBuilder.addBinary( + result, + MediaSubtype.imagePng.mediaType, + filename: 'location.jpg', + ); + changed = true; + } } break; case 5: // gif / sticker / emoji file - changed = await addAttachmentGif(context, localizations); + if (context.mounted) { + changed = await addAttachmentGif(context, localizations); + } break; case 6: // appointment - changed = - await addAttachmentAppointment(context, ref, localizations); + if (context.mounted) { + changed = + await addAttachmentAppointment(context, ref, localizations); + } break; } if (changed) { @@ -362,9 +369,9 @@ class ComposeAttachment extends ConsumerWidget { final mime = MimeMessage.parseFromData(attachment.data!); final message = Message.embedded(mime, parentMessage); - return locator().push( + return context.pushNamed( Routes.mailDetails, - arguments: message, + extra: message, ); } if (attachment.mediaType.sub == MediaSubtype.applicationIcs || @@ -380,12 +387,12 @@ class ComposeAttachment extends ConsumerWidget { attachment.part.text = update.toString(); } - return; + return Future.value(); } - return locator().push( + return context.pushNamed( Routes.interactiveMedia, - arguments: interactiveMedia, + extra: interactiveMedia, ); }, contextMenuEntries: [ diff --git a/lib/widgets/editor_extensions.dart b/lib/widgets/editor_extensions.dart index 6124a5f..d30e6ea 100644 --- a/lib/widgets/editor_extensions.dart +++ b/lib/widgets/editor_extensions.dart @@ -3,10 +3,9 @@ import 'package:enough_ascii_art/enough_ascii_art.dart'; import 'package:enough_html_editor/enough_html_editor.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../localization/extension.dart'; -import '../locator.dart'; -import '../services/navigation_service.dart'; import '../util/localized_dialog_helper.dart'; import 'button_text.dart'; @@ -108,8 +107,7 @@ class _EditorArtExtensionWidgetState extends State { if (text != null && text.isNotEmpty) { widget.editorApi.insertText(text); } - final navService = locator(); - navService.pop(); + context.pop(); }, ), const Divider(), diff --git a/lib/widgets/menu_with_badge.dart b/lib/widgets/menu_with_badge.dart index 7b83a51..6f3aa7b 100644 --- a/lib/widgets/menu_with_badge.dart +++ b/lib/widgets/menu_with_badge.dart @@ -1,11 +1,10 @@ import 'dart:io'; import 'package:badges/badges.dart' as badges; -import '../locator.dart'; -import '../services/navigation_service.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; class MenuWithBadge extends StatelessWidget { const MenuWithBadge({ @@ -18,28 +17,25 @@ class MenuWithBadge extends StatelessWidget { @override Widget build(BuildContext context) => DensePlatformIconButton( - icon: badges.Badge( - badgeContent: badgeContent, - child: _buildIndicator(context), - ), - onPressed: () { - if (Platform.isIOS) { - // go back - locator().pop(); - } else { - Scaffold.of(context).openDrawer(); - } - }, - ); + icon: badges.Badge( + badgeContent: badgeContent, + child: _buildIndicator(context), + ), + onPressed: () { + if (Platform.isIOS) { + // go back + context.pop(); + } else { + Scaffold.of(context).openDrawer(); + } + }, + ); Widget _buildIndicator(BuildContext context) { if (Platform.isIOS) { final iOSText = this.iOSText; - if (iOSText != null) { - return Text(iOSText); - } else { - return const Icon(CupertinoIcons.back); - } + + return iOSText != null ? Text(iOSText) : const Icon(CupertinoIcons.back); } else { return const Icon(Icons.menu); } diff --git a/lib/widgets/message_actions.dart b/lib/widgets/message_actions.dart index 2cea274..b645bad 100644 --- a/lib/widgets/message_actions.dart +++ b/lib/widgets/message_actions.dart @@ -2,6 +2,7 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../account/model.dart'; @@ -12,9 +13,7 @@ import '../models/compose_data.dart'; import '../models/message.dart'; import '../notification/service.dart'; import '../routes.dart'; -import '../services/i18n_service.dart'; import '../services/icon_service.dart'; -import '../services/navigation_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/model.dart'; import '../settings/provider.dart'; @@ -55,25 +54,25 @@ class MessageActions extends HookConsumerWidget { void onOverflowChoiceSelected(_OverflowMenuChoice result) { switch (result) { case _OverflowMenuChoice.reply: - _reply(ref); + _reply(context, ref); break; case _OverflowMenuChoice.replyAll: - _replyAll(ref); + _replyAll(context, ref); break; case _OverflowMenuChoice.forward: - _forward(ref); + _forward(context, ref); break; case _OverflowMenuChoice.forwardAsAttachment: - _forwardAsAttachment(ref); + _forwardAsAttachment(context, ref); break; case _OverflowMenuChoice.forwardAttachments: - _forwardAttachments(ref); + _forwardAttachments(context, ref); break; case _OverflowMenuChoice.delete: - _delete(); + _delete(context); break; case _OverflowMenuChoice.inbox: - _moveToInbox(); + _moveToInbox(context); break; case _OverflowMenuChoice.seen: _toggleSeen(); @@ -85,10 +84,10 @@ class MessageActions extends HookConsumerWidget { _move(context); break; case _OverflowMenuChoice.junk: - _moveJunk(); + _moveJunk(context); break; case _OverflowMenuChoice.archive: - _moveArchive(); + _moveArchive(context); break; case _OverflowMenuChoice.redirect: _redirectMessage(context, ref); @@ -121,25 +120,25 @@ class MessageActions extends HookConsumerWidget { const Spacer(), DensePlatformIconButton( icon: Icon(iconService.messageActionReply), - onPressed: () => _reply(ref), + onPressed: () => _reply(context, ref), ), DensePlatformIconButton( icon: Icon(iconService.messageActionReplyAll), - onPressed: () => _replyAll(ref), + onPressed: () => _replyAll(context, ref), ), DensePlatformIconButton( icon: Icon(iconService.messageActionForward), - onPressed: () => _forward(ref), + onPressed: () => _forward(context, ref), ), if (message.source.isTrash) DensePlatformIconButton( icon: Icon(iconService.messageActionMoveToInbox), - onPressed: _moveToInbox, + onPressed: () => _moveToInbox(context), ) else if (!message.isEmbedded) DensePlatformIconButton( icon: Icon(iconService.messageActionDelete), - onPressed: _delete, + onPressed: () => _delete(context), ), PlatformPopupMenuButton<_OverflowMenuChoice>( onSelected: onOverflowChoiceSelected, @@ -304,11 +303,11 @@ class MessageActions extends HookConsumerWidget { // } // } - void _replyAll(WidgetRef ref) { - _reply(ref, all: true); + void _replyAll(BuildContext context, WidgetRef ref) { + _reply(context, ref, all: true); } - void _reply(WidgetRef ref, {all = false}) { + void _reply(BuildContext context, WidgetRef ref, {all = false}) { final account = message.account; final builder = MessageBuilder.prepareReplyToMessage( @@ -318,7 +317,7 @@ class MessageActions extends HookConsumerWidget { handlePlusAliases: account is RealAccount && account.supportsPlusAliases, replyAll: all, ); - _navigateToCompose(ref, message, builder, ComposeAction.answer); + _navigateToCompose(context, ref, message, builder, ComposeAction.answer); } Future _redirectMessage(BuildContext context, WidgetRef ref) async { @@ -417,22 +416,22 @@ class MessageActions extends HookConsumerWidget { } } - Future _delete() async { - locator().pop(); + Future _delete(BuildContext context) async { + context.pop(); await message.source.deleteMessages( [message], - locator().localizations.resultDeleted, + context.text.resultDeleted, ); } void _move(BuildContext context) { - final localizations = locator().localizations; + final localizations = context.text; LocalizedDialogHelper.showWidgetDialog( context, SingleChildScrollView( child: MailboxTree( account: message.account, - onSelected: _moveTo, + onSelected: (mailbox) => _moveTo(context, mailbox), // TODO(RV): retrieve the current selected mailbox in a different way // current: message.mailClient.selectedMailbox, ), @@ -442,10 +441,11 @@ class MessageActions extends HookConsumerWidget { ); } - Future _moveTo(Mailbox mailbox) async { - locator().pop(); // alert - locator().pop(); // detail view - final localizations = locator().localizations; + Future _moveTo(BuildContext context, Mailbox mailbox) async { + context + ..pop() // alert + ..pop(); // detail view + final localizations = context.text; final source = message.source; await source.moveMessage( message, @@ -454,36 +454,45 @@ class MessageActions extends HookConsumerWidget { ); } - Future _moveJunk() async { + Future _moveJunk(BuildContext context) async { final source = message.source; if (source.isJunk) { await source.markAsNotJunk(message); } else { - locator().cancelNotificationForMailMessage(message); + locator().cancelNotificationForMessage(message); await source.markAsJunk(message); } - locator().pop(); + if (context.mounted) { + context.pop(); + } } - Future _moveToInbox() async { + Future _moveToInbox(BuildContext context) async { final source = message.source; - await source.moveMessageToFlag(message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox); - locator().pop(); + await source.moveMessageToFlag( + message, + MailboxFlag.inbox, + context.text.resultMovedToInbox, + ); + if (context.mounted) { + context.pop(); + } } - Future _moveArchive() async { + Future _moveArchive(BuildContext context) async { final source = message.source; if (source.isArchive) { await source.moveToInbox(message); } else { - locator().cancelNotificationForMailMessage(message); + locator().cancelNotificationForMessage(message); await source.archive(message); } - locator().pop(); + if (context.mounted) { + context.pop(); + } } - void _forward(WidgetRef ref) { + void _forward(BuildContext context, WidgetRef ref) { final from = message.account.fromAddress; final builder = MessageBuilder.prepareForwardMessage( message.mimeMessage, @@ -493,6 +502,7 @@ class MessageActions extends HookConsumerWidget { ); final composeFuture = _addAttachments(message, builder); _navigateToCompose( + context, ref, message, builder, @@ -501,7 +511,7 @@ class MessageActions extends HookConsumerWidget { ); } - Future _forwardAsAttachment(WidgetRef ref) async { + Future _forwardAsAttachment(BuildContext context, WidgetRef ref) async { final message = this.message; final from = message.account.fromAddress; final mime = message.mimeMessage; @@ -519,6 +529,7 @@ class MessageActions extends HookConsumerWidget { builder.addMessagePart(mime); } _navigateToCompose( + context, ref, message, builder, @@ -527,7 +538,7 @@ class MessageActions extends HookConsumerWidget { ); } - Future _forwardAttachments(WidgetRef ref) async { + Future _forwardAttachments(BuildContext context, WidgetRef ref) async { final message = this.message; final from = message.account.fromAddress; final mime = message.mimeMessage; @@ -538,6 +549,7 @@ class MessageActions extends HookConsumerWidget { ); final composeFuture = _addAttachments(message, builder); _navigateToCompose( + context, ref, message, builder, @@ -595,6 +607,7 @@ class MessageActions extends HookConsumerWidget { } void _navigateToCompose( + BuildContext context, WidgetRef ref, Message? message, MessageBuilder builder, @@ -629,8 +642,7 @@ class MessageActions extends HookConsumerWidget { future: composeFuture, composeMode: mode, ); - locator() - .push(Routes.mailCompose, arguments: data, replace: true); + context.goNamed(Routes.mailCompose, extra: data); } void _addNotification() { diff --git a/lib/widgets/search_text_field.dart b/lib/widgets/search_text_field.dart index 7a56669..c451b03 100644 --- a/lib/widgets/search_text_field.dart +++ b/lib/widgets/search_text_field.dart @@ -2,12 +2,11 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_platform_widgets/cupertino.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../localization/extension.dart'; -import '../locator.dart'; import '../models/message_source.dart'; import '../routes.dart'; -import '../services/navigation_service.dart'; /// A dedicated search field optimized for Cupertino class CupertinoSearch extends StatelessWidget { @@ -18,17 +17,19 @@ class CupertinoSearch extends StatelessWidget { @override Widget build(BuildContext context) { final localizations = context.text; + return CupertinoSearchFlowTextField( - onSubmitted: _onSearchSubmitted, - cancelText: localizations.actionCancel); + onSubmitted: (text) => _onSearchSubmitted(context, text), + cancelText: localizations.actionCancel, + ); } - void _onSearchSubmitted(String text) { + void _onSearchSubmitted(BuildContext context, String text) { final search = MailSearch(text, SearchQueryType.allTextHeaders); final next = messageSource.search(search); - locator().push( + context.pushNamed( Routes.messageSource, - arguments: next, + extra: next, ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 477f77a..2240535 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,10 +18,11 @@ publish_to: "none" version: 1.0.0+86 environment: - sdk: '>=2.18.0 <4.0.0' + flutter: ">=3.13.0" + sdk: '>=3.0.0 <4.0.0' dependencies: - background_fetch: ^1.1.0 + background_fetch: ^1.2.1 badges: ^3.0.2 cached_network_image: ^3.2.1 collection: ^1.16.0 diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index 2146aa3..b8c593c 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -1202,12 +1202,12 @@ class TestNotificationService implements NotificationService { } @override - void cancelNotificationForMail(MimeMessage mimeMessage) { + void cancelNotificationForMime(MimeMessage mimeMessage) { _cancelledNotifications++; } @override - void cancelNotificationForMailMessage(Message message) { + void cancelNotificationForMessage(Message message) { _cancelledNotifications++; } From c48cb78b67ef26e14ecb6408e50058a0d02d2749 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Fri, 20 Oct 2023 18:16:07 +0200 Subject: [PATCH 19/95] chore:upgrade notifications, remove i18n service --- android/app/build.gradle | 27 ++ android/build.gradle | 2 +- lib/background/provider.g.dart | 2 +- lib/localization/extension.dart | 295 +++++++++++++++++- lib/locator.dart | 4 - lib/mail/provider.dart | 4 +- lib/mail/provider.g.dart | 24 +- lib/main.dart | 23 +- lib/models/date_sectioned_message_source.dart | 41 ++- lib/models/message_source.dart | 74 +++-- lib/notification/service.dart | 23 +- lib/screens/account_edit_screen.dart | 3 +- lib/screens/compose_screen.dart | 12 +- lib/screens/lock_screen.dart | 3 +- lib/screens/mail_search_screen.dart | 3 +- lib/screens/message_details_screen.dart | 36 +-- lib/screens/message_source_screen.dart | 107 +++++-- lib/services/app_service.dart | 5 +- lib/services/biometrics_service.dart | 16 +- lib/services/date_service.dart | 13 +- lib/services/i18n_service.dart | 283 ----------------- lib/services/scaffold_messenger_service.dart | 46 +-- lib/settings/provider.dart | 27 +- .../view/settings_feedback_screen.dart | 1 + .../view/settings_folders_screen.dart | 12 +- .../view/settings_language_screen.dart | 17 +- .../view/settings_security_screen.dart | 6 +- lib/widgets/attachment_chip.dart | 3 +- lib/widgets/cupertino_status_bar.dart | 18 +- lib/widgets/ical_composer.dart | 76 +++-- lib/widgets/ical_interactive_media.dart | 10 +- lib/widgets/mail_address_chip.dart | 6 +- lib/widgets/message_actions.dart | 23 +- lib/widgets/message_overview_content.dart | 29 +- lib/widgets/message_stack.dart | 90 +++--- lib/widgets/search_text_field.dart | 2 +- lib/widgets/signature.dart | 9 +- pubspec.yaml | 2 +- test/model/multiple_message_source_test.dart | 67 ++-- 39 files changed, 836 insertions(+), 608 deletions(-) delete mode 100644 lib/services/i18n_service.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 760f2ae..66eac96 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -31,6 +31,18 @@ if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) android { + defaultConfig { + multiDexEnabled true + } + + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + // Sets Java compatibility to Java 8 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + compileSdkVersion rootProject.ext.compileSdkVersion sourceSets { @@ -68,6 +80,18 @@ if (keystorePropertiesFile.exists()) { System.out.println("warning: android/key.properties not found, now using debug key.") android { + defaultConfig { + multiDexEnabled true + } + + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + // Sets Java compatibility to Java 8 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + compileSdkVersion rootProject.ext.compileSdkVersion sourceSets { @@ -103,4 +127,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" androidTestImplementation 'androidx.test:runner:1.3.0' // or higher androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' // or higher + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2' + implementation 'androidx.window:window:1.0.0' + implementation 'androidx.window:window-java:1.0.0' } diff --git a/android/build.gradle b/android/build.gradle index 1ed2a32..36033de 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -12,7 +12,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/lib/background/provider.g.dart b/lib/background/provider.g.dart index 4f0e6af..1477721 100644 --- a/lib/background/provider.g.dart +++ b/lib/background/provider.g.dart @@ -6,7 +6,7 @@ part of 'provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$backgroundHash() => r'cd72738cc7ca6dda23ff242116fd23079ea760bb'; +String _$backgroundHash() => r'e71958e083fcc34aa39eb838b6087a408299eaf2'; /// Registers the background service to check for emails regularly /// diff --git a/lib/localization/extension.dart b/lib/localization/extension.dart index 9e04458..7375eab 100644 --- a/lib/localization/extension.dart +++ b/lib/localization/extension.dart @@ -1,11 +1,304 @@ -import 'package:flutter/cupertino.dart'; +import 'dart:io'; +import 'package:enough_icalendar/enough_icalendar.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/date_symbol_data_local.dart' as date_intl; +import 'package:intl/date_symbols.dart'; +import 'package:intl/intl.dart'; + +import '../services/date_service.dart'; import 'app_localizations.g.dart'; import 'app_localizations_en.g.dart'; +// lateDateFormat _dateTimeFormatToday; +// late intl.DateFormat _dateTimeFormatLastWeek; +// late intl.DateFormat _dateTimeFormat; +// late intl.DateFormat _dateTimeFormatLong; +// late intl.DateFormat _dateFormatDayInLastWeek; +// late intl.DateFormat _dateFormatDayBeforeLastWeek; +// late intl.DateFormat _dateFormatLong; +// late intl.DateFormat _dateFormatShort; +// // late intl.DateFormat _dateFormatMonth; +// late intl.DateFormat _dateFormatWeekday; +// // late intl.DateFormat _dateFormatNoTime; + /// Allows to look up the localized strings for the current locale extension AppLocalizationBuildContext on BuildContext { /// Retrieves the current localizations AppLocalizations get text => AppLocalizations.of(this) ?? AppLocalizationsEn(); + + /// Retrieves the data range Name + String getDateRangeName( + DateSectionRange range, + ) { + final localizations = text; + switch (range) { + case DateSectionRange.future: + return localizations.dateRangeFuture; + case DateSectionRange.tomorrow: + return localizations.dateRangeTomorrow; + case DateSectionRange.today: + return localizations.dateRangeToday; + case DateSectionRange.yesterday: + return localizations.dateRangeYesterday; + case DateSectionRange.thisWeek: + return localizations.dateRangeCurrentWeek; + case DateSectionRange.lastWeek: + return localizations.dateRangeLastWeek; + case DateSectionRange.thisMonth: + return localizations.dateRangeCurrentMonth; + case DateSectionRange.monthOfThisYear: + return localizations.dateRangeCurrentYear; + case DateSectionRange.monthAndYear: + return localizations.dateRangeLongAgo; + } + } + + DateFormat get _dateTimeFormatLong => + DateFormat.yMMMMEEEEd(text.localeName).add_jm(); + DateFormat get _dateTimeFormatLastWeek => + DateFormat.E(text.localeName).add_jm(); + DateFormat get _dateFormatDayInLastWeek => DateFormat.E(text.localeName); + DateFormat get _dateFormatDayBeforeLastWeek => + DateFormat.yMd(text.localeName); + DateFormat get _dateFormatLong => DateFormat.yMMMMEEEEd(text.localeName); + DateFormat get _dateFormatShort => DateFormat.yMd(text.localeName); + DateFormat get _dateFormatWeekday => DateFormat.EEEE(text.localeName); + DateFormat get _dateTimeFormatToday => DateFormat.jm(text.localeName); + DateFormat get _dateTimeFormat => DateFormat.yMd(text.localeName).add_jm(); + + String formatDateTime( + DateTime? dateTime, { + bool alwaysUseAbsoluteFormat = false, + bool useLongFormat = false, + }) { + if (dateTime == null) { + return text.dateUndefined; + } + if (alwaysUseAbsoluteFormat) { + if (useLongFormat) { + return _dateTimeFormatLong.format(dateTime); + } + + return _dateTimeFormat.format(dateTime); + } + final nw = DateTime.now(); + final today = nw.subtract( + Duration( + hours: nw.hour, + minutes: nw.minute, + seconds: nw.second, + milliseconds: nw.millisecond, + ), + ); + final lastWeek = today.subtract(const Duration(days: 7)); + String date; + if (dateTime.isAfter(today)) { + date = _dateTimeFormatToday.format(dateTime); + } else if (dateTime.isAfter(lastWeek)) { + date = _dateTimeFormatLastWeek.format(dateTime); + } else { + date = useLongFormat + ? _dateTimeFormatLong.format(dateTime) + : _dateTimeFormat.format(dateTime); + } + + return date; + } + + String formatDate(DateTime? dateTime, {bool useLongFormat = false}) { + if (dateTime == null) { + return text.dateUndefined; + } + + return useLongFormat + ? _dateFormatLong.format(dateTime) + : _dateFormatShort.format(dateTime); + } + + String formatDay(DateTime dateTime) { + final messageDate = dateTime; + final nw = DateTime.now(); + final today = nw.subtract( + Duration( + hours: nw.hour, + minutes: nw.minute, + seconds: nw.second, + milliseconds: nw.millisecond, + ), + ); + if (messageDate.isAfter(today)) { + return text.dateDayToday; + } else if (messageDate.isAfter(today.subtract(const Duration(days: 1)))) { + return text.dateDayYesterday; + } else if (messageDate.isAfter(today.subtract(const Duration(days: 7)))) { + return text + .dateDayLastWeekday(_dateFormatDayInLastWeek.format(messageDate)); + } else { + return _dateFormatDayBeforeLastWeek.format(messageDate); + } + } + + String formatWeekDay(DateTime dateTime) => + _dateFormatWeekday.format(dateTime); + + List formatWeekDays({int? startOfWeekDay, bool abbreviate = false}) { + final dateSymbols = date_intl.dateTimeSymbolMap()[text.localeName]; + final weekdays = (dateSymbols is DateSymbols) + ? (abbreviate + ? dateSymbols.STANDALONESHORTWEEKDAYS + : dateSymbols.STANDALONEWEEKDAYS) + : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + final usedStartOfWeekDay = startOfWeekDay ?? firstDayOfWeek; + final result = []; + for (int i = 0; i < 7; i++) { + final day = ((usedStartOfWeekDay + i) <= 7) + ? (usedStartOfWeekDay + i) + : ((usedStartOfWeekDay + i) - 7); + final nameIndex = day == DateTime.sunday ? 0 : day; + final name = weekdays[nameIndex]; + result.add(WeekDay(day, name)); + } + + return result; + } + + Locale _getPlatformLocale() { + final localeName = Platform.localeName; + final parts = localeName.split('_'); + final languageCode = parts.first; + final countryCode = parts.length > 1 ? parts[1].split('.').first : null; + + return Locale(languageCode, countryCode); + } + + static int? _firstDayOfWeek; + int get firstDayOfWeek { + final value = _firstDayOfWeek; + if (value != null) { + return value; + } + final locale = _getPlatformLocale(); + final firstDay = + _firstDayOfWeekPerCountryCode[locale.countryCode] ?? DateTime.monday; + _firstDayOfWeek = firstDay; + + return firstDay; + } + + String formatTimeOfDay(TimeOfDay timeOfDay) => timeOfDay.format(this); + + String? formatMemory(int? size) { + if (size == null) { + return null; + } + double sizeD = size + 0.0; + final units = ['gb', 'mb', 'kb', 'bytes']; + var unitIndex = units.length - 1; + while ((sizeD / 1024) > 1.0 && unitIndex > 0) { + sizeD = sizeD / 1024; + unitIndex--; + } + final sizeFormat = NumberFormat('###.0#', text.localeName); + + return '${sizeFormat.format(sizeD)} ${units[unitIndex]}'; + } + + String formatIsoDuration(IsoDuration duration) { + final localizations = text; + final buffer = StringBuffer(); + if (duration.isNegativeDuration) { + buffer.write('-'); + } + if (duration.years > 0) { + buffer.write(localizations.durationYears(duration.years)); + } + if (duration.months > 0) { + if (buffer.isNotEmpty) buffer.write(', '); + buffer.write(localizations.durationMonths(duration.months)); + } + if (duration.weeks > 0) { + if (buffer.isNotEmpty) buffer.write(', '); + buffer.write(localizations.durationWeeks(duration.weeks)); + } + if (duration.days > 0) { + if (buffer.isNotEmpty) buffer.write(', '); + buffer.write(localizations.durationDays(duration.days)); + } + if (duration.hours > 0) { + if (buffer.isNotEmpty) buffer.write(', '); + buffer.write(localizations.durationHours(duration.hours)); + } + if (duration.minutes > 0) { + if (buffer.isNotEmpty) buffer.write(', '); + buffer.write(localizations.durationHours(duration.minutes)); + } + if (buffer.isEmpty) { + buffer.write(localizations.durationEmpty); + } + + return buffer.toString(); + } } + +class WeekDay { + const WeekDay(this.day, this.name); + final int day; + final String name; +} + +/// Day of week for countries (in two letter code) for +/// which the week does not start on Monday +/// +/// Source: http://chartsbin.com/view/41671 +const _firstDayOfWeekPerCountryCode = { + 'ae': DateTime.saturday, // United Arab Emirates + 'af': DateTime.saturday, // Afghanistan + 'ar': DateTime.sunday, // Argentina + 'bh': DateTime.saturday, // Bahrain + 'br': DateTime.sunday, // Brazil + 'bz': DateTime.sunday, // Belize + 'bo': DateTime.sunday, // Bolivia + 'ca': DateTime.sunday, // Canada + 'cl': DateTime.sunday, // Chile + 'cn': DateTime.sunday, // China + 'co': DateTime.sunday, // Colombia + 'cr': DateTime.sunday, // Costa Rica + 'do': DateTime.sunday, // Dominican Republic + 'dz': DateTime.saturday, // Algeria + 'ec': DateTime.sunday, // Ecuador + 'eg': DateTime.saturday, // Egypt + 'gt': DateTime.sunday, // Guatemala + 'hk': DateTime.sunday, // Hong Kong + 'hn': DateTime.sunday, // Honduras + 'il': DateTime.sunday, // Israel + 'iq': DateTime.saturday, // Iraq + 'ir': DateTime.saturday, // Iran + 'jm': DateTime.sunday, // Jamaica + 'io': DateTime.saturday, // Jordan + 'jp': DateTime.sunday, // Japan + 'ke': DateTime.sunday, // Kenya + 'kr': DateTime.sunday, // South Korea + 'kw': DateTime.saturday, // Kuwait + 'ly': DateTime.saturday, // Libya + 'mo': DateTime.sunday, // Macao + 'mx': DateTime.sunday, // Mexico + 'ni': DateTime.sunday, // Nicaragua + 'om': DateTime.saturday, // Oman + 'pa': DateTime.sunday, // Panama + 'pe': DateTime.sunday, // Peru + 'ph': DateTime.sunday, // Philippines + 'pr': DateTime.sunday, // Puerto Rico + 'qa': DateTime.saturday, // Qatar + 'sa': DateTime.saturday, // Saudi Arabia + 'sv': DateTime.sunday, // El Salvador + 'sy': DateTime.saturday, // Syria + 'tw': DateTime.sunday, // Taiwan + 'us': DateTime.sunday, // USA + 've': DateTime.sunday, // Venezuela + 'ye': DateTime.saturday, // Yemen + 'za': DateTime.sunday, // South Africa + 'zw': DateTime.sunday, // Zimbabwe +}; diff --git a/lib/locator.dart b/lib/locator.dart index 61e0f97..9b637aa 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -3,8 +3,6 @@ import 'package:get_it/get_it.dart'; import 'notification/service.dart'; import 'services/app_service.dart'; import 'services/biometrics_service.dart'; -import 'services/date_service.dart'; -import 'services/i18n_service.dart'; import 'services/icon_service.dart'; import 'services/key_service.dart'; import 'services/location_service.dart'; @@ -15,9 +13,7 @@ GetIt locator = GetIt.instance; void setupLocator() { locator - ..registerLazySingleton(I18nService.new) ..registerLazySingleton(ScaffoldMessengerService.new) - ..registerLazySingleton(DateService.new) ..registerSingleton(IconService()) ..registerLazySingleton(() => NotificationService.instance) ..registerLazySingleton(AppService.new) diff --git a/lib/mail/provider.dart b/lib/mail/provider.dart index 5bb0026..3b36c05 100644 --- a/lib/mail/provider.dart +++ b/lib/mail/provider.dart @@ -6,6 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../account/model.dart'; import '../account/provider.dart'; import '../app_lifecycle/provider.dart'; +import '../localization/app_localizations.g.dart'; import '../models/async_mime_source.dart'; import '../models/message.dart'; import '../models/message_source.dart'; @@ -256,13 +257,14 @@ class MailClientSource extends _$MailClientSource { @riverpod Future mailSearch( MailSearchRef ref, { + required AppLocalizations localizations, required MailSearch search, }) async { final account = ref.watch(currentAccountProvider) ?? ref.watch(allAccountsProvider).first; final source = await ref.watch(sourceProvider(account: account).future); - return source.search(search); + return source.search(localizations, search); } /// Loads the message source for the given payload diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart index 7c4ffc2..fdeec1b 100644 --- a/lib/mail/provider.g.dart +++ b/lib/mail/provider.g.dart @@ -482,7 +482,7 @@ class _RealMimeSourceProviderElement Mailbox? get mailbox => (origin as RealMimeSourceProvider).mailbox; } -String _$mailSearchHash() => r'166028850f57246adf47921d461b1ff3b5bc3230'; +String _$mailSearchHash() => r'12e814bd6c0f53f6209dd0f68edf09a0ec769c8b'; /// Carries out a search for mail messages /// @@ -503,9 +503,11 @@ class MailSearchFamily extends Family> { /// /// Copied from [mailSearch]. MailSearchProvider call({ + required AppLocalizations localizations, required MailSearch search, }) { return MailSearchProvider( + localizations: localizations, search: search, ); } @@ -515,6 +517,7 @@ class MailSearchFamily extends Family> { covariant MailSearchProvider provider, ) { return call( + localizations: provider.localizations, search: provider.search, ); } @@ -542,10 +545,12 @@ class MailSearchProvider extends AutoDisposeFutureProvider { /// /// Copied from [mailSearch]. MailSearchProvider({ + required AppLocalizations localizations, required MailSearch search, }) : this._internal( (ref) => mailSearch( ref as MailSearchRef, + localizations: localizations, search: search, ), from: mailSearchProvider, @@ -557,6 +562,7 @@ class MailSearchProvider extends AutoDisposeFutureProvider { dependencies: MailSearchFamily._dependencies, allTransitiveDependencies: MailSearchFamily._allTransitiveDependencies, + localizations: localizations, search: search, ); @@ -567,9 +573,11 @@ class MailSearchProvider extends AutoDisposeFutureProvider { required super.allTransitiveDependencies, required super.debugGetCreateSourceHash, required super.from, + required this.localizations, required this.search, }) : super.internal(); + final AppLocalizations localizations; final MailSearch search; @override @@ -585,6 +593,7 @@ class MailSearchProvider extends AutoDisposeFutureProvider { dependencies: null, allTransitiveDependencies: null, debugGetCreateSourceHash: null, + localizations: localizations, search: search, ), ); @@ -597,12 +606,15 @@ class MailSearchProvider extends AutoDisposeFutureProvider { @override bool operator ==(Object other) { - return other is MailSearchProvider && other.search == search; + return other is MailSearchProvider && + other.localizations == localizations && + other.search == search; } @override int get hashCode { var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, localizations.hashCode); hash = _SystemHash.combine(hash, search.hashCode); return _SystemHash.finish(hash); @@ -610,6 +622,9 @@ class MailSearchProvider extends AutoDisposeFutureProvider { } mixin MailSearchRef on AutoDisposeFutureProviderRef { + /// The parameter `localizations` of this provider. + AppLocalizations get localizations; + /// The parameter `search` of this provider. MailSearch get search; } @@ -618,6 +633,9 @@ class _MailSearchProviderElement extends AutoDisposeFutureProviderElement with MailSearchRef { _MailSearchProviderElement(super.provider); + @override + AppLocalizations get localizations => + (origin as MailSearchProvider).localizations; @override MailSearch get search => (origin as MailSearchProvider).search; } @@ -1456,7 +1474,7 @@ class _UnifiedSourceProviderElement Mailbox? get mailbox => (origin as UnifiedSourceProvider).mailbox; } -String _$realSourceHash() => r'3583e95ef8796fe9a9696ece4708c1aacf222964'; +String _$realSourceHash() => r'8f473819a0105254df83c234906e6e4889f29ef0'; abstract class _$RealSource extends BuildlessAsyncNotifier { diff --git a/lib/main.dart b/lib/main.dart index 192715b..def55ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:collection/collection.dart' show IterableExtension; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -12,9 +11,9 @@ import 'background/provider.dart'; import 'localization/app_localizations.g.dart'; import 'locator.dart'; import 'logger.dart'; +import 'notification/service.dart'; import 'routes.dart'; import 'screens/screens.dart'; -import 'services/i18n_service.dart'; import 'services/scaffold_messenger_service.dart'; import 'settings/provider.dart'; import 'settings/theme/provider.dart'; @@ -45,8 +44,9 @@ class MailyApp extends HookConsumerWidget { final themeSettingsData = ref.watch(themeFinderProvider(context: context)); final languageTag = ref.watch(settingsProvider.select((settings) => settings.languageTag)); - ref.watch(incomingShareProvider); - ref.watch(backgroundProvider); + ref + ..watch(incomingShareProvider) + ..watch(backgroundProvider); final app = PlatformSnackApp.router( supportedLocales: AppLocalizations.supportedLocales, @@ -111,17 +111,9 @@ class _InitializationScreen extends ConsumerState { Future _initApp() async { await ref.read(settingsProvider.notifier).init(); await ref.read(realAccountsProvider.notifier).init(); - - final settings = ref.read(settingsProvider); - - final i18nService = locator(); - final languageTag = settings.languageTag ?? 'en'; - final settingsLocale = AppLocalizations.supportedLocales - .firstWhereOrNull((l) => l.toLanguageTag() == languageTag); - if (settingsLocale != null) { - final settingsLocalizations = - await AppLocalizations.delegate.load(settingsLocale); - i18nService.init(settingsLocalizations, settingsLocale); + await ref.read(backgroundProvider.notifier).init(); + if (context.mounted) { + await NotificationService.instance.init(context: context); } // final mailService = locator(); // // key service is required before mail service due to Oauth configs @@ -163,7 +155,6 @@ class _InitializationScreen extends ConsumerState { // unawaited(locator() // .push(Routes.welcome, fade: true, replace: true)); // } - await ref.read(backgroundProvider.notifier).init(); // final usedContext = Routes.navigatorKey.currentContext ?? context; // if (usedContext.mounted) { // usedContext.pushReplacement(Routes.home); diff --git a/lib/models/date_sectioned_message_source.dart b/lib/models/date_sectioned_message_source.dart index f796bf9..5f851ae 100644 --- a/lib/models/date_sectioned_message_source.dart +++ b/lib/models/date_sectioned_message_source.dart @@ -2,17 +2,20 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; -import '../locator.dart'; +import '../localization/app_localizations.g.dart'; import '../services/date_service.dart'; -import '../services/i18n_service.dart'; import 'message.dart'; import 'message_date_section.dart'; import 'message_source.dart'; class DateSectionedMessageSource extends ChangeNotifier { - DateSectionedMessageSource(this.messageSource) { + DateSectionedMessageSource( + this.messageSource, { + required this.firstDayOfWeek, + }) { messageSource.addListener(_update); } + final int firstDayOfWeek; final MessageSource messageSource; int _numberOfSections = 0; int get size { @@ -65,11 +68,13 @@ class DateSectionedMessageSource extends ChangeNotifier { int numberOfMessagesToBeConsidered = 40, }) async { final max = messageSource.size; - if (numberOfMessagesToBeConsidered > max) { - numberOfMessagesToBeConsidered = max; - } + final usedNumberOfMessagesToBeConsidered = + (numberOfMessagesToBeConsidered > max) + ? max + : numberOfMessagesToBeConsidered; + final messages = []; - for (var i = 0; i < numberOfMessagesToBeConsidered; i++) { + for (var i = 0; i < usedNumberOfMessagesToBeConsidered; i++) { final message = await messageSource.getMessageAt(i); messages.add(message); } @@ -77,7 +82,9 @@ class DateSectionedMessageSource extends ChangeNotifier { return getDateSections(messages); } - List getDateSections(List messages) { + List getDateSections( + List messages, + ) { final sections = []; DateSectionRange? lastRange; int foundSections = 0; @@ -85,7 +92,8 @@ class DateSectionedMessageSource extends ChangeNotifier { final message = messages[i]; final dateTime = message.mimeMessage.decodeDate(); if (dateTime != null) { - final range = locator().determineDateSection(dateTime); + final range = + DateService(firstDayOfWeek).determineDateSection(dateTime); if (range != lastRange) { final index = (lastRange == null) ? 0 : i + foundSections; sections.add(MessageDateSection(range, dateTime, index)); @@ -94,6 +102,7 @@ class DateSectionedMessageSource extends ChangeNotifier { lastRange = range; } } + return sections; } @@ -151,21 +160,21 @@ class DateSectionedMessageSource extends ChangeNotifier { futures.add(messageSource.getMessageAt(i)); } final messages = await Future.wait(futures); + return messages; } List _getTopMessages(int length) { final max = messageSource.size; - if (length > max) { - length = max; - } + final usedLength = (length > max) ? max : length; final messages = []; - for (int i = 0; i < length; i++) { + for (int i = 0; i < usedLength; i++) { final message = messageSource.cache[i]; if (message != null) { messages.add(message); } } + return messages; } @@ -175,9 +184,11 @@ class DateSectionedMessageSource extends ChangeNotifier { notifyListeners(); } - Future deleteMessage(Message message) => messageSource.deleteMessages( + Future deleteMessage(AppLocalizations localizations, Message message) => + messageSource.deleteMessages( + localizations, [message], - locator().localizations.resultDeleted, + localizations.resultDeleted, ); } diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index a8651f2..949cad8 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -3,11 +3,11 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; import '../account/model.dart'; +import '../localization/app_localizations.g.dart'; import '../locator.dart'; import '../logger.dart'; import '../notification/model.dart'; import '../notification/service.dart'; -import '../services/i18n_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../util/indexed_cache.dart'; import 'async_mime_source.dart'; @@ -191,13 +191,16 @@ abstract class MessageSource extends ChangeNotifier /// /// Just forwards to [deleteMessages] @Deprecated('use deleteMessages instead') - Future deleteMessage(Message message) => deleteMessages( + Future deleteMessage(AppLocalizations localizations, Message message) => + deleteMessages( + localizations, [message], - locator().localizations.resultDeleted, + localizations.resultDeleted, ); /// Deletes the given messages Future deleteMessages( + AppLocalizations localizations, List messages, String notification, ) { @@ -211,10 +214,11 @@ abstract class MessageSource extends ChangeNotifier } notifyListeners(); - return _deleteMessages(messages, notification); + return _deleteMessages(localizations, messages, notification); } Future _deleteMessages( + AppLocalizations localizations, List messages, String notification, ) async { @@ -228,6 +232,7 @@ abstract class MessageSource extends ChangeNotifier } } locator().showTextSnackBar( + localizations, notification, undo: resultsBySource.isEmpty ? null @@ -241,24 +246,30 @@ abstract class MessageSource extends ChangeNotifier ); } - Future markAsJunk(Message message) => moveMessageToFlag( + Future markAsJunk(AppLocalizations localizations, Message message) => + moveMessageToFlag( + localizations, message, MailboxFlag.junk, - locator().localizations.resultMovedToJunk, + localizations.resultMovedToJunk, ); - Future markAsNotJunk(Message message) => moveMessageToFlag( + Future markAsNotJunk(AppLocalizations localizations, Message message) => + moveMessageToFlag( + localizations, message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox, + localizations.resultMovedToInbox, ); Future moveMessageToFlag( + AppLocalizations localizations, Message message, MailboxFlag targetMailboxFlag, String notification, ) => moveMessage( + localizations, message, message.source .getMimeSource(message) @@ -274,6 +285,7 @@ abstract class MessageSource extends ChangeNotifier ); Future moveMessage( + AppLocalizations localizations, Message message, Mailbox targetMailbox, String notification, @@ -292,6 +304,7 @@ abstract class MessageSource extends ChangeNotifier notifyListeners(); if (moveResult.canUndo) { locator().showTextSnackBar( + localizations, notification, undo: () async { await mailClient.undoMoveMessages(moveResult); @@ -312,6 +325,7 @@ abstract class MessageSource extends ChangeNotifier } Future moveMessagesToFlag( + AppLocalizations localizations, List messages, MailboxFlag targetMailboxFlag, String notification, @@ -337,6 +351,7 @@ abstract class MessageSource extends ChangeNotifier notifyListeners(); if (resultsBySource.isNotEmpty) { locator().showTextSnackBar( + localizations, notification, undo: () async { for (final source in resultsBySource.keys) { @@ -350,6 +365,7 @@ abstract class MessageSource extends ChangeNotifier } Future moveMessages( + AppLocalizations localizations, List messages, Mailbox targetMailbox, String notification, @@ -369,6 +385,7 @@ abstract class MessageSource extends ChangeNotifier final moveResult = await source.moveMessages(mimes, targetMailbox); notifyListeners(); locator().showTextSnackBar( + localizations, notification, undo: moveResult.canUndo ? () async { @@ -379,7 +396,12 @@ abstract class MessageSource extends ChangeNotifier : null, ); } else if (parent != null) { - return parent.moveMessages(messages, targetMailbox, notification); + return parent.moveMessages( + localizations, + messages, + targetMailbox, + notification, + ); } } @@ -390,16 +412,23 @@ abstract class MessageSource extends ChangeNotifier } } - Future moveToInbox(Message message) async => moveMessageToFlag( + Future moveToInbox( + AppLocalizations localizations, + Message message, + ) async => + moveMessageToFlag( + localizations, message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox, + localizations.resultMovedToInbox, ); - Future archive(Message message) => moveMessageToFlag( + Future archive(AppLocalizations localizations, Message message) => + moveMessageToFlag( + localizations, message, MailboxFlag.archive, - locator().localizations.resultArchived, + localizations.resultArchived, ); Future markAsSeen(Message msg, bool isSeen) { @@ -410,7 +439,11 @@ abstract class MessageSource extends ChangeNotifier locator().cancelNotificationForMessage(msg); } - return source.store([msg.mimeMessage], [MessageFlags.seen]); + return source.store( + [msg.mimeMessage], + [MessageFlags.seen], + action: isSeen ? StoreAction.add : StoreAction.remove, + ); } msg.isSeen = isSeen; final parent = _parentMessageSource; @@ -433,7 +466,7 @@ abstract class MessageSource extends ChangeNotifier final parentMsg = parent.cache.getWithMime(msg.mimeMessage, getMimeSource(msg)); if (parentMsg != null) { - parent.onMarkedAsSeen(parentMsg, isSeen); + return parent.onMarkedAsSeen(parentMsg, isSeen); } } } @@ -543,7 +576,7 @@ abstract class MessageSource extends ChangeNotifier return Future.wait(futures); } - MessageSource search(MailSearch search); + MessageSource search(AppLocalizations localizations, MailSearch search); void removeMime(MimeMessage mimeMessage, AsyncMimeSource? mimeSource) { final existingMessage = cache.getWithMime(mimeMessage, mimeSource); @@ -732,7 +765,7 @@ class MailboxMessageSource extends MessageSource { bool get supportsSearching => mimeSource.supportsSearching; @override - MessageSource search(MailSearch search) { + MessageSource search(AppLocalizations localizations, MailSearch search) { final searchSource = mimeSource.search(search); return MailboxMessageSource.fromMimeSource( @@ -1000,12 +1033,11 @@ class MultipleMessageSource extends MessageSource { mimeSources.any((source) => source.supportsSearching); @override - MessageSource search(MailSearch search) { + MessageSource search(AppLocalizations localizations, MailSearch search) { final searchMimeSources = mimeSources .where((source) => source.supportsSearching) .map((source) => source.search(search)) .toList(); - final localizations = locator().localizations; final searchMessageSource = MultipleMessageSource( searchMimeSources, localizations.searchQueryTitle(search.query), @@ -1184,7 +1216,7 @@ class SingleMessageSource extends MessageSource { bool get isSent => false; @override - MessageSource search(MailSearch search) { + MessageSource search(AppLocalizations localizations, MailSearch search) { throw UnimplementedError(); } @@ -1267,7 +1299,7 @@ class ListMessageSource extends MessageSource { bool get isSent => false; @override - MessageSource search(MailSearch search) { + MessageSource search(AppLocalizations localizations, MailSearch search) { throw UnimplementedError(); } diff --git a/lib/notification/service.dart b/lib/notification/service.dart index c6045dd..de09e5d 100644 --- a/lib/notification/service.dart +++ b/lib/notification/service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:go_router/go_router.dart'; @@ -22,6 +23,7 @@ class NotificationService { final _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); Future init({ + BuildContext? context, bool checkForLaunchDetails = true, }) async { // print('init notification service...'); @@ -43,6 +45,12 @@ class NotificationService { initSettings, onDidReceiveNotificationResponse: _selectNotification, ); + if (Platform.isAndroid) { + await _flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); + } if (checkForLaunchDetails) { final launchDetails = await _flutterLocalNotificationsPlugin .getNotificationAppLaunchDetails(); @@ -52,7 +60,7 @@ class NotificationService { // print( // 'got notification launched details: $launchDetails // with payload ${response.payload}'); - _selectNotification(response); + _selectNotification(response, context: context); return NotificationServiceInitResult.appLaunchedByNotification; } @@ -104,19 +112,24 @@ class NotificationService { return MailNotificationPayload.fromJson(json); } - void _selectNotification(NotificationResponse response) { + void _selectNotification( + NotificationResponse response, { + BuildContext? context, + }) { final payloadText = response.payload; if (kDebugMode) { print('select notification: $payloadText'); } - final context = Routes.navigatorKey.currentContext; - if (context == null) { + final usedContext = context ?? Routes.navigatorKey.currentContext; + if (usedContext == null) { + logger.e('Unable to show notification: no context found'); + return; } if (payloadText != null && payloadText.startsWith(_messagePayloadStart)) { final payload = _deserialize(payloadText); - context.pushNamed( + usedContext.pushNamed( Routes.mailDetailsForNotification, extra: payload, ); diff --git a/lib/screens/account_edit_screen.dart b/lib/screens/account_edit_screen.dart index 14a04cd..b992e55 100644 --- a/lib/screens/account_edit_screen.dart +++ b/lib/screens/account_edit_screen.dart @@ -184,6 +184,7 @@ class AccountEditScreen extends HookConsumerWidget { onDismissed: (direction) { account.removeAlias(alias); locator().showTextSnackBar( + localizations, localizations.editAccountAliasRemoved( alias.email, ), @@ -342,7 +343,7 @@ class AccountEditScreen extends HookConsumerWidget { ? localizations.editAccountLoggingEnabled : localizations.editAccountLoggingDisabled; locator() - .showTextSnackBar(message); + .showTextSnackBar(localizations, message); } }, ), diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index 4db581c..a95e1f7 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -219,6 +219,7 @@ class _ComposeScreenState extends ConsumerState { Future.value(widget.data.resumeText); String get _signature => ref.read(settingsProvider.notifier).getSignatureHtml( + context, _from.account, widget.data.action, context.text.localeName, @@ -422,8 +423,8 @@ class _ComposeScreenState extends ConsumerState { from: _from.account.fromAddress, appendToSent: append, ); - locator() - .showTextSnackBar(localizations.composeMailSendSuccess); + locator().showTextSnackBar( + localizations, localizations.composeMailSendSuccess); } catch (e, s) { if (kDebugMode) { print('Unable to send or append mail: $e $s'); @@ -508,6 +509,7 @@ class _ComposeScreenState extends ConsumerState { // let it pop but show snackbar to return: await _populateMessageBuilder(storeComposeDataForResume: true); locator().showTextSnackBar( + localizations, localizations.composeLeftByMistake, undo: _returnToCompose, ); @@ -780,8 +782,10 @@ class _ComposeScreenState extends ConsumerState { final mime = await _buildMimeMessage(mailClient); try { await mailClient.saveDraftMessage(mime); - locator() - .showTextSnackBar(localizations.composeMessageSavedAsDraft); + locator().showTextSnackBar( + localizations, + localizations.composeMessageSavedAsDraft, + ); final originalMessage = widget.data.originalMessage; if (originalMessage != null) { await Future.delayed(const Duration(milliseconds: 20)); diff --git a/lib/screens/lock_screen.dart b/lib/screens/lock_screen.dart index 5181160..c270082 100644 --- a/lib/screens/lock_screen.dart +++ b/lib/screens/lock_screen.dart @@ -45,7 +45,8 @@ class LockScreen extends StatelessWidget { ); Future _authenticate(BuildContext context) async { - final didAuthenticate = await locator().authenticate(); + final didAuthenticate = + await locator().authenticate(context.text); if (didAuthenticate && context.mounted) { context.pop(); } diff --git a/lib/screens/mail_search_screen.dart b/lib/screens/mail_search_screen.dart index a4921de..095cb4e 100644 --- a/lib/screens/mail_search_screen.dart +++ b/lib/screens/mail_search_screen.dart @@ -10,7 +10,7 @@ import 'message_source_screen.dart'; /// Displays the search result for class MailSearchScreen extends ConsumerWidget { - /// Creates a [MailScreen] + /// Creates a [MailSearchScreen] const MailSearchScreen({ super.key, required this.search, @@ -24,6 +24,7 @@ class MailSearchScreen extends ConsumerWidget { final text = context.text; final searchSource = ref.watch( mailSearchProvider( + localizations: text, search: search, ), ); diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index abbcecb..85acf56 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -19,7 +19,6 @@ import '../models/message.dart'; import '../models/message_source.dart'; import '../notification/service.dart'; import '../routes.dart'; -import '../services/i18n_service.dart'; import '../services/icon_service.dart'; import '../settings/model.dart'; import '../settings/provider.dart'; @@ -193,29 +192,26 @@ class _MessageContentState extends ConsumerState<_MessageContent> { Widget build(BuildContext context) { final localizations = context.text; - return _buildMailDetails(localizations); - } - - Widget _buildMailDetails(AppLocalizations localizations) => - SingleChildScrollView( - child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: _buildHeader(localizations), - ), - _buildContent(localizations), - ], - ), + return SingleChildScrollView( + child: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: _buildHeader(context, localizations), + ), + _buildContent(localizations), + ], ), - ); + ), + ); + } - Widget _buildHeader(AppLocalizations localizations) { + Widget _buildHeader(BuildContext context, AppLocalizations localizations) { final mime = widget.message.mimeMessage; final attachments = widget.message.attachments; - final date = locator().formatDateTime(mime.decodeDate()); + final date = context.formatDateTime(mime.decodeDate()); final subject = mime.decodeSubject(); return Column( diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index 84d43d1..9043a19 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -19,7 +19,6 @@ import '../models/message_source.dart'; import '../models/swipe.dart'; import '../notification/service.dart'; import '../routes.dart'; -import '../services/i18n_service.dart'; import '../services/icon_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../settings/provider.dart'; @@ -98,7 +97,10 @@ class _MessageSourceScreenState extends ConsumerState void initState() { super.initState(); _searchEditingController = TextEditingController(); - _sectionedMessageSource = DateSectionedMessageSource(widget.messageSource); + _sectionedMessageSource = DateSectionedMessageSource( + widget.messageSource, + firstDayOfWeek: context.firstDayOfWeek, + ); _sectionedMessageSource.addListener(_update); _messageLoader = initMessageSource(); } @@ -131,7 +133,8 @@ class _MessageSourceScreenState extends ConsumerState return; } final search = MailSearch(query, SearchQueryType.allTextHeaders); - final searchSource = _sectionedMessageSource.messageSource.search(search); + final searchSource = + _sectionedMessageSource.messageSource.search(context.text, search); context.pushNamed(Routes.messageSource, extra: searchSource); setState(() { _isInSearchMode = false; @@ -238,7 +241,6 @@ class _MessageSourceScreenState extends ConsumerState // ], // ), ]; - final i18nService = locator(); Widget? zeroPosWidget; if (_sectionedMessageSource.isInitialized && source.size == 0) { final emptyMessage = source.isSearch @@ -451,8 +453,10 @@ class _MessageSourceScreenState extends ConsumerState } final section = element.section; if (section != null) { - final text = i18nService.formatDateRange( - section.range, section.date); + final text = context.getDateRangeName( + section.range, + ); + return GestureDetector( onLongPress: () async { _selectedMessages = @@ -471,7 +475,8 @@ class _MessageSourceScreenState extends ConsumerState final sectionMessages = await _sectionedMessageSource .getMessagesForSection( - section); + section, + ); final doSelect = !sectionMessages .first.isSelected; for (final msg in sectionMessages) { @@ -508,7 +513,7 @@ class _MessageSourceScreenState extends ConsumerState ), ), ), - const Divider() + const Divider(), ], ), ); @@ -516,6 +521,7 @@ class _MessageSourceScreenState extends ConsumerState final message = element.message!; // print( // '$index subject=${message.mimeMessage?.decodeSubject()}'); + return Dismissible( key: ValueKey(message), dismissThresholds: { @@ -528,24 +534,29 @@ class _MessageSourceScreenState extends ConsumerState color: swipeLeftToRightAction.colorBackground, padding: const EdgeInsets.symmetric( - horizontal: 8), + horizontal: 8, + ), alignment: AlignmentDirectional.centerStart, child: Row( children: [ Padding( padding: const EdgeInsets.symmetric( - horizontal: 8), + horizontal: 8, + ), child: Text( swipeLeftToRightAction .name(localizations), style: TextStyle( - color: swipeLeftToRightAction - .colorForeground), + color: swipeLeftToRightAction + .colorForeground, + ), ), ), - Icon(swipeLeftToRightAction.icon, - color: swipeLeftToRightAction - .colorIcon), + Icon( + swipeLeftToRightAction.icon, + color: + swipeLeftToRightAction.colorIcon, + ), ], ), ), @@ -553,7 +564,8 @@ class _MessageSourceScreenState extends ConsumerState color: swipeRightToLeftAction.colorBackground, padding: const EdgeInsets.symmetric( - horizontal: 8), + horizontal: 8, + ), alignment: AlignmentDirectional.centerEnd, child: Row( mainAxisAlignment: MainAxisAlignment.end, @@ -565,13 +577,15 @@ class _MessageSourceScreenState extends ConsumerState ), Padding( padding: const EdgeInsets.symmetric( - horizontal: 8), + horizontal: 8, + ), child: Text( swipeRightToLeftAction .name(localizations), style: TextStyle( - color: swipeRightToLeftAction - .colorForeground), + color: swipeRightToLeftAction + .colorForeground, + ), ), ), ], @@ -589,7 +603,12 @@ class _MessageSourceScreenState extends ConsumerState direction == DismissDirection.startToEnd ? swipeLeftToRightAction : swipeRightToLeftAction; - fireSwipeAction(swipeAction, message); + fireSwipeAction( + localizations, + swipeAction, + message, + ); + return Future.value( swipeAction.isMessageMoving, ); @@ -606,6 +625,7 @@ class _MessageSourceScreenState extends ConsumerState if (widget is MessageOverview) { return widget.message.sourceIndex; } + return null; }, ), @@ -627,6 +647,7 @@ class _MessageSourceScreenState extends ConsumerState final isAnyUnseen = _selectedMessages.any((m) => !m.isSeen); final isAnyUnflagged = _selectedMessages.any((m) => !m.isFlagged); final iconService = locator(); + return PlatformBottomBar( cupertinoBlurBackground: true, child: SafeArea( @@ -821,10 +842,10 @@ class _MessageSourceScreenState extends ConsumerState Future handleMultipleChoice(_MultipleChoice choice) async { final source = _sectionedMessageSource.messageSource; - final localizations = locator().localizations; + final localizations = context.text; if (_selectedMessages.isEmpty) { - locator() - .showTextSnackBar(localizations.multipleSelectionNeededInfo); + locator().showTextSnackBar( + localizations, localizations.multipleSelectionNeededInfo); return; } @@ -839,13 +860,18 @@ class _MessageSourceScreenState extends ConsumerState case _MultipleChoice.delete: final notification = localizations.multipleMovedToTrash(_selectedMessages.length); - await source.deleteMessages(_selectedMessages, notification); + await source.deleteMessages( + localizations, _selectedMessages, notification); break; case _MultipleChoice.inbox: final notification = localizations.multipleMovedToInbox(_selectedMessages.length); await source.moveMessagesToFlag( - _selectedMessages, MailboxFlag.inbox, notification); + localizations, + _selectedMessages, + MailboxFlag.inbox, + notification, + ); break; case _MultipleChoice.seen: endSelectionMode = false; @@ -875,12 +901,17 @@ class _MessageSourceScreenState extends ConsumerState final notification = localizations.multipleMovedToJunk(_selectedMessages.length); await source.moveMessagesToFlag( - _selectedMessages, MailboxFlag.junk, notification); + localizations, + _selectedMessages, + MailboxFlag.junk, + notification, + ); break; case _MultipleChoice.archive: final notification = localizations.multipleMovedToArchive(_selectedMessages.length); await source.moveMessagesToFlag( + localizations, _selectedMessages, MailboxFlag.archive, notification, @@ -1014,7 +1045,7 @@ class _MessageSourceScreenState extends ConsumerState } void move() { - final localizations = locator().localizations; + final localizations = context.text; var account = widget.messageSource.account; if (account.isVirtual) { // check how many mail-clients are involved in the current selection @@ -1056,16 +1087,18 @@ class _MessageSourceScreenState extends ConsumerState }); context.pop(); // alert final source = _sectionedMessageSource.messageSource; - final localizations = locator().localizations; + final localizations = context.text; final account = widget.messageSource.account; if (account.isVirtual) { await source.moveMessagesToFlag( + localizations, _selectedMessages, mailbox.flags.first, localizations.moveSuccess(mailbox.name), ); } else { await source.moveMessages( + localizations, _selectedMessages, mailbox, localizations.moveSuccess(mailbox.name), @@ -1126,7 +1159,8 @@ class _MessageSourceScreenState extends ConsumerState }); } - Future fireSwipeAction(SwipeAction action, Message message) { + Future fireSwipeAction( + AppLocalizations localizations, SwipeAction action, Message message) { switch (action) { case SwipeAction.markRead: final isSeen = !message.isSeen; @@ -1134,11 +1168,13 @@ class _MessageSourceScreenState extends ConsumerState return _sectionedMessageSource.messageSource .markAsSeen(message, isSeen); case SwipeAction.archive: - return _sectionedMessageSource.messageSource.archive(message); + return _sectionedMessageSource.messageSource + .archive(localizations, message); case SwipeAction.markJunk: - return _sectionedMessageSource.messageSource.markAsJunk(message); + return _sectionedMessageSource.messageSource + .markAsJunk(localizations, message); case SwipeAction.delete: - return _sectionedMessageSource.deleteMessage(message); + return _sectionedMessageSource.deleteMessage(localizations, message); case SwipeAction.flag: final isFlagged = !message.isFlagged; message.isFlagged = isFlagged; @@ -1197,8 +1233,11 @@ class _MessageSourceScreenState extends ConsumerState } }; } - locator() - .showTextSnackBar(localizations.homeDeleteAllSuccess, undo: undo); + locator().showTextSnackBar( + localizations, + localizations.homeDeleteAllSuccess, + undo: undo, + ); } } } diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart index 7e38fce..0f4408e 100644 --- a/lib/services/app_service.dart +++ b/lib/services/app_service.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import '../localization/app_localizations_en.g.dart'; import '../locator.dart'; import '../logger.dart'; import '../settings/model.dart'; @@ -54,8 +55,8 @@ class AppService { // if (navService.currentRouteName != Routes.lockScreen) { // await navService.push(Routes.lockScreen); // } - final bool didAuthenticate = - await locator().authenticate(); + final bool didAuthenticate = await locator() + .authenticate(AppLocalizationsEn()); // if (!didAuthenticate) { // if (navService.currentRouteName != Routes.lockScreen) { // await navService.push(Routes.lockScreen); diff --git a/lib/services/biometrics_service.dart b/lib/services/biometrics_service.dart index 90d344f..325fea5 100644 --- a/lib/services/biometrics_service.dart +++ b/lib/services/biometrics_service.dart @@ -4,9 +4,9 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:local_auth/local_auth.dart'; +import '../localization/app_localizations.g.dart'; import '../locator.dart'; import 'app_service.dart'; -import 'i18n_service.dart'; /// Handles biometrics class BiometricsService { @@ -35,18 +35,21 @@ class BiometricsService { } /// Authenticates the user with biometrics - Future authenticate({String? reason}) async { + Future authenticate( + AppLocalizations localizations, { + String? reason, + }) async { if (!_isResolved) { await isDeviceSupported(); } if (!_isSupported) { return false; } - reason ??= await _getLocalizedUnlockReason(); locator().ignoreBiometricsCheckAtNextResume = true; try { final result = await _localAuth.authenticate( - localizedReason: reason, + localizedReason: + reason ?? await _getLocalizedUnlockReason(localizations), options: const AuthenticationOptions( sensitiveTransaction: false, ), @@ -65,8 +68,9 @@ class BiometricsService { return false; } - Future _getLocalizedUnlockReason() async { - final localizations = locator().localizations; + Future _getLocalizedUnlockReason( + AppLocalizations localizations, + ) async { if (PlatformInfo.isCupertino) { final availableBiometrics = await _localAuth.getAvailableBiometrics(); if (availableBiometrics.contains(BiometricType.face)) { diff --git a/lib/services/date_service.dart b/lib/services/date_service.dart index 677a881..0be986d 100644 --- a/lib/services/date_service.dart +++ b/lib/services/date_service.dart @@ -1,6 +1,3 @@ -import '../locator.dart'; -import 'i18n_service.dart'; - /// The date section of a given date enum DateSectionRange { /// The date is in the future, more distant than tomorrow @@ -34,10 +31,13 @@ enum DateSectionRange { /// Allows to determine the date section of a given date class DateService { /// Creates a new [DateService] - DateService() { + DateService(this.firstDayOfWeek) { _setupDates(); } + /// The first weekday of the week + final int firstDayOfWeek; + late DateTime _today; late DateTime _tomorrow; late DateTime _dayAfterTomorrow; @@ -51,7 +51,6 @@ class DateService { _tomorrow = _today.add(const Duration(days: 1)); _dayAfterTomorrow = _tomorrow.add(const Duration(days: 1)); _yesterday = _today.subtract(const Duration(days: 1)); - final firstDayOfWeek = locator().firstDayOfWeek; if (_today.weekday == firstDayOfWeek) { _thisWeek = _today; } else if (_yesterday.weekday == firstDayOfWeek) { @@ -66,7 +65,9 @@ class DateService { } /// Determines the date section of the given [localTime] - DateSectionRange determineDateSection(DateTime localTime) { + DateSectionRange determineDateSection( + DateTime localTime, + ) { if (_today.weekday != DateTime.now().weekday) { _setupDates(); } diff --git a/lib/services/i18n_service.dart b/lib/services/i18n_service.dart deleted file mode 100644 index d7e1c07..0000000 --- a/lib/services/i18n_service.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:enough_icalendar/enough_icalendar.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/date_symbol_data_local.dart' as date_intl; -import 'package:intl/date_symbols.dart'; -import 'package:intl/intl.dart' as intl; -import 'package:intl/intl.dart'; - -import '../localization/app_localizations.g.dart'; -import 'date_service.dart'; - -class I18nService { - /// Day of week for countries (in two letter code) for which the week does not start on Monday - /// Source: http://chartsbin.com/view/41671 - static const firstDayOfWeekPerCountryCode = { - 'ae': DateTime.saturday, // United Arab Emirates - 'af': DateTime.saturday, // Afghanistan - 'ar': DateTime.sunday, // Argentina - 'bh': DateTime.saturday, // Bahrain - 'br': DateTime.sunday, // Brazil - 'bz': DateTime.sunday, // Belize - 'bo': DateTime.sunday, // Bolivia - 'ca': DateTime.sunday, // Canada - 'cl': DateTime.sunday, // Chile - 'cn': DateTime.sunday, // China - 'co': DateTime.sunday, // Colombia - 'cr': DateTime.sunday, // Costa Rica - 'do': DateTime.sunday, // Dominican Republic - 'dz': DateTime.saturday, // Algeria - 'ec': DateTime.sunday, // Ecuador - 'eg': DateTime.saturday, // Egypt - 'gt': DateTime.sunday, // Guatemala - 'hk': DateTime.sunday, // Hong Kong - 'hn': DateTime.sunday, // Honduras - 'il': DateTime.sunday, // Israel - 'iq': DateTime.saturday, // Iraq - 'ir': DateTime.saturday, // Iran - 'jm': DateTime.sunday, // Jamaica - 'io': DateTime.saturday, // Jordan - 'jp': DateTime.sunday, // Japan - 'ke': DateTime.sunday, // Kenya - 'kr': DateTime.sunday, // South Korea - 'kw': DateTime.saturday, // Kuwait - 'ly': DateTime.saturday, // Libya - 'mo': DateTime.sunday, // Macao - 'mx': DateTime.sunday, // Mexico - 'ni': DateTime.sunday, // Nicaragua - 'om': DateTime.saturday, // Oman - 'pa': DateTime.sunday, // Panama - 'pe': DateTime.sunday, // Peru - 'ph': DateTime.sunday, // Philippines - 'pr': DateTime.sunday, // Puerto Rico - 'qa': DateTime.saturday, // Qatar - 'sa': DateTime.saturday, // Saudi Arabia - 'sv': DateTime.sunday, // El Salvador - 'sy': DateTime.saturday, // Syria - 'tw': DateTime.sunday, // Taiwan - 'us': DateTime.sunday, // USA - 've': DateTime.sunday, // Venezuela - 'ye': DateTime.saturday, // Yemen - 'za': DateTime.sunday, // South Africa - 'zw': DateTime.sunday, // Zimbabwe - }; - int firstDayOfWeek = DateTime.monday; - Locale? _locale; - - late AppLocalizations _localizations; - AppLocalizations get localizations => _localizations; - - late intl.DateFormat _dateTimeFormatToday; - late intl.DateFormat _dateTimeFormatLastWeek; - late intl.DateFormat _dateTimeFormat; - late intl.DateFormat _dateTimeFormatLong; - late intl.DateFormat _dateFormatDayInLastWeek; - late intl.DateFormat _dateFormatDayBeforeLastWeek; - late intl.DateFormat _dateFormatLong; - late intl.DateFormat _dateFormatShort; - // late intl.DateFormat _dateFormatMonth; - late intl.DateFormat _dateFormatWeekday; - // late intl.DateFormat _dateFormatNoTime; - - void init(AppLocalizations localizations, Locale locale) { - _localizations = localizations; - _locale = locale; - final countryCode = locale.countryCode?.toLowerCase(); - firstDayOfWeek = countryCode == null - ? DateTime.monday - : firstDayOfWeekPerCountryCode[countryCode] ?? DateTime.monday; - final localeText = locale.toString(); - date_intl.initializeDateFormatting(localeText).then( - (_) { - _dateTimeFormatToday = intl.DateFormat.jm(localeText); - _dateTimeFormatLastWeek = intl.DateFormat.E(localeText).add_jm(); - _dateTimeFormat = intl.DateFormat.yMd(localeText).add_jm(); - _dateTimeFormatLong = intl.DateFormat.yMMMMEEEEd(localeText).add_jm(); - _dateFormatDayInLastWeek = intl.DateFormat.E(localeText); - _dateFormatDayBeforeLastWeek = intl.DateFormat.yMd(localeText); - _dateFormatLong = intl.DateFormat.yMMMMEEEEd(localeText); - _dateFormatShort = intl.DateFormat.yMd(localeText); - _dateFormatWeekday = intl.DateFormat.EEEE(localeText); - // _dateFormatMonth = intl.DateFormat.MMMM(localeText); - // _dateFormatNoTime = intl.DateFormat.yMEd(localeText); - }, - ); - } - - String formatDateTime( - DateTime? dateTime, { - bool alwaysUseAbsoluteFormat = false, - useLongFormat = false, - }) { - if (dateTime == null) { - return _localizations.dateUndefined; - } - if (alwaysUseAbsoluteFormat) { - if (useLongFormat) { - return _dateTimeFormatLong.format(dateTime); - } - return _dateTimeFormat.format(dateTime); - } - final nw = DateTime.now(); - final today = nw.subtract( - Duration( - hours: nw.hour, - minutes: nw.minute, - seconds: nw.second, - milliseconds: nw.millisecond, - ), - ); - final lastWeek = today.subtract(const Duration(days: 7)); - String date; - if (dateTime.isAfter(today)) { - date = _dateTimeFormatToday.format(dateTime); - } else if (dateTime.isAfter(lastWeek)) { - date = _dateTimeFormatLastWeek.format(dateTime); - } else { - date = useLongFormat - ? _dateTimeFormatLong.format(dateTime) - : _dateTimeFormat.format(dateTime); - } - - return date; - } - - String formatDate(DateTime? dateTime, {bool useLongFormat = false}) { - if (dateTime == null) { - return _localizations.dateUndefined; - } - - return useLongFormat - ? _dateFormatLong.format(dateTime) - : _dateFormatShort.format(dateTime); - } - - String formatDay(DateTime dateTime) { - final messageDate = dateTime; - final nw = DateTime.now(); - final today = nw.subtract( - Duration( - hours: nw.hour, - minutes: nw.minute, - seconds: nw.second, - milliseconds: nw.millisecond, - ), - ); - if (messageDate.isAfter(today)) { - return localizations.dateDayToday; - } else if (messageDate.isAfter(today.subtract(const Duration(days: 1)))) { - return localizations.dateDayYesterday; - } else if (messageDate.isAfter(today.subtract(const Duration(days: 7)))) { - return localizations - .dateDayLastWeekday(_dateFormatDayInLastWeek.format(messageDate)); - } else { - return _dateFormatDayBeforeLastWeek.format(messageDate); - } - } - - String formatWeekDay(DateTime dateTime) => - _dateFormatWeekday.format(dateTime); - - List formatWeekDays({int? startOfWeekDay, bool abbreviate = false}) { - startOfWeekDay ??= firstDayOfWeek; - final dateSymbols = - date_intl.dateTimeSymbolMap()[_locale.toString()] as DateSymbols; - final weekdays = abbreviate - ? dateSymbols.STANDALONESHORTWEEKDAYS - : dateSymbols.STANDALONEWEEKDAYS; - final result = []; - for (int i = 0; i < 7; i++) { - final day = ((startOfWeekDay + i) <= 7) - ? (startOfWeekDay + i) - : ((startOfWeekDay + i) - 7); - final nameIndex = day == DateTime.sunday ? 0 : day; - final name = weekdays[nameIndex]; - result.add(WeekDay(day, name)); - } - - return result; - } - - String formatDateRange(DateSectionRange range, DateTime dateTime) { - switch (range) { - case DateSectionRange.future: - return _localizations.dateRangeFuture; - case DateSectionRange.tomorrow: - return _localizations.dateRangeTomorrow; - case DateSectionRange.today: - return _localizations.dateRangeToday; - case DateSectionRange.yesterday: - return _localizations.dateRangeYesterday; - case DateSectionRange.thisWeek: - return _localizations.dateRangeCurrentWeek; - case DateSectionRange.lastWeek: - return _localizations.dateRangeLastWeek; - case DateSectionRange.thisMonth: - return _localizations.dateRangeCurrentMonth; - case DateSectionRange.monthOfThisYear: - return _localizations.dateRangeCurrentYear; - case DateSectionRange.monthAndYear: - return _localizations.dateRangeLongAgo; - } - } - - String formatTimeOfDay(TimeOfDay timeOfDay, BuildContext context) => - timeOfDay.format(context); - - String? formatMemory(int? size) { - if (size == null) { - return null; - } - double sizeD = size + 0.0; - final units = ['gb', 'mb', 'kb', 'bytes']; - var unitIndex = units.length - 1; - while ((sizeD / 1024) > 1.0 && unitIndex > 0) { - sizeD = sizeD / 1024; - unitIndex--; - } - final sizeFormat = NumberFormat('###.0#'); - - return '${sizeFormat.format(sizeD)} ${units[unitIndex]}'; - } - - String formatIsoDuration(IsoDuration duration) { - final localizations = _localizations; - final buffer = StringBuffer(); - if (duration.isNegativeDuration) { - buffer.write('-'); - } - if (duration.years > 0) { - buffer.write(localizations.durationYears(duration.years)); - } - if (duration.months > 0) { - if (buffer.isNotEmpty) buffer.write(', '); - buffer.write(localizations.durationMonths(duration.months)); - } - if (duration.weeks > 0) { - if (buffer.isNotEmpty) buffer.write(', '); - buffer.write(localizations.durationWeeks(duration.weeks)); - } - if (duration.days > 0) { - if (buffer.isNotEmpty) buffer.write(', '); - buffer.write(localizations.durationDays(duration.days)); - } - if (duration.hours > 0) { - if (buffer.isNotEmpty) buffer.write(', '); - buffer.write(localizations.durationHours(duration.hours)); - } - if (duration.minutes > 0) { - if (buffer.isNotEmpty) buffer.write(', '); - buffer.write(localizations.durationHours(duration.minutes)); - } - if (buffer.isEmpty) { - buffer.write(localizations.durationEmpty); - } - - return buffer.toString(); - } -} - -class WeekDay { - const WeekDay(this.day, this.name); - final int day; - final String name; -} diff --git a/lib/services/scaffold_messenger_service.dart b/lib/services/scaffold_messenger_service.dart index 1a36698..5f5ce6c 100644 --- a/lib/services/scaffold_messenger_service.dart +++ b/lib/services/scaffold_messenger_service.dart @@ -1,10 +1,10 @@ import 'dart:io'; -import '../locator.dart'; -import 'i18n_service.dart'; -import '../widgets/cupertino_status_bar.dart'; import 'package:flutter/material.dart'; +import '../localization/app_localizations.g.dart'; +import '../widgets/cupertino_status_bar.dart'; + class ScaffoldMessengerService { final GlobalKey scaffoldMessengerKey = GlobalKey(); @@ -20,37 +20,43 @@ class ScaffoldMessengerService { } void popStatusBarState() { - if (_statusBarStates.isNotEmpty) { - _statusBarState = _statusBarStates.removeLast(); - } else { - _statusBarState = null; - } + _statusBarState = + _statusBarStates.isNotEmpty ? _statusBarStates.removeLast() : null; } - SnackBar _buildTextSnackBar(String text, {Function()? undo}) => SnackBar( - content: Text(text), - action: undo == null - ? null - : SnackBarAction( - label: locator().localizations.actionUndo, - onPressed: undo, - ), - ); + SnackBar _buildTextSnackBar( + AppLocalizations localizations, + String text, { + Function()? undo, + }) => + SnackBar( + content: Text(text), + action: undo == null + ? null + : SnackBarAction( + label: localizations.actionUndo, + onPressed: undo, + ), + ); void _showSnackBar(SnackBar snackBar) { scaffoldMessengerKey.currentState?.showSnackBar(snackBar); } - void showTextSnackBar(String text, {Function()? undo}) { + void showTextSnackBar( + AppLocalizations localizations, + String text, { + Function()? undo, + }) { if (Platform.isIOS || Platform.isMacOS) { final state = _statusBarState; if (state != null) { state.showTextStatus(text, undo: undo); } else { - _showSnackBar(_buildTextSnackBar(text, undo: undo)); + _showSnackBar(_buildTextSnackBar(localizations, text, undo: undo)); } } else { - _showSnackBar(_buildTextSnackBar(text, undo: undo)); + _showSnackBar(_buildTextSnackBar(localizations, text, undo: undo)); } } } diff --git a/lib/settings/provider.dart b/lib/settings/provider.dart index 1e4eb93..e3b9d0f 100644 --- a/lib/settings/provider.dart +++ b/lib/settings/provider.dart @@ -1,10 +1,10 @@ +import 'package:flutter/widgets.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../account/model.dart'; -import '../locator.dart'; +import '../localization/extension.dart'; import '../logger.dart'; import '../models/compose_data.dart'; -import '../services/i18n_service.dart'; import 'model.dart'; import 'storage.dart'; @@ -50,6 +50,7 @@ class SettingsNotifier extends Notifier { /// Retrieves the HTML signature for the specified [account] /// and [composeAction] String getSignatureHtml( + BuildContext context, RealAccount account, ComposeAction composeAction, String? languageCode, @@ -58,26 +59,28 @@ class SettingsNotifier extends Notifier { return ''; } - return account.getSignatureHtml(languageCode) ?? getSignatureHtmlGlobal(); + return account.getSignatureHtml(languageCode) ?? + getSignatureHtmlGlobal(context); } /// Retrieves the global signature - String getSignatureHtmlGlobal() => - state.signatureHtml ?? '

---
$_fallbackSignature

'; + String getSignatureHtmlGlobal(BuildContext context) => + state.signatureHtml ?? '

---
${context.text.signature}

'; /// Retrieves the plain text signature for the specified account - String getSignaturePlain(RealAccount account, ComposeAction composeAction) { + String getSignaturePlain( + BuildContext context, + RealAccount account, + ComposeAction composeAction, + ) { if (!state.signatureActions.contains(composeAction)) { return ''; } - return account.signaturePlain ?? getSignaturePlainGlobal(); + return account.signaturePlain ?? getSignaturePlainGlobal(context); } /// Retrieves the global plain text signature - String getSignaturePlainGlobal() => - state.signaturePlain ?? '\n---\n$_fallbackSignature'; - - String get _fallbackSignature => - locator().localizations.signature; + String getSignaturePlainGlobal(BuildContext context) => + state.signaturePlain ?? '\n---\n${context.text.signature}'; } diff --git a/lib/settings/view/settings_feedback_screen.dart b/lib/settings/view/settings_feedback_screen.dart index 92601c8..9331569 100644 --- a/lib/settings/view/settings_feedback_screen.dart +++ b/lib/settings/view/settings_feedback_screen.dart @@ -97,6 +97,7 @@ class _SettingsFeedbackScreenState extends State { onPressed: () { Clipboard.setData(ClipboardData(text: info ?? '')); locator().showTextSnackBar( + localizations, localizations.feedbackResultInfoCopied, ); }, diff --git a/lib/settings/view/settings_folders_screen.dart b/lib/settings/view/settings_folders_screen.dart index 1fa0a4c..1fad1e8 100644 --- a/lib/settings/view/settings_folders_screen.dart +++ b/lib/settings/view/settings_folders_screen.dart @@ -359,8 +359,10 @@ class MailboxWidget extends ConsumerWidget { folderNameController.text, mailbox, ); - locator() - .showTextSnackBar(localizations.folderAddResultSuccess); + locator().showTextSnackBar( + localizations, + localizations.folderAddResultSuccess, + ); onMailboxAdded(); } on MailException catch (e) { if (context.mounted) { @@ -391,8 +393,10 @@ class MailboxWidget extends ConsumerWidget { await ref .read(mailClientSourceProvider(account: account).notifier) .deleteMailbox(mailbox); - locator() - .showTextSnackBar(localizations.folderDeleteResultSuccess); + locator().showTextSnackBar( + localizations, + localizations.folderDeleteResultSuccess, + ); onMailboxDeleted(); } on MailException catch (e) { if (context.mounted) { diff --git a/lib/settings/view/settings_language_screen.dart b/lib/settings/view/settings_language_screen.dart index b22d70d..875fd98 100644 --- a/lib/settings/view/settings_language_screen.dart +++ b/lib/settings/view/settings_language_screen.dart @@ -6,9 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../localization/app_localizations.g.dart'; import '../../localization/extension.dart'; -import '../../locator.dart'; import '../../screens/base.dart'; -import '../../services/i18n_service.dart'; import '../../util/localized_dialog_helper.dart'; import '../../widgets/button_text.dart'; import '../provider.dart'; @@ -28,18 +26,19 @@ class SettingsLanguageScreen extends HookConsumerWidget { ) .toList(); final systemLanguage = _Language( - null, locator().localizations.designThemeOptionSystem); + null, + context.text.designThemeOptionSystem, + ); + final languages = [systemLanguage, ...available]; final languageTag = ref.watch( settingsProvider.select((value) => value.languageTag), ); final _Language? selectedLanguage; - if (languageTag != null) { - selectedLanguage = available - .firstWhereOrNull((l) => l.locale?.toLanguageTag() == languageTag); - } else { - selectedLanguage = systemLanguage; - } + selectedLanguage = languageTag != null + ? available + .firstWhereOrNull((l) => l.locale?.toLanguageTag() == languageTag) + : systemLanguage; final theme = Theme.of(context); final localizations = context.text; diff --git a/lib/settings/view/settings_security_screen.dart b/lib/settings/view/settings_security_screen.dart index 0743586..16b558a 100644 --- a/lib/settings/view/settings_security_screen.dart +++ b/lib/settings/view/settings_security_screen.dart @@ -129,8 +129,10 @@ class SettingsSecurityScreen extends HookConsumerWidget { ? null : localizations.securityUnlockDisableReason; final didAuthenticate = - await locator() - .authenticate(reason: reason); + await locator().authenticate( + localizations, + reason: reason, + ); if (didAuthenticate) { await ref.read(settingsProvider.notifier).update( settings.copyWith( diff --git a/lib/widgets/attachment_chip.dart b/lib/widgets/attachment_chip.dart index 37218e6..83ade56 100644 --- a/lib/widgets/attachment_chip.dart +++ b/lib/widgets/attachment_chip.dart @@ -10,7 +10,6 @@ import '../logger.dart'; import '../models/message.dart'; import '../routes.dart'; import '../screens/media_screen.dart'; -import '../services/i18n_service.dart'; import '../services/icon_service.dart'; import 'button_text.dart'; import 'ical_interactive_media.dart'; @@ -220,7 +219,7 @@ class _AttachmentChipState extends State { BuildContext context, MediaProvider mediaProvider, ) { - final sizeText = locator().formatMemory(mediaProvider.size); + final sizeText = context.formatMemory(mediaProvider.size); final localizations = context.text; final iconData = locator() .getForMediaType(MediaType.fromText(mediaProvider.mediaType)); diff --git a/lib/widgets/cupertino_status_bar.dart b/lib/widgets/cupertino_status_bar.dart index 8c03efb..c216558 100644 --- a/lib/widgets/cupertino_status_bar.dart +++ b/lib/widgets/cupertino_status_bar.dart @@ -1,10 +1,9 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/cupertino.dart'; -import '../services/i18n_service.dart'; -import '../services/scaffold_messenger_service.dart'; - +import '../localization/extension.dart'; import '../locator.dart'; +import '../services/scaffold_messenger_service.dart'; /// Status bar for cupertino. /// @@ -26,11 +25,11 @@ class CupertinoStatusBar extends StatefulWidget { CupertinoStatusBarState createState() => CupertinoStatusBarState(); static Widget? createInfo(String? text) => (text == null) - ? null - : Text( - text, - style: _statusTextStyle, - ); + ? null + : Text( + text, + style: _statusTextStyle, + ); } class CupertinoStatusBarState extends State { @@ -74,6 +73,7 @@ class CupertinoStatusBarState extends State { child: _status, ) : widget.info ?? Container(); + return CupertinoBar( blurBackground: true, backgroundOpacity: 0.8, @@ -124,7 +124,7 @@ class CupertinoStatusBarState extends State { padding: const EdgeInsets.all(8), minSize: 20, child: Text( - locator().localizations.actionUndo, + context.text.actionUndo, style: CupertinoStatusBar._statusTextStyle, ), onPressed: () { diff --git a/lib/widgets/ical_composer.dart b/lib/widgets/ical_composer.dart index 51a9ed8..9bcad5c 100644 --- a/lib/widgets/ical_composer.dart +++ b/lib/widgets/ical_composer.dart @@ -7,8 +7,6 @@ import '../account/model.dart'; import '../account/provider.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; -import '../locator.dart'; -import '../services/i18n_service.dart'; import '../util/datetime.dart'; import '../util/modal_bottom_sheet_helper.dart'; @@ -111,6 +109,7 @@ class _IcalComposerState extends ConsumerState { final isAllday = _event.isAllDayEvent ?? false; final recurrenceRule = _event.recurrenceRule; final theme = Theme.of(context); + return Material( child: Padding( padding: const EdgeInsets.all(8), @@ -311,9 +310,9 @@ class DateTimePicker extends StatelessWidget { @override Widget build(BuildContext context) { - final i18nService = locator(); final localizations = context.text; final dt = dateTime; + return Row( children: [ // set date button: @@ -321,7 +320,7 @@ class DateTimePicker extends StatelessWidget { child: PlatformText( dt == null ? localizations.composeAppointmentLabelDay - : i18nService.formatDate(dt.toLocal(), useLongFormat: true), + : context.formatDate(dt.toLocal(), useLongFormat: true), ), onPressed: () async { FocusScope.of(context).unfocus(); @@ -348,8 +347,9 @@ class DateTimePicker extends StatelessWidget { child: PlatformText( dt == null ? localizations.composeAppointmentLabelTime - : i18nService.formatTimeOfDay( - TimeOfDay.fromDateTime(dt.toLocal()), context), + : context.formatTimeOfDay( + TimeOfDay.fromDateTime(dt.toLocal()), + ), ), onPressed: () async { FocusScope.of(context).unfocus(); @@ -422,10 +422,10 @@ class _RecurrenceComposerState extends State { @override Widget build(BuildContext context) { - final i18nService = locator(); final localizations = context.text; final rule = _recurrenceRule; + return Padding( padding: const EdgeInsets.all(8), child: Column( @@ -436,7 +436,8 @@ class _RecurrenceComposerState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - localizations.composeAppointmentRecurrenceFrequencyLabel), + localizations.composeAppointmentRecurrenceFrequencyLabel, + ), ), PlatformDropdownButton<_RepeatFrequency>( items: _RepeatFrequency.values @@ -559,11 +560,11 @@ class _RecurrenceComposerState extends State { : rule.until == _recommendationDate ? localizations .composeAppointmentRecurrenceUntilOptionRecommended( - i18nService.formatIsoDuration( + context.formatIsoDuration( rule.frequency.recommendedUntil!, ), ) - : i18nService.formatDate( + : context.formatDate( rule.until, useLongFormat: true, ), @@ -619,11 +620,10 @@ class _WeekDaySelectorState extends State { @override void initState() { super.initState(); - final i18nService = locator(); - _weekdays = i18nService.formatWeekDays(abbreviate: true); + _weekdays = context.formatWeekDays(abbreviate: true); final byWeekDays = widget.recurrence.byWeekDay; if (byWeekDays != null) { - final int firstDayOfWeek = i18nService.firstDayOfWeek; + final int firstDayOfWeek = context.firstDayOfWeek; for (int i = 0; i < 7; i++) { final day = ((firstDayOfWeek + i) <= 7) ? (firstDayOfWeek + i) @@ -727,7 +727,7 @@ class _DayOfMonthSelectorState extends State { @override void initState() { super.initState(); - _weekdays = locator().formatWeekDays(); + _weekdays = context.formatWeekDays(); if (widget.recurrence.hasByMonthDay) { _option = _DayOfMonthOption.dayOfMonth; } else { @@ -748,6 +748,7 @@ class _DayOfMonthSelectorState extends State { Widget build(BuildContext context) { final localizations = context.text; final rule = _byDayRule; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -755,8 +756,10 @@ class _DayOfMonthSelectorState extends State { groupValue: _option, value: _DayOfMonthOption.dayOfMonth, title: Text( - localizations.composeAppointmentRecurrenceMonthlyOnDayOfMonth( - widget.startDate.day)), + localizations.composeAppointmentRecurrenceMonthlyOnDayOfMonth( + widget.startDate.day, + ), + ), onChanged: (value) { setState(() { _option = value!; @@ -773,8 +776,9 @@ class _DayOfMonthSelectorState extends State { onChanged: (value) { if (_byDayRule == null) { final recurrence = DayOfMonthSelector.updateMonthlyRecurrence( - widget.recurrence.copyWith(copyByRules: false), - widget.startDate)!; + widget.recurrence.copyWith(copyByRules: false), + widget.startDate, + )!; final rule = recurrence.byWeekDay!.first; _byDayRule = rule; _currentWeekday = @@ -857,8 +861,13 @@ class _DayOfMonthSelectorState extends State { } class UntilComposer extends StatefulWidget { - const UntilComposer( - {super.key, required this.start, this.until, this.recommendation}); + const UntilComposer({ + super.key, + required this.start, + this.until, + this.recommendation, + }); + final DateTime start; final DateTime? until; final IsoDuration? recommendation; @@ -876,11 +885,7 @@ class UntilComposer extends StatefulWidget { UntilComposer(start: start, until: until, recommendation: recommendation), ); - if (result) { - return _UntilComposerState._currentState._until; - } else { - return until; - } + return result ? _UntilComposerState._currentState._until : until; } } @@ -913,6 +918,7 @@ class _UntilComposerState extends State { // final i18nService = locator(); final localizations = context.text; final theme = Theme.of(context); + return Padding( padding: const EdgeInsets.all(8), child: Column( @@ -926,13 +932,20 @@ class _UntilComposerState extends State { value: value, onChanged: _onChanged, title: Text( - value.localization(localizations, widget.recommendation)), + value.localization( + context, + localizations, + widget.recommendation, + ), + ), ), if (_option == _UntilOption.date) ...[ Padding( padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), - child: Text(localizations.composeAppointmentRecurrenceUntilLabel, - style: theme.textTheme.bodySmall), + child: Text( + localizations.composeAppointmentRecurrenceUntilLabel, + style: theme.textTheme.bodySmall, + ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -975,14 +988,17 @@ enum _UntilOption { unlimited, recommendation, date } extension _ExtensionUntilOption on _UntilOption { String localization( - AppLocalizations localizations, IsoDuration? recommendation) { + BuildContext context, + AppLocalizations localizations, + IsoDuration? recommendation, + ) { switch (this) { case _UntilOption.unlimited: return localizations.composeAppointmentRecurrenceUntilOptionUnlimited; case _UntilOption.recommendation: final duration = recommendation == null ? '' - : locator().formatIsoDuration(recommendation); + : context.formatIsoDuration(recommendation); return localizations .composeAppointmentRecurrenceUntilOptionRecommended(duration); case _UntilOption.date: diff --git a/lib/widgets/ical_interactive_media.dart b/lib/widgets/ical_interactive_media.dart index 055d9fb..0de2b96 100644 --- a/lib/widgets/ical_interactive_media.dart +++ b/lib/widgets/ical_interactive_media.dart @@ -15,7 +15,6 @@ import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; import '../locator.dart'; import '../models/message.dart'; -import '../services/i18n_service.dart'; import '../services/scaffold_messenger_service.dart'; import '../util/localized_dialog_helper.dart'; import 'mail_address_chip.dart'; @@ -85,7 +84,6 @@ class _IcalInteractiveMediaState extends State { } final isReply = _calendar?.method == Method.reply; final attendees = isReply ? [] : event.attendees; - final i18nService = locator(); final userEmail = widget.message.account.email.toLowerCase(); final recurrenceRule = event.recurrenceRule; final end = event.end; @@ -178,7 +176,7 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - i18nService.formatDateTime( + context.formatDateTime( start.toLocal(), alwaysUseAbsoluteFormat: true, useLongFormat: true, @@ -197,7 +195,7 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - i18nService.formatDateTime( + context.formatDateTime( end.toLocal(), alwaysUseAbsoluteFormat: true, useLongFormat: true, @@ -216,7 +214,7 @@ class _IcalInteractiveMediaState extends State { Padding( padding: const EdgeInsets.all(8), child: Text( - i18nService.formatIsoDuration(duration), + context.formatIsoDuration(duration), ), ), ], @@ -414,7 +412,7 @@ class _IcalInteractiveMediaState extends State { productId: 'Maily', ); locator() - .showTextSnackBar(status.localization(localizations)); + .showTextSnackBar(localizations, status.localization(localizations)); } catch (e, s) { if (kDebugMode) { print('Unable to send status update: $e $s'); diff --git a/lib/widgets/mail_address_chip.dart b/lib/widgets/mail_address_chip.dart index 324ed3c..c524453 100644 --- a/lib/widgets/mail_address_chip.dart +++ b/lib/widgets/mail_address_chip.dart @@ -61,8 +61,10 @@ class MailAddressChip extends StatelessWidget { break; case _AddressAction.copy: await Clipboard.setData(ClipboardData(text: mailAddress.email)); - locator() - .showTextSnackBar(localizations.feedbackResultInfoCopied); + locator().showTextSnackBar( + localizations, + localizations.feedbackResultInfoCopied, + ); break; case _AddressAction.compose: final messageBuilder = MessageBuilder()..to = [mailAddress]; diff --git a/lib/widgets/message_actions.dart b/lib/widgets/message_actions.dart index b645bad..a3823a9 100644 --- a/lib/widgets/message_actions.dart +++ b/lib/widgets/message_actions.dart @@ -397,8 +397,10 @@ class MessageActions extends HookConsumerWidget { recipients: recipients, appendToSent: false, ); - locator() - .showTextSnackBar(localizations.resultRedirectedSuccess); + locator().showTextSnackBar( + localizations, + localizations.resultRedirectedSuccess, + ); } on MailException catch (e, s) { if (kDebugMode) { print('message could not get redirected: $e $s'); @@ -417,10 +419,12 @@ class MessageActions extends HookConsumerWidget { } Future _delete(BuildContext context) async { + final localizations = context.text; context.pop(); await message.source.deleteMessages( + localizations, [message], - context.text.resultDeleted, + localizations.resultDeleted, ); } @@ -448,6 +452,7 @@ class MessageActions extends HookConsumerWidget { final localizations = context.text; final source = message.source; await source.moveMessage( + localizations, message, mailbox, localizations.moveSuccess(mailbox.name), @@ -457,10 +462,10 @@ class MessageActions extends HookConsumerWidget { Future _moveJunk(BuildContext context) async { final source = message.source; if (source.isJunk) { - await source.markAsNotJunk(message); + await source.markAsNotJunk(context.text, message); } else { locator().cancelNotificationForMessage(message); - await source.markAsJunk(message); + await source.markAsJunk(context.text, message); } if (context.mounted) { context.pop(); @@ -469,10 +474,12 @@ class MessageActions extends HookConsumerWidget { Future _moveToInbox(BuildContext context) async { final source = message.source; + final localizations = context.text; await source.moveMessageToFlag( + localizations, message, MailboxFlag.inbox, - context.text.resultMovedToInbox, + localizations.resultMovedToInbox, ); if (context.mounted) { context.pop(); @@ -482,10 +489,10 @@ class MessageActions extends HookConsumerWidget { Future _moveArchive(BuildContext context) async { final source = message.source; if (source.isArchive) { - await source.moveToInbox(message); + await source.moveToInbox(context.text, message); } else { locator().cancelNotificationForMessage(message); - await source.archive(message); + await source.archive(context.text, message); } if (context.mounted) { context.pop(); diff --git a/lib/widgets/message_overview_content.dart b/lib/widgets/message_overview_content.dart index 31a786a..9f61747 100644 --- a/lib/widgets/message_overview_content.dart +++ b/lib/widgets/message_overview_content.dart @@ -3,9 +3,7 @@ import 'package:flutter/material.dart'; import '../localization/app_localizations.g.dart'; import '../localization/extension.dart'; -import '../locator.dart'; import '../models/message.dart'; -import '../services/i18n_service.dart'; import '../services/icon_service.dart'; class MessageOverviewContent extends StatelessWidget { @@ -27,8 +25,9 @@ class MessageOverviewContent extends StatelessWidget { final subject = mime.decodeSubject() ?? localizations.subjectUndefined; final senderOrRecipients = _getSenderOrRecipients(mime, localizations); final hasAttachments = msg.hasAttachment; - final date = locator().formatDateTime(mime.decodeDate()); + final date = context.formatDateTime(mime.decodeDate()); final theme = Theme.of(context); + return Container( padding: const EdgeInsets.symmetric(vertical: 4), color: msg.isFlagged ? theme.colorScheme.secondary : null, @@ -47,8 +46,9 @@ class MessageOverviewContent extends StatelessWidget { overflow: TextOverflow.fade, softWrap: false, style: TextStyle( - fontWeight: - msg.isSeen ? FontWeight.normal : FontWeight.bold), + fontWeight: + msg.isSeen ? FontWeight.normal : FontWeight.bold, + ), ), ), ), @@ -69,8 +69,11 @@ class MessageOverviewContent extends StatelessWidget { if (msg.isAnswered) const Icon(Icons.reply, size: 12), if (msg.isForwarded) const Icon(Icons.forward, size: 12), if (threadLength != 0) - IconService.buildNumericIcon(context, threadLength, - size: 12), + IconService.buildNumericIcon( + context, + threadLength, + size: 12, + ), ], ), ), @@ -80,8 +83,9 @@ class MessageOverviewContent extends StatelessWidget { subject, overflow: TextOverflow.ellipsis, style: TextStyle( - fontStyle: FontStyle.italic, - fontWeight: msg.isSeen ? FontWeight.normal : FontWeight.bold), + fontStyle: FontStyle.italic, + fontWeight: msg.isSeen ? FontWeight.normal : FontWeight.bold, + ), ), ], ), @@ -96,11 +100,8 @@ class MessageOverviewContent extends StatelessWidget { .join(', '); } MailAddress? from; - if (mime.from?.isNotEmpty ?? false) { - from = mime.from!.first; - } else { - from = mime.sender; - } + from = mime.from?.isNotEmpty ?? false ? mime.from!.first : mime.sender; + return (from?.personalName?.isNotEmpty ?? false) ? from!.personalName! : from?.email != null diff --git a/lib/widgets/message_stack.dart b/lib/widgets/message_stack.dart index f6fbc70..16d5102 100644 --- a/lib/widgets/message_stack.dart +++ b/lib/widgets/message_stack.dart @@ -5,10 +5,10 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import '../localization/extension.dart'; import '../locator.dart'; import '../models/message.dart'; import '../models/message_source.dart'; -import '../services/i18n_service.dart'; import '../services/scaffold_messenger_service.dart'; import 'mail_address_chip.dart'; @@ -78,8 +78,8 @@ class _MessageStackState extends State { Widget build(BuildContext context) { final quickReplies = ['OK', 'Thank you!', '👍', '😊']; final dateTime = _currentMessage!.mimeMessage.decodeDate(); - final dayName = - dateTime == null ? '' : locator().formatDay(dateTime); + final dayName = dateTime == null ? '' : context.formatDay(dateTime); + return Stack( alignment: Alignment.center, fit: StackFit.expand, @@ -180,8 +180,11 @@ class _MessageStackState extends State { ); } - Future acceptDragOperation(Message message, DragAction action, - {Object? data}) async { + Future acceptDragOperation( + Message message, + DragAction action, { + Object? data, + }) async { moveToNextMessage(); //print('drag operation: $action'); String? snack; @@ -215,30 +218,35 @@ class _MessageStackState extends State { break; } if (snack != null) { - //TODO allow undo when marking as deleted - locator().showTextSnackBar( - snack, - undo: () async { - // bring back message: - setState(() { - _currentMessage = message; - _currentMessageIndex = message.sourceIndex; - }); - await undo(); - }, - ); + if (context.mounted) { + //TODO allow undo when marking as deleted + locator().showTextSnackBar( + context.text, + snack, + undo: () async { + // bring back message: + setState(() { + _currentMessage = message; + _currentMessageIndex = message.sourceIndex; + }); + await undo(); + }, + ); + } } } } class MessageDragTarget extends StatefulWidget { - const MessageDragTarget( - {super.key, - required this.action, - required this.onComplete, - this.data, - this.width, - this.height}); + const MessageDragTarget({ + super.key, + required this.action, + required this.onComplete, + this.data, + this.width, + this.height, + }); + final DragAction action; final Object? data; final Function(Message message, DragAction action, {Object? data}) onComplete; @@ -441,6 +449,7 @@ class _MessageCardState extends State { Widget buildMessageContents() { final mime = widget.message!.mimeMessage; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -478,22 +487,25 @@ class _MessageCardState extends State { // when the widget is not exposed, unless the content is there already if (!widget.message!.mimeMessage.isDownloaded) { return FutureBuilder( - future: downloadMessageContents(widget.message!), - builder: (context, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return const Center(child: PlatformProgressIndicator()); - case ConnectionState.done: - if (snapshot.hasError) { - return const Text('Unable to download message'); - } - break; - } - return buildMessageContent(context); - }); + future: downloadMessageContents(widget.message!), + builder: (context, snapshot) { + switch (snapshot.connectionState) { + case ConnectionState.none: + case ConnectionState.waiting: + case ConnectionState.active: + return const Center(child: PlatformProgressIndicator()); + case ConnectionState.done: + if (snapshot.hasError) { + return const Text('Unable to download message'); + } + break; + } + + return buildMessageContent(context); + }, + ); } + return buildMessageContent(context); } diff --git a/lib/widgets/search_text_field.dart b/lib/widgets/search_text_field.dart index c451b03..a1d7d03 100644 --- a/lib/widgets/search_text_field.dart +++ b/lib/widgets/search_text_field.dart @@ -26,7 +26,7 @@ class CupertinoSearch extends StatelessWidget { void _onSearchSubmitted(BuildContext context, String text) { final search = MailSearch(text, SearchQueryType.allTextHeaders); - final next = messageSource.search(search); + final next = messageSource.search(context.text, search); context.pushNamed( Routes.messageSource, extra: next, diff --git a/lib/widgets/signature.dart b/lib/widgets/signature.dart index c093eb9..d31d3ec 100644 --- a/lib/widgets/signature.dart +++ b/lib/widgets/signature.dart @@ -26,7 +26,7 @@ class SignatureWidget extends HookConsumerWidget { final account = this.account; final signatureState = useState( account?.getSignatureHtml(context.text.localeName) ?? - ref.read(settingsProvider.notifier).getSignatureHtmlGlobal(), + ref.read(settingsProvider.notifier).getSignatureHtmlGlobal(context), ); final signature = signatureState.value; @@ -40,7 +40,9 @@ class SignatureWidget extends HookConsumerWidget { account?.name ?? localizations.signatureSettingsTitle, PackagedHtmlEditor( initialContent: signature ?? - ref.read(settingsProvider.notifier).getSignatureHtmlGlobal(), + ref + .read(settingsProvider.notifier) + .getSignatureHtmlGlobal(context), excludeDocumentLevelControls: true, onCreated: (api) => editorApi = api, ), @@ -58,10 +60,11 @@ class SignatureWidget extends HookConsumerWidget { } else { final settings = ref.read(settingsProvider); final notifier = ref.read(settingsProvider.notifier); + signatureState.value = + notifier.getSignatureHtmlGlobal(context); await notifier.update( settings.withoutSignatures(), ); - signatureState.value = notifier.getSignatureHtmlGlobal(); } }, ), diff --git a/pubspec.yaml b/pubspec.yaml index 2240535..4ee6d10 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,7 @@ dependencies: sdk: flutter flutter_colorpicker: ^1.0.3 flutter_hooks: ^0.20.1 - flutter_local_notifications: ^15.1.0+1 + flutter_local_notifications: ^16.1.0 flutter_localizations: sdk: flutter flutter_secure_storage: ^9.0.0 diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index b8c593c..3499881 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -1,5 +1,7 @@ import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail_app/account/model.dart'; +import 'package:enough_mail_app/localization/app_localizations.g.dart'; +import 'package:enough_mail_app/localization/app_localizations_en.g.dart'; import 'package:enough_mail_app/models/async_mime_source.dart'; import 'package:enough_mail_app/models/message.dart'; import 'package:enough_mail_app/models/message_source.dart'; @@ -21,7 +23,8 @@ void main() async { final notificationService = TestNotificationService(); GetIt.instance.registerSingleton(notificationService); GetIt.instance.registerLazySingleton( - TestScaffoldMessengerService.new); + TestScaffoldMessengerService.new, + ); final firstMimeSourceStartDate = DateTime.utc(2022, 04, 16, 09); const firstMimeSourceDifferencePerMessage = Duration(minutes: 5); @@ -1054,14 +1057,19 @@ void main() async { expect(message.mimeMessage.sequenceId, 19); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( - message.mimeMessage.decodeDate(), - secondMimeSourceStartDate - .subtract(secondMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .subtract(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); final messages = [await source.getMessageAt(2)]; expect(source.size, 120); - await source.deleteMessages(messages, 'deleted messages'); + await source.deleteMessages( + AppLocalizationsEn(), + messages, + 'deleted messages', + ); expect(source.size, 119); expect(sourceNotifyCounter, 1); expect(notificationService.sendNotifications, 0); @@ -1070,24 +1078,29 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 99); expect(message.mimeMessage.guid, 100); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); message = await source.getMessageAt(2); expect(message.mimeMessage.sequenceId, 19); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect( - message.mimeMessage.decodeDate(), - secondMimeSourceStartDate - .subtract(secondMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .subtract(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); await _expectMessagesOrderedByDate(); }); @@ -1101,14 +1114,18 @@ void main() async { var message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 100); expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); message = await source.getMessageAt(2); expect(message.mimeMessage.sequenceId, 99); @@ -1130,7 +1147,11 @@ void main() async { final messages = [await source.getMessageAt(2)]; expect(source.size, 120); - await source.deleteMessages(messages, 'deleted messages'); + await source.deleteMessages( + AppLocalizationsEn(), + messages, + 'deleted messages', + ); source.cache.clear(); expect(source.size, 119); expect(sourceNotifyCounter, 1); @@ -1140,8 +1161,10 @@ void main() async { message = await source.getMessageAt(0); expect(message.mimeMessage.sequenceId, 20); expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); message = await source.getMessageAt(1); expect(message.mimeMessage.sequenceId, 99); @@ -1175,7 +1198,11 @@ class TestScaffoldMessengerService implements ScaffoldMessengerService { throw UnimplementedError(); @override - void showTextSnackBar(String text, {Function()? undo}) { + void showTextSnackBar( + AppLocalizations localization, + String text, { + Function()? undo, + }) { // TODO: implement showTextSnackBar } From 301ce3397384dd5e6a058fd804dc0a68fdb5fe49 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Sat, 21 Oct 2023 09:11:13 +0200 Subject: [PATCH 20/95] chore: use correct account email when sending notification, add error screen --- lib/models/message_source.dart | 18 ++++++++ lib/notification/service.dart | 6 ++- lib/screens/error_screen.dart | 42 +++++++++++++++++++ lib/screens/home_screen.dart | 1 + lib/screens/mail_screen.dart | 36 ++++++++++------ ...ssage_details_screen_for_notification.dart | 3 +- 6 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 lib/screens/error_screen.dart diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 949cad8..439fc04 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -1112,6 +1112,22 @@ class MultipleMessageSource extends MessageSource { multipleSource.clear(); } } + + // @override + // Future loadSingleMessage(MailNotificationPayload payload) async { + // final mimeSource = mimeSources.firstWhereOrNull( + // (source) => source.mailClient.account.email == payload.accountEmail, + // ); + // if (mimeSource == null) { + // throw Exception('Unable to find mime source for ${payload.accountEmail}'); + // } + // final payloadMime = MimeMessage() + // ..sequenceId = payload.sequenceId + // ..uid = payload.uid; + // final mime = await mimeSource.fetchMessageContents(payloadMime); + + // return createMessage(mime, mimeSource, 0); + // } } class _UnifiedMessage extends Message { @@ -1169,8 +1185,10 @@ class _MultipleMimeSource { if (mimeSource.supportsDeleteAll) { _currentIndex = 0; _currentMessage = null; + return mimeSource.deleteAllMessages(expunge: expunge); } + return Future.value([]); } diff --git a/lib/notification/service.dart b/lib/notification/service.dart index de09e5d..0170fa2 100644 --- a/lib/notification/service.dart +++ b/lib/notification/service.dart @@ -143,7 +143,11 @@ class NotificationService { ); Future sendLocalNotificationForMailMessage(maily.Message message) => - sendLocalNotificationForMail(message.mimeMessage, message.account.email); + sendLocalNotificationForMail( + message.mimeMessage, + message.source.getMimeSource(message)?.mailClient.account.email ?? + message.account.email, + ); Future sendLocalNotificationForMail( MimeMessage mimeMessage, diff --git a/lib/screens/error_screen.dart b/lib/screens/error_screen.dart new file mode 100644 index 0000000..0dc6dbe --- /dev/null +++ b/lib/screens/error_screen.dart @@ -0,0 +1,42 @@ +import 'package:flutter/widgets.dart'; + +import '../localization/extension.dart'; +import '../logger.dart'; +import 'base.dart'; + +/// Displays details about an error +class ErrorScreen extends StatelessWidget { + /// Creates an [ErrorScreen] + ErrorScreen({ + super.key, + required this.error, + this.stackTrace, + this.message, + }) { + logger.e( + '${message ?? 'ErrorScreen'}: $error', + error: error, + stackTrace: stackTrace ?? StackTrace.current, + ); + } + + /// The error + final Object error; + + /// The optional error message + final String? message; + + /// The optional stack trace + final StackTrace? stackTrace; + + @override + Widget build(BuildContext context) => BasePage( + title: context.text.errorTitle, + content: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(message ?? '$error'), + ), + ), + ); +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ce6bc22..9d65f6d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -24,6 +24,7 @@ class HomeScreen extends ConsumerWidget { return MailScreen( account: accounts.first, + showSplashWhileLoading: true, ); } } diff --git a/lib/screens/mail_screen.dart b/lib/screens/mail_screen.dart index 112c75a..672d01f 100644 --- a/lib/screens/mail_screen.dart +++ b/lib/screens/mail_screen.dart @@ -8,12 +8,18 @@ import '../account/provider.dart'; import '../localization/extension.dart'; import '../mail/provider.dart'; import 'base.dart'; -import 'message_source_screen.dart'; +import 'error_screen.dart'; +import 'screens.dart'; /// Displays the mail for a given account class MailScreen extends ConsumerWidget { /// Creates a [MailScreen] - const MailScreen({super.key, required this.account, this.mailbox}); + const MailScreen({ + super.key, + required this.account, + this.mailbox, + this.showSplashWhileLoading = false, + }); /// The account to display final Account account; @@ -21,6 +27,9 @@ class MailScreen extends ConsumerWidget { /// The optional mailbox final Mailbox? mailbox; + /// Should the splash screen shown while loading the message source? + final bool showSplashWhileLoading; + @override Widget build(BuildContext context, WidgetRef ref) { final text = context.text; @@ -40,17 +49,18 @@ class MailScreen extends ConsumerWidget { currentMailboxProvider.overrideWithValue(mailbox), ], child: sourceFuture.when( - loading: () => BasePage( - title: title, - subtitle: subtitle, - content: const Center( - child: PlatformProgressIndicator(), - ), - ), - error: (error, stack) => BasePage( - title: title, - subtitle: subtitle, - content: Center(child: Text('$error')), + loading: () => showSplashWhileLoading + ? const SplashScreen() + : BasePage( + title: title, + subtitle: subtitle, + content: const Center( + child: PlatformProgressIndicator(), + ), + ), + error: (error, stack) => ErrorScreen( + error: error, + stackTrace: stack, ), data: (source) => MessageSourceScreen(messageSource: source), ), diff --git a/lib/screens/message_details_screen_for_notification.dart b/lib/screens/message_details_screen_for_notification.dart index 69a3a89..1833202 100644 --- a/lib/screens/message_details_screen_for_notification.dart +++ b/lib/screens/message_details_screen_for_notification.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../mail/provider.dart'; import '../notification/model.dart'; +import 'error_screen.dart'; import 'message_details_screen.dart'; /// Displays the message details for a notification @@ -28,7 +29,7 @@ class MessageDetailsForNotificationScreen extends ConsumerWidget { return messageValue.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('$error')), + error: (error, stack) => ErrorScreen(error: error, stackTrace: stack), data: (data) => MessageDetailsScreen( message: data, blockExternalContent: blockExternalContent, From 950d168752799d06a7d119e4e5e1b6f43a9667bd Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Sat, 21 Oct 2023 15:50:01 +0200 Subject: [PATCH 21/95] chore: update splash screen --- .../res/drawable-hdpi/android12splash.png | Bin 0 -> 5932 bytes .../app/src/main/res/drawable-hdpi/splash.png | Bin 0 -> 5932 bytes .../res/drawable-mdpi/android12splash.png | Bin 0 -> 3738 bytes .../app/src/main/res/drawable-mdpi/splash.png | Bin 0 -> 3738 bytes .../drawable-night-hdpi/android12splash.png | Bin 0 -> 5932 bytes .../drawable-night-mdpi/android12splash.png | Bin 0 -> 3738 bytes .../drawable-night-xhdpi/android12splash.png | Bin 0 -> 7754 bytes .../drawable-night-xxhdpi/android12splash.png | Bin 0 -> 14116 bytes .../android12splash.png | Bin 0 -> 19490 bytes .../src/main/res/drawable-v21/background.png | Bin 0 -> 69 bytes .../res/drawable-v21/launch_background.xml | 9 ++ .../res/drawable-xhdpi/android12splash.png | Bin 0 -> 7754 bytes .../src/main/res/drawable-xhdpi/splash.png | Bin 0 -> 7754 bytes .../res/drawable-xxhdpi/android12splash.png | Bin 0 -> 14116 bytes .../src/main/res/drawable-xxhdpi/splash.png | Bin 0 -> 14116 bytes .../res/drawable-xxxhdpi/android12splash.png | Bin 0 -> 19490 bytes .../src/main/res/drawable-xxxhdpi/splash.png | Bin 0 -> 19490 bytes .../app/src/main/res/drawable/background.png | Bin 0 -> 69 bytes .../main/res/drawable/launch_background.xml | 15 +- .../src/main/res/values-night-v31/styles.xml | 21 +++ .../app/src/main/res/values-night/styles.xml | 22 +++ .../app/src/main/res/values-v31/styles.xml | 21 +++ android/app/src/main/res/values/styles.xml | 4 + flutter_native_splash.yaml | 91 ++++++++++++ .../LaunchBackground.imageset/Contents.json | 21 +++ .../LaunchBackground.imageset/background.png | Bin 0 -> 69 bytes .../LaunchImage.imageset/Contents.json | 10 +- .../LaunchImage.imageset/LaunchImage.png | Bin 68 -> 3738 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 68 -> 7754 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 68 -> 14116 bytes ios/Runner/Base.lproj/LaunchScreen.storyboard | 17 ++- ios/Runner/Info.plist | 138 +++++++++--------- lib/screens/splash_screen.dart | 1 + pubspec.yaml | 1 + store/logo_padded.png | Bin 0 -> 19771 bytes 35 files changed, 284 insertions(+), 87 deletions(-) create mode 100644 android/app/src/main/res/drawable-hdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-hdpi/splash.png create mode 100644 android/app/src/main/res/drawable-mdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-mdpi/splash.png create mode 100644 android/app/src/main/res/drawable-night-hdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-night-mdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-night-xhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-night-xxhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-v21/background.png create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable-xhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-xhdpi/splash.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/splash.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/android12splash.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/splash.png create mode 100644 android/app/src/main/res/drawable/background.png create mode 100644 android/app/src/main/res/values-night-v31/styles.xml create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values-v31/styles.xml create mode 100644 flutter_native_splash.yaml create mode 100644 ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png create mode 100644 store/logo_padded.png diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000000000000000000000000000000000000..b88353cac6d6f8f73cbc3f3981d32b1ce52dceb3 GIT binary patch literal 5932 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 MTU;}*H1&x84@gw$00000 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..b88353cac6d6f8f73cbc3f3981d32b1ce52dceb3 GIT binary patch literal 5932 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 MTU;}*H1&x84@gw$00000 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000000000000000000000000000000000000..84884238786147d1f9dfba6cdee77dac618bf8f8 GIT binary patch literal 3738 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..84884238786147d1f9dfba6cdee77dac618bf8f8 GIT binary patch literal 3738 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000000000000000000000000000000000000..b88353cac6d6f8f73cbc3f3981d32b1ce52dceb3 GIT binary patch literal 5932 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 MTU;}*H1&x84@gw$00000 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000000000000000000000000000000000000..84884238786147d1f9dfba6cdee77dac618bf8f8 GIT binary patch literal 3738 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000000000000000000000000000000000000..06d77bb15377eab7c0dfc12976a7b471d55290b0 GIT binary patch literal 7754 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 + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000000000000000000000000000000000000..06d77bb15377eab7c0dfc12976a7b471d55290b0 GIT binary patch literal 7754 zcmeHM_g7Qdw`UNCCP+~b2oOjBl|T>_5~)LxAOa3E(ghii-g^-+N)-qpNEL_-O{I5` zCPjLJ(rY3`q_+?-B!m~=dVj(D_4{4x?6vOM?VP*!y61fMz6lSF^|?5NIhdH3xC{`w zrc6w~4g4au-x-{tg0M}-B*s-o=b?d)4$#NX+sW1ADHBsjZbYg&qDKqx+0MGy)Cy1z zTt*k%jlcLOZ@=khNsH^AF=nC<}45{E%JHuN7#*z zh=g-P4!M8SV7vXT;VG@b&zmftzWU04;mYL1ctSIJxQc^r&Y}>{(|7BJqK=`HK%I}M z-O2twS$?Q)Ve|~H?br3>xjqne%Sa_bCEi)ZD4C?;o0YDY{WCX2yOfhp)9%hn(d)D;Nf+9o z8RT0WKZB8GJ+&Pva%F+cKl72Y4J%c5kS!)-BBFABEJxlK1gAxf<}SzwKQE9mn$WX8 z{CXsi_=zeM@t4@O=8Mek#@;&-xf)ECn{JffVKA1y`>)$~4|h9u33T=G-91(gHoukE z%m2P+Vgfuj(Ea1@kl%I|Ine-T`)>zT&d2W`R}icWG9cE7M#y{?lX6g!=zLeEmM?qs z1q`!)ieJqA$B*SD9!W$Gue`c|kCxP*EXMr(?yjGfXlyk2c_{eWCFL64-!-)|Ae~3G zj_MG@xpcF#Nn^W{a!2}Gd<(5AIH*X7eq-;T*vsT;7LJ8yN%(F zv9t&s=Q37yy(t@{_BCUjuv zU_~xD7EeGKx|1C(@8%zu@RuF+Vxf_Cm)a)S-3=>1@SHPZ=q{nUkMy&v-d2&r zPdl4EcKY`A=wH%KJSl4f2$%Yxce!#%4~IH|qocEhH<@Sj?yGfC&TXmq5wi*YLbeAf z36t_%4OY&6WGNLcLe1vfMPm3j6z=>f=|Xs<@QCz$hE`+g>MN1ot84afa)+MaDE*;JbUwI- za^;mY^ywTTT=4PjEfa5BUkdqzMoiwDtqTp&S9s$g%RGC}i3`8}aZ^-Q6Ryy?)!u)s zDX4$FaxJNr@Mr5mpEZ;+9j-~85NT5X=&Zvt*eHSa{AX%e=&{P#T8_!I+ftr#sWx@M zY}%@j7vFihC`cgpM3uMul2U@yd30;#*c?T&nq1ySo>i1-1VT^^W+|*Nb&9@(D+toO zDL33X#V@R)ep;x$^%5bFuO(pTM^?pCT`EAKnJ{hY5GWf57PO&fTn$z`S(v%gn!nJg z6z^ET?pPx=O)E8TYY+B1r#>UF?LZS9$x81Sa z$Ei^hvABFEAA+UDonMU{&4<8fw7AhV^KV0{?Vr=uaL zIJ~fG<)c~3%R!dfCm8gIC;8fQ*GzGNmioz;PZW`me5BcBCKn_a;zs6SY$~yT*8gqT zVPHqo|A~)gjKBqV4+H6lGS)6f#)f9C{1jvzOv?Iyd(gUMcKP}L`n|o4TSJko)VZJJ z6f;}iW{idab3|Htm$cbC%sviwagDqZ7->IZ#V5RyycV=h zizMwO)Arx=-h+iqIU257cQ-@_xf#NiToM1cU2%QAwlngC#=-xv?;mw(DdM+pDboN9l#!;KHga^Z&hs$hJVEA3F3lk z-U=9|sGp1M&GITg^VYv6B7{96Kie9hJKyUb*Nl)q!tHQZ(Ta?_lkJJeI#BAFPOE;;iROt#^HE8$J(-+XY9HOZI$Uup zKUO?)BcWQ3^V=}IIr#Phl_JxfBcjhB{F_)_0rL)g@bMHQ{EXp7F6yi+1;G{l>L+zv z6KHxtYH#3?=l-Pv{XfnhD|gX0ni^wv>0!OG67>Er#mCL4fDiqto z!Q1n`LEfABv(25D;R;4{h`n>0_B;}dGf1;ZW_wZD<~^>Qa^O3v)_7hn?i{f00;_fh zkJv7#Tr6DvzS{Y3c;lr!vRE%gtWqbOJI?Z@Qi;+kcuygK5nqb@9W&2?R14eOA84wrrLtZF>;2lnjNF2zqI%P#rJYF)o& z;^~MOr!kOI9{Z>0!W(fxOFBR6DzrWknlCCw@5@k#6sTM4dQ_*aZ(i{tjp$e=x+;Kw?uNi}E1QA-0dgA|b$oOX3UwMb&6 zQdrWny2%`ojA+_2@71gTL2bHP>8wWtF4?d<0volQBvwdBjW)blyKsp0CWIbWd$BUl z{(krFmOUQRvF~w_n>t`36ubeI=TgPOcw&RF^!(J!#b>spI_na1x^*Z)%D=mI_(yDo z=3@99bJ5Yn|yMSVpT2eaVjjdu6$mv%%zqegvGXnK5F3fqE8ar=RUfI^j8Q9+S4TP$??f<|g`CE!8p*+`n1G&Af($ ziZH1t<5!gw)of`TZdJC@=31i~&4YA=q_6`~sN^`jNA@u^tNF=c?RExqB_V2N%pUs} z(Dr z)pYF`Sc7)EhILQh$XywhvNKN0w7$EsRh+&y9aJ0fst2!y>ovCT-r^DgN8qfrj_M_NRc9 zm0qVuvevU~j_e8G-0)?KNM=h@3P-C#;;MXV^bHx*BoHl?k!8^Rn!kUOX8=>zaavRv-NXF-Zu|ordf7IB;io|Hzl#cPd$F@IZX_-}7ks6f@^Q2-gVnhE&*zk|L3 zH4e5w^aDj+0~!9e!nW~+xDRcwwGR2Xe=FeVP0a+MbrZl(rBS&Y$kifUj3S@Dak|!_ z5`!m+%H>9`KK-8RVSCe^^zfScx~XM->58vS)|tG_^1uxqgs}le-;eSjwMZ@x=6~gv zq2G`$Cdjewrt^IuTu#zJl~*4htK~PxU;DIb-W)Bu6XTj=7|SD*9G-U zlC%_`>SD~Y;vM>HU?1~Af9V6WO(h2d`1J8oNXMZB@c179_%sW~UmFb1%qLn;a>H|V zmbaR}sektS7*0cj>3qyN-wE*xR45M8N!V53y>M!ez0AzbH z57xWf8(TBOqhVtlj7d#Px{?H{{*{(ug)V0N;29t@5j1WB%np(qd=C@rzxeAVrg#um z+F*)hZ>l8Y=^i-2wXdeCF7%S;KXo_d!07td2tP^RA_NO2!#@`c_bqZ^!D##BIyNML z^uXvVvABv!FzQOWmg#j#OSxr4Dmyp^gqZ}QuHhznJRb zy^K)hL(Ih@8fAQo9eXfGqM% zaJ-cOdMbggzmp0P@GW}4P?y1c2BKakYngIL&RQ)agxJBw5}1mwNe)<;sRpybdo^(b z85mZ;3H3T#%k=Ij_L~iO^oDQIHVej-Z_wWm@SYi2RFBg4^A;G?k%T|(P5r=wxG(3u zya2)c0WOV(nMyGmY$U8s@aTWI2Yk|zai^g>Q6n&!w z*}&;C82(;fX?_Ho{Q-FjqaES$nklz{IjrMV|qkwLp zeEgW%p&3};A8inf>vi%2Q^1llKO){GKSLgC5bJl3G5^jE&egU2rR&DR7a3ct$KXw{ zgWHk>C9|DUJ%~D5y}ZULTHQCY;#WtuZNtTw$C6gG%t;Cv$|85`lNf>=m~kz1LKUNM zwCyqH=q2cX^jkg&0S@ zC^A|}q57>#0C}p1;K{19;$qd7ZK+Wdro5tF)!E{D6&AtF?)ODN~HLJrRAN25F35&mI0;nU2FZ`}UX z_X5CxB;4QD1m?#8a?Rei0b;_OEBYe1D(uFHBKF4GA?}^}Ssu>PJ=jj)UV|E4%Zq^N zANmBI1S)&Gg18(ClNG{z%0op&L@y^pvXY&Y#?#cb-%Vi3k6U3n^p$#3dGs#%NMe24RX|GH*YCqYYB`D>>k90|ClD`fTht7ue#>MrUtCaREV)M zoOEqR25fyK{dkOy?PU~&Nw$e`w1ej!JM$|Cpl z6o~@b*ln;wT>tL;`NSVG9KTAqw=$jQ3!3GTXWiDd>_xiT8^ucr)ZjCRESM$JMM?DM zG{X5OhjYK#*-K3$ z5(RBz9(b6t+ObJWq&MkdS^qDsKhC~ci)aKcTAQ6afMR0MZ;wz``q?n%~y!I6;4fJNv0mc0+y)^A%4cq`?^BUkWiJ(4c4VHMwi zCHj~%)~jZz{S+H~sgy_^;qMF|uMk{BS3HdPg3+7s6ZSxa72X4Ux#psas?Ub2d6|HBB9>4;tarV1WbgspY;SsZD*VZ;-D(ROM#Y> z3^c{#6Fc^WQ9p~(sqS(*cDRy1 zHJzV+wIiNQAQ`E?1T_K9>zw{voM*J4NSxk}1HXS0tW#SXX0#t7in0&nG#98?aa!MB zA}?nhta%2GQwhHFFYO}x<7}xNpWW9Y4`&KeCD}h`+Fgc6tffYr?-+`=oS$Fx^$=m6 zwH<@fzMe%Lpi*q^L>&yxM!lZUs;YO( zhnn?&XQTd+W{*i91@xf$=qV9f!Q#{>JpGLz^v-%Jnm|W3h@S?FcZBVYCH}6#>y|JI zF0eREaB!f4s2cm9HEpQ}K$NEeaNeAu)wTyE(7MdMZOJ(?Shu3&iwRr$+QZ+mDpP55 z{vu)vWNSOBxaR3U4EIv#9l5j3xG35ri3mR#YHA}jV7Zcy$o+UJ;A@4mLraHulUpZI zXG$rwzEh}Yq#|SM`~%0CUM&%OQzljI)HS=1J*m!iKXSLG2f*_ZKy?fCu)AfNR$h}2 z?lDpS_@gnLzLsFJO{n)SntZvu&F%;8>tt>9LeBdv@`DI<3_Ew!4wxswsEm0@_g`dGDzw%1Chl^gRgTmy>BI(x(dSebx0jhT~kz}gfOvtaSxp|hcexBeTz*wCWS6p^%va^d=T%N27 zogX@Zs73Y7*PS%T3Mb!otWk@MFzAZT%&daC&LPrh$xfJPT&v3Ev&EHQ@6&m4S`R9) zE2*ZQrM4|cb|U%`|pPCq50id@{wQcKH`-ACqu z3N`7plGa0OI?>Fg;mIKF=~Qu=Z0EY`Mts4Y+I1;b z9XY_v$x#=Mff+UHok?FD!W+3O!u@u3J?;NVOaz)S5c@DmHn_2SZ@_E5uJs&XJ2T<- zJub8I@>B|=<-d8~#83Rg?&qzS_5~)LxAOa3E(ghii-g^-+N)-qpNEL_-O{I5` zCPjLJ(rY3`q_+?-B!m~=dVj(D_4{4x?6vOM?VP*!y61fMz6lSF^|?5NIhdH3xC{`w zrc6w~4g4au-x-{tg0M}-B*s-o=b?d)4$#NX+sW1ADHBsjZbYg&qDKqx+0MGy)Cy1z zTt*k%jlcLOZ@=khNsH^AF=nC<}45{E%JHuN7#*z zh=g-P4!M8SV7vXT;VG@b&zmftzWU04;mYL1ctSIJxQc^r&Y}>{(|7BJqK=`HK%I}M z-O2twS$?Q)Ve|~H?br3>xjqne%Sa_bCEi)ZD4C?;o0YDY{WCX2yOfhp)9%hn(d)D;Nf+9o z8RT0WKZB8GJ+&Pva%F+cKl72Y4J%c5kS!)-BBFABEJxlK1gAxf<}SzwKQE9mn$WX8 z{CXsi_=zeM@t4@O=8Mek#@;&-xf)ECn{JffVKA1y`>)$~4|h9u33T=G-91(gHoukE z%m2P+Vgfuj(Ea1@kl%I|Ine-T`)>zT&d2W`R}icWG9cE7M#y{?lX6g!=zLeEmM?qs z1q`!)ieJqA$B*SD9!W$Gue`c|kCxP*EXMr(?yjGfXlyk2c_{eWCFL64-!-)|Ae~3G zj_MG@xpcF#Nn^W{a!2}Gd<(5AIH*X7eq-;T*vsT;7LJ8yN%(F zv9t&s=Q37yy(t@{_BCUjuv zU_~xD7EeGKx|1C(@8%zu@RuF+Vxf_Cm)a)S-3=>1@SHPZ=q{nUkMy&v-d2&r zPdl4EcKY`A=wH%KJSl4f2$%Yxce!#%4~IH|qocEhH<@Sj?yGfC&TXmq5wi*YLbeAf z36t_%4OY&6WGNLcLe1vfMPm3j6z=>f=|Xs<@QCz$hE`+g>MN1ot84afa)+MaDE*;JbUwI- za^;mY^ywTTT=4PjEfa5BUkdqzMoiwDtqTp&S9s$g%RGC}i3`8}aZ^-Q6Ryy?)!u)s zDX4$FaxJNr@Mr5mpEZ;+9j-~85NT5X=&Zvt*eHSa{AX%e=&{P#T8_!I+ftr#sWx@M zY}%@j7vFihC`cgpM3uMul2U@yd30;#*c?T&nq1ySo>i1-1VT^^W+|*Nb&9@(D+toO zDL33X#V@R)ep;x$^%5bFuO(pTM^?pCT`EAKnJ{hY5GWf57PO&fTn$z`S(v%gn!nJg z6z^ET?pPx=O)E8TYY+B1r#>UF?LZS9$x81Sa z$Ei^hvABFEAA+UDonMU{&4<8fw7AhV^KV0{?Vr=uaL zIJ~fG<)c~3%R!dfCm8gIC;8fQ*GzGNmioz;PZW`me5BcBCKn_a;zs6SY$~yT*8gqT zVPHqo|A~)gjKBqV4+H6lGS)6f#)f9C{1jvzOv?Iyd(gUMcKP}L`n|o4TSJko)VZJJ z6f;}iW{idab3|Htm$cbC%sviwagDqZ7->IZ#V5RyycV=h zizMwO)Arx=-h+iqIU257cQ-@_xf#NiToM1cU2%QAwlngC#=-xv?;mw(DdM+pDboN9l#!;KHga^Z&hs$hJVEA3F3lk z-U=9|sGp1M&GITg^VYv6B7{96Kie9hJKyUb*Nl)q!tHQZ(Ta?_lkJJeI#BAFPOE;;iROt#^HE8$J(-+XY9HOZI$Uup zKUO?)BcWQ3^V=}IIr#Phl_JxfBcjhB{F_)_0rL)g@bMHQ{EXp7F6yi+1;G{l>L+zv z6KHxtYH#3?=l-Pv{XfnhD|gX0ni^wv>0!OG67>Er#mCL4fDiqto z!Q1n`LEfABv(25D;R;4{h`n>0_B;}dGf1;ZW_wZD<~^>Qa^O3v)_7hn?i{f00;_fh zkJv7#Tr6DvzS{Y3c;lr!vRE%gtWqbOJI?Z@Qi;+kcuygK5nqb@9W&2?R14eOA84wrrLtZF>;2lnjNF2zqI%P#rJYF)o& z;^~MOr!kOI9{Z>0!W(fxOFBR6DzrWknlCCw@5@k#6sTM4dQ_*aZ(i{tjp$e=x+;Kw?uNi}E1QA-0dgA|b$oOX3UwMb&6 zQdrWny2%`ojA+_2@71gTL2bHP>8wWtF4?d<0volQBvwdBjW)blyKsp0CWIbWd$BUl z{(krFmOUQRvF~w_n>t`36ubeI=TgPOcw&RF^!(J!#b>spI_na1x^*Z)%D=mI_(yDo z=3@99bJ5Yn|yMSVpT2eaVjjdu6$mv%%zqegvGXnK5F3fqE8ar=RUfI^j8Q9+S4TP$??f<|g`CE!8p*+`n1G&Af($ ziZH1t<5!gw)of`TZdJC@=31i~&4YA=q_6`~sN^`jNA@u^tNF=c?RExqB_V2N%pUs} z(Dr z)pYF`Sc7)EhILQh$XywhvNKN0w7$EsRh+&y9aJ0fst2!y>ovCT-r^DgN8qfrj_M_NRc9 zm0qVuvevU~j_e8G-0)?KNM=h@3P-C#;;MXV^bHx*BoHl?k!8^Rn!kUOX8=>zaavRv-NXF-Zu|ordf7IB;io|Hzl#cPd$F@IZX_-}7ks6f@^Q2-gVnhE&*zk|L3 zH4e5w^aDj+0~!9e!nW~+xDRcwwGR2Xe=FeVP0a+MbrZl(rBS&Y$kifUj3S@Dak|!_ z5`!m+%H>9`KK-8RVSCe^^zfScx~XM->58vS)|tG_^1uxqgs}le-;eSjwMZ@x=6~gv zq2G`$Cdjewrt^IuTu#zJl~*4htK~PxU;DIb-W)Bu6XTj=7|SD*9G-U zlC%_`>SD~Y;vM>HU?1~Af9V6WO(h2d`1J8oNXMZB@c179_%sW~UmFb1%qLn;a>H|V zmbaR}sektS7*0cj>3qyN-wE*xR45M8N!V53y>M!ez0AzbH z57xWf8(TBOqhVtlj7d#Px{?H{{*{(ug)V0N;29t@5j1WB%np(qd=C@rzxeAVrg#um z+F*)hZ>l8Y=^i-2wXdeCF7%S;KXo_d!07td2tP^RA_NO2!#@`c_bqZ^!D##BIyNML z^uXvVvABv!FzQOWmg#j#OSxr4Dmyp^gqZ}QuHhznJRb zy^K)hL(Ih@8fAQo9eXfGqM% zaJ-cOdMbggzmp0P@GW}4P?y1c2BKakYngIL&RQ)agxJBw5}1mwNe)<;sRpybdo^(b z85mZ;3H3T#%k=Ij_L~iO^oDQIHVej-Z_wWm@SYi2RFBg4^A;G?k%T|(P5r=wxG(3u zya2)c0WOV(nMyGmY$U8s@aTWI2Yk|zai^g>Q6n&!w z*}&;C82(;fX?_Ho{Q-FjqaES$nklz{IjrMV|qkwLp zeEgW%p&3};A8inf>vi%2Q^1llKO){GKSLgC5bJl3G5^jE&egU2rR&DR7a3ct$KXw{ zgWHk>C9|DUJ%~D5y}ZULTHQCY;#WtuZNtTw$C6gG%t;Cv$|85`lNf>=m~kz1LKUNM zwCyqH=q2cX^jkg&0S@ zC^A|}q57>#0C}p1;K{19;$qd7ZK+Wdro5tF)!E{D6&AtF?)ODN~HLJrRAN25F35&mI0;nU2FZ`}UX z_X5CxB;4QD1m?#8a?Rei0b;_OEBYe1D(uFHBKF4GA?}^}Ssu>PJ=jj)UV|E4%Zq^N zANmBI1S)&Gg18(ClNG{z%0op&L@y^pvXY&Y#?#cb-%Vi3k6U3n^p$#3dGs#%NMe24RX|GH*YCqYYB`D>>k90|ClD`fTht7ue#>MrUtCaREV)M zoOEqR25fyK{dkOy?PU~&Nw$e`w1ej!JM$|Cpl z6o~@b*ln;wT>tL;`NSVG9KTAqw=$jQ3!3GTXWiDd>_xiT8^ucr)ZjCRESM$JMM?DM zG{X5OhjYK#*-K3$ z5(RBz9(b6t+ObJWq&MkdS^qDsKhC~ci)aKcTAQ6afMR0MZ;wz``q?n%~y!I6;4fJNv0mc0+y)^A%4cq`?^BUkWiJ(4c4VHMwi zCHj~%)~jZz{S+H~sgy_^;qMF|uMk{BS3HdPg3+7s6ZSxa72X4Ux#psas?Ub2d6|HBB9>4;tarV1WbgspY;SsZD*VZ;-D(ROM#Y> z3^c{#6Fc^WQ9p~(sqS(*cDRy1 zHJzV+wIiNQAQ`E?1T_K9>zw{voM*J4NSxk}1HXS0tW#SXX0#t7in0&nG#98?aa!MB zA}?nhta%2GQwhHFFYO}x<7}xNpWW9Y4`&KeCD}h`+Fgc6tffYr?-+`=oS$Fx^$=m6 zwH<@fzMe%Lpi*q^L>&yxM!lZUs;YO( zhnn?&XQTd+W{*i91@xf$=qV9f!Q#{>JpGLz^v-%Jnm|W3h@S?FcZBVYCH}6#>y|JI zF0eREaB!f4s2cm9HEpQ}K$NEeaNeAu)wTyE(7MdMZOJ(?Shu3&iwRr$+QZ+mDpP55 z{vu)vWNSOBxaR3U4EIv#9l5j3xG35ri3mR#YHA}jV7Zcy$o+UJ;A@4mLraHulUpZI zXG$rwzEh}Yq#|SM`~%0CUM&%OQzljI)HS=1J*m!iKXSLG2f*_ZKy?fCu)AfNR$h}2 z?lDpS_@gnLzLsFJO{n)SntZvu&F%;8>tt>9LeBdv@`DI<3_Ew!4wxswsEm0@_g`dGDzw%1Chl^gRgTmy>BI(x(dSebx0jhT~kz}gfOvtaSxp|hcexBeTz*wCWS6p^%va^d=T%N27 zogX@Zs73Y7*PS%T3Mb!otWk@MFzAZT%&daC&LPrh$xfJPT&v3Ev&EHQ@6&m4S`R9) zE2*ZQrM4|cb|U%`|pPCq50id@{wQcKH`-ACqu z3N`7plGa0OI?>Fg;mIKF=~Qu=Z0EY`Mts4Y+I1;b z9XY_v$x#=Mff+UHok?FD!W+3O!u@u3J?;NVOaz)S5c@DmHn_2SZ@_E5uJs&XJ2T<- zJub8I@>B|=<-d8~#83Rg?&qzS57P=f{hSrLX$2aC;Uuj*d*Oh8STV&of zmmMtVZ_{X$u6HUGDapnBgyOwB9xY^ zoh`o>&bh%aZ>y=c&{Po+Tj5J6F>{FMILvdTr@Je>^kqvPKihQAxfcg%if5JdZafhE z>M4!hSXx?BX#V=UZTI5kN3|G&flQ3zE!A+<7mlj8qF1!N-VycRZzf?cX9*nAd1Uoy zGur$614NtP?5|Z#uXm__9_M#AlF9If^9{mgcg_wcdZQ*Q8_lwN&2~~)A+^EfuSeBe zg-=qRpS*ieCOwA#%Q@jplT(el3G3quY@319<9PPoGMJ9%xbgVXw+lfm73dX}^!Id+3qhzP|j!Ft0-z zPjKJj+y)C`;XUYp;Yy*1l2Q0>n^w^0jI5Sb^GMf1|x}vvt9IvF;92b(0vpE%7Z&FAfq$7MI1FWU}->Bx_g_#PTX@ z943lL)HWS_64!Tv7# zHY+jV-$@?*%L%O-pR5y1;dwBr?4jH2?z!d5?kCy&rfz9s2Lw%RiBQ?g%%!?}@QrR4 zcq;ynz>fA#rtyNGjAr1BMF1^(D82sq%5l`pRaZFgny1Y-k5ZcfIHLUFy7HUzdc)}M z`ddX4{-5cQ=kS$f?JphDzg*|}rg2Eg=HSJV6+_~_X0N7g~-@3EaeDZR$qDGRdO zAA3~!v=w>u`Ok)zO~m3y>toqlLt!yc)b8Lf3E1B9=a@_jvc5~P!_MlbUvL8t>gJbh zQ)4%YKr06syk8YkP_8xa0@C6u*-IKxpsAsCW91%fZ(5>*V^DSFqzGwIHe$Kf|BkjM zT9b;qv~_q`LsNZeonE!^1x-eXZAkeqH)t8-w%Su+eoh=QAt|+@PFc$t{$OIa1Ic5} zbO_fmr=K^(iZ#dzW%o4S4Ya5{7ca)}56tw+`I+I!(k2AGOlG>Dw@n<59KWsKXQ@rV zul#7V^Z14P)lmK8Ym0WphHDdk>L+AzyPY}S6tB0!Rdt681oG>@vQ4Q}W>x&KPsfdt zO1yLa`Z6)oyi%NAU?_?xKolMZ0H%6b`5_C7T*sn=52;Ix?}_M*4;l|~OD8s1KlC6W zjU$a_CygktGiV{+E?rC0r|4}#O(v?XIJ^U9X<*V=!MMS!yNCG9Ff3L$ZGfyLY=SmN zeZP8!H;56sL1oO-rL5Q$h`>i1JJ5=qk@#m$r|y7qU$9+^fY-IdhgJ%P5fO9qX5Mme z&GEB%z`C+X_zOi{!Sj1NYE3I|iwYkq9;e;X*_s)y;y{9XkcUp9u3tZK%_C?k2Ch)* zwX%bZGo@Bke(G)ZwYas{DO$7TtF51nLwy`cmBk*wkD7RF?hIEfrq1J68!m5o7a75% z$in208N#GtmT?|yDiN);>)mprSp_FM*nkp;hBN>Yu0811nt{ zoDwbH9Cl?7QXSMqu-iK07gLoQ(3IEXvIZm5BmCnWtZ(jZ*UtKsE8D|5K(E?*ji_X` z4g_sI(+OzeKaeM^X#u%i$nP{%cgEyh?S;zYxsSx*PXwwr9mGc0vdlT zT6vBCDB-l`;nuX8Vx%Jx4To|HlQEfx3rHS)DxQr8#Msr|22<#G(?`bVxlVqL>aC>S z?Y^uWK#v|)WTsoN|Ma1A*>9aPbN0aJd*3mt#~zPY`;2YYS(w`OFl2Qz3M!*|&F`cJ z&NNmB4JOLQ2F)Cf9X>kOW0<%f*`Mw8G+@eOc(ZEs_F7g1`CDDrMh&mbEiX+OIAd)d z5zwWdno?$4?>n^24)9o9z0`r;19x9bfK@OO=Kj=p^hTIBCX;}2gGzfuvgI%AXyylw z-@t+Ms`KSL?ctayp#KV`Q{k7xuu4VXPFys;O0)Wr*YA&#gxo{Ekk9cQ+nv%JY?m>y zcH1MkC|s@J}aUn=b6 z4JHL>CH0mn5(aX(eex@*;-^cjs=oSgj%Hf`a9MwaQj;=2n02W^?BVFBeet@pU|X{eYNp z??w3TTj(KY?;4if0wIn!0bEF*`^Cv}COZ(7Oq+vgtM0md-lvG|C}U9~A8_ zWKoc4s9gPtIySbqofKjID*C2S(;)Z5{kB9=VRE4%v)3KLiOUdGIDj=K zpTRA@Mxgdidzrl_7iAPH-GND#Qd!I_^+4TKXszK5K`j%!{+;jOvlLLMFX~ZQwU*hH zSIX%!-iPc3ec=@2SIvaqoxY(xX*%|1DIp!v^`=j@b*FCy6XX7Yxu6}Nh@^Qm2n4yy zKy*wz4&HZycRTv5Y_>&RBrVq7?4eXt??G~5!=F{u+Vs<{ z+uZ`Qr2VoH8LZ~l%It?@CCtUMR77r=qu&$8lu=c+S5u=AOu1^V`=Ug`)QH5CJq|yl z$(Ok3!G=!R+csAxq<1{61-bZF_zzvrOr`NI$H;O$EqQ0DUrv5tu2t_Nx@2kG!aPpu)A5nkpwEN>iIUolnp{X~l+ ztKfujk{fPkdJ6KK4f6t>L=dB zbgn6zdFS0gi1gXEyn3599)r4NmVi!;JfjsG?NL$9y5Gl+c<=eW#j$RaHmV|}h&Eu|D#PWEp`4@T5@SfPCLz@JbZKdUd25{*dn})_lKZUnVvU$YQWHcW6cLE?-DC{U z@6W?8_Sn>i*yUVAZeP~qjP0wTKO8u`z|Mz8=eyA8MOo*Aeh+uPaW#<=SPgj!#*@~am~##tVP2z8Aw@4#bvLQJ^# zH*gW+rry5-%)x!|&_tu_>Cn!a+ovoN&@-!}tC`|l%*MOEyFDLlrUt_j?s!7cZS;(> znxEsNF!rYFsK_u?KI-OyoR<7fgXV9zxk$eHc#xGGM1dU zc^kL>i@)|W7j3b&=YEz7ApcG%dm=xE*MM}1^5kkt7-ePX*=Pqs%8l%km$LH;z1N*E8Dte=PZ+Yg#frB-sB-f6Hfo4v3g=WnE+--~4v#(@uB z)WI_;e?j#J0|@e-eQ!7!qV9e~XTwXCpap?y75PqV2QE!g-x;JNdCVzMM(e!LAJyQ@ zOWoA47!@l1=LeweM9Q0{b@}IZJ5!10dqKM4_~>URdT3AVa#so?FqxU5S6l9pS;r@i z_iDSI58m;G3hY3&i*xTRt)_Oc(qeIJ-A$VB&F4f13jro8qR6jnStEAPd%C+-ws)h= zDX20`r&+!RBR%AT180N@sHK4voMH%jk_WSr+MpH9hqx^<_USTqZ;e@!5q?0 z1J;h_QXi9+M6NR1@*lgo^EBC@zN6}lg{U$URP(0g_$O|o@9Wt@Z|Pg#kL4TQ01=MF z%1~4?f89%5yvrOCjI7oQ=GC-Ji>hPb3)mg-01@D z3oR>}H?7V|iWe+FtJ5S8gubj2gfL>|T zne7`2=@C9TWlw5MeBCz`^8|!lOPt~r+?taE|eFUbmGA2PXG zn;-2~Zdhqq+=zs?4qoqH&RWacya<)IQ-0{YH|I3UIZ`~w?kI2ltEw~6dkZi1zCy@k z5T|Yt&}Dj(J=9O9DDm z9t_nEn`&KcL7epF6Ls_Iv3(OQmQ;9Y>#++X_*IYfns4(;q;J(xN!Pacr2O}z#jK0F zWcB;|^~QN@iN&Ca_k^~7NT*spe`6pP&0Bbl#jRA~uKB}KY3O(V7n6d?=wrom@Aw~+ zCMyZFWI8Y0X7+|^XcYwNItVL#Vq~ptmhwjaw+^YT(e+2e9HnAUI}!}nBH*N>B{3n5 zZ(7o+W43gDrt3A!t{bzxqW~%Yn z@yl9|=9#8X$!~HFZFERW9(8ha*gFpfUloTU*YCd<4BPT1?}Ng)C%Zc+}II z+>-TP1>CZ&#%De*2a9=}JgB;*B1!q#Z0uO6Jki&EyY$cY`=$1&^}L-RvLay|{u~i7 zL9W>E^`v0(6I+Ao<*l~#jY+HCS+jJo(l|4IUf}2V-WRD|?{mhZE5ss9`fpJ>g}qYT zyVk$cq~HIJ4BBlpc4TD)_S_PCp1dI?2p5=DYm0A8yktL0!xw7Z{1H_+oo(gc+F!jC znfG2utjbv6_AQ9p6 zJ-Eljy0A#h;6RMvb9&`$gpxUIe?|3bcbAS>^$PdY#LU#65v+3MoCQKi!@B6=k)jct z+f&0+kVHfxKaZZsxQ-8aBp5GPz7&9SSgdV|2_sG<5PL<#$GN~ASb21`Z4m+)EJl#! zR7|fWrY<@L9dqHmly446RDeP9wo_V;<}KyQOYn@a8oP4uw$`gw*%_2hGxSD>st2V- zW8_n}%ceG^9h2U4Ss2u-zMc9*i=;(;D!eux*FW)CkNldu)^I9^TJF0N^Zja}LM#18 zT9Iy*^@ZZY6$=B!A@hjT{Q)p-$(Z>>h{1Y_V5B!ZI%{)WER&&o>OSuZ&HaL^mAb@f z)dQ;5GGfCe1@_u6AK&*_Xsz58`D)YU@Wy7izNs#M_e}lh;F&s{L>@CKN)`Bwgdc-_ ztLvj-jf|dWw|?omq0wCCcm2jIw^EukMvheYp0-Tx9#U z`3{cuYjo3)K~jkhtxQnXrYrJ~?byjQNK1`Z5bNAZMJXlL`?9IvD8_onPMVXm#-N}+GPLE|=_S=B#mlYwhD-#89GbPRRf z5XU&Xu;Po|u(U(mK}VU}xyPK#eW$WuWg>zLAel|L5L$5<~ zW0|ZtTV7m8S3C2}yCAdKcnu6q$0bDb4Gs6eub)LC;u3vthUis9@fG&ZtkgxHcG48t zoJ)Q7#4O;BiCzZ|Gm+%>tOvit+|~%}csEbF7K1sZrC*t^>#d35)Hjg}i{G@J&-OAZ zHV{R#)J173LXjq~!CVP4$zq`NF`3CN*umd^+#xmQ=7Hx-C1f<$OW9h(a>;S7XJF#a zP_5AQCl;vjH<#vlkuTW$vCV))tJg^6DRSS+%SK`%;7bg4g zjdKB09`34u9pEETYq+7N3_T~ju7m3~gx5rxSf9@_Z35 z5V;no2!Jeo?O_sHvuHfOErDxciU2kNlUbcC2@!|vz^pBt=T|Rr?Xd$qC2RSQ#qiy) zqq*CSF6ABu*;E62PuKF_vD3uJxD5%HRI2Bf2k2%&_@^~!z{th@NK}_N7IH-!`3M76mrWSM#z7Szxn)CHdj7>n;xZ3z$u0=Dy+U@@(P@?kYESsIgo|o z@)>)POSnDo%9r=Xxm;DnP{KJieilc0UJM|W|5|kA!CHQFTXv$r&F8xiSWzc`QVDkd z`!f6EQMV5Cm-GJ&#)N#mFY%+E=6*Pu*qh+A_val;t7tgMR^(?2_*V}oRj52Dleg!# zculCl)kfLMeZeBuQC&2ib*;Zf@7Wv0Ra17D>?s6lJBkU6$Vu=JR!*QsEy>Y*mcmUu zlXYX^4jufOEi-mN7pSbH{YW)aFU>SbQ}d=okl`;Oa$-3(T`APFgh1pdZXZ(3IQo6> z4O)GA(^OsaqgF$BJ4FSwwow-NB6@}wfwW7hfM6HjPQ-1oo>J3zBHh%s{x zQFuG$2cFQO;Q788HNV=S%Q;AHf8g5Tq6w7Wz`g3(TunoT2!VZEAa?-`x7}m71Vg_I zfR^J!z=Q~a5)B@seNhCa@c?2dz!A=v*r+0bqpDS{>PreL7c6&$^UrpqjJWz*bW+AyJQRz1% zAcU`9i$O#SSvZACj|3m5d|d=F4^G~6hYFM08~4C{K&>qOmb3%nZ-u7cfs@+j_rOkYYg6Mzy>TKQqajw;@dv-t~sCxuBqh2r4d_(6^VOgh(CPCXy z5b%G434wOK=)Doe*N&I0;QA5uJUySQ#3BTG0+r79ON$WHDbDk`A(5LL16tzr#b6Qt zZixyI4Ku?UQG-6&pkf@rzITH>(~nydx%&dOkM@@qDDp?T#xw%ET3_~4-s8Lo> zD02`6e6&Cp029co;kc2RL7uotefVIDIIs$<<=@R8nf5vd$UvW zpo*}NBqU95%!RRex`GREJVdUMkbUOYhnuli1G!c)A2@)qeRA^V!-Av#g0|+3VihQ6 z1L!|VV@$SusvEgxLgsEej(R%PEe-Mu^`n^wO5s4`aXMfJ z)cgX;3n)Q#NuVS@<1l-v;0{Ly;iV?>80zU)TTQ|mMi5XF3l>pxeV-`cnGywt2bU?_ zhzRf@_wR!t_>hn&uw;9I1}w|nbOTW$>1Zu~(woxGa<5se8b*_lcj7N+(coYs3ip)U z&l5l068D8Yfq7gd5s@j!ctKAe!R<$mRNgm+LDU{-5 zQMCB$0Dv+M7D0(Anj6nUWz2WX?F2C&K;7-`Lt+N+^CtYqu;^JV$f;|JNr#RhI2s8Jf^>Mj;`RFQ&%wGc@B;Q69(;vyfiUyn1mEZLBNTxmcKBH zNf`Ry_FJ&+0QQT4{lELMghYW=UOPayVm~rX{fuQbd-_o#O?3QZRefe`5avCvaAnta~Vm?_;ev@DjA! zll55LDG46n?n%_sk05JOpip1-z+Wa6F$wj?N&C*kDn#^(p63NVf(v&&0-kde0R__x zT5?jV&~`(R!S4M?88b6z-H+mMPbWH)a?VjRO$3~0P!}BE0yhIU0STeSl=DzZ49KDh zNZ$7#S$Em?!^7X|PzHQ$(vZX?tccn@UYWROQ`0Qlz86%Uh-8QV_bY0wO(w;&4qLMY zV&8b8L4$0&GWe&mte57<-Epod7k5>HIO#CRgaWvtxt%EIsy=K@*08Ty^hF&}jtB`zm~ZO7m8P7+lw7ZS1o;o(GJt#Kw7=ge$a#lC=wN`7 zlun`ec9of3qLAQ}jqQVzQSRbLNV;@zA&p;v=?VoY0Y7Dk!A4t{oQipB=xn=FrXp|q z=5U_LQz$r1ic*o^$9(kcy#S-f+`-yaO6|yX^^5= zmyhR1$j$&G=@jYRP>hW$g*HSzsgH#zy4tE`e1r^R~x$e(c1$=H{wQ@r+S(y`Ezn@&)Oe?v^3) zvr1qEr5I>MO8J2K-y7GV=}rX z)j&?~u0T_~iv4@rz0;4^GWHp_N)OWir#5BFk=~FxFkC&KyIf()d!7tbMV(hy{T`1~AX~$KhFh zPg~G@?ym8ZZOq@wC-VD-W~pPD3Vv|cEX7JfX(yk1^`n~5xtC!d!i?El9R|kT>Vay1 z>z0t|JN1?$T1RUyqO$EX%CekfBpxo!NgYZk|2&+c7t2emd6C zPZaMa2c%6MvB7-a{2I#|;9C$9J>vw{5a13Pi~TDb^y;lKyT~)hPX)hGfNt#B0z+hC zz{l-H-yn9)T_@{tefTVR+BIC`TMi6^6UWZ$-@*BcD%JA0TQK}m7Po&_1yGNG6>e~~ zb1W{aEfx3Y8CfM)+c@OE^XUT1$#;&dy+lCK+dqI55 zj5otu25^BEokPE~MAA&VTWoW1%<&42`UO+dSx{g8@t92cCn(V4wa)wCS4H1W2+I*3# zJ~Psn_3J}LFe(!a!RKEO?soqiuJ#)1ENL`-%WGlD<&#%{YC0XpUbn+<|JhLw{AZP~ z)=;CtU}!76`pe**t;GUjhUg<-D)+Rx7o0Tr?dcni*IFYoccL%}lWE03R|F2{(s6C4 zIFFoMriXi6*J^(VP=NAVcaEe(3+B4;yHpJw(I2^;r@`d;`+@VOtgMZoNmVvImg-(V z7M_V8oEhgD(I>?>nwQOj*Qxq{2E_D<5dNm`E!G>WwN@<<0lVF?-7bxv+JBco2&*Dq zA6-5DN78xtVg}3CsWDLDKs`#QQFK{PEN9!@*vhNbmz~v2n@DY2k<-2`mIH*t8TyKv zDoab_C3Asvp48tOVhytH;L4TI1Sr~Td}vnAB5P)LxT=7H(~qlgfa{Jih)encp&T9zVW_mJ@ z-+ZEnKfQG48}T_ybdCjzTV^8dCzg#UxaFQGZ}sl47KpLdv3dmG(hxK+}` zfCjsfZ+JPJ=&~CPT9;jf+p#}Aa9Pe2->_d(UM|&`+zDRCx_{w;xyNpUUiCJEj%K>A zb@*Eqau-_RUYUqfF`GKW?2c;I`=X7nW$vv zfYjw9?!KV^)@tlPSIy!B{!Bs3Pk>Nxn{t)%I&SB(*K8mpaI$weaQYr+EN^J}6)|Lc zTQ>g~byC@@w$ssHze8n#9n#$xRD_y>)P3>S^2{t&#$zEbtsL+Lm` z(T+HN#LTZu`Wv5b(8Pk4&WdWa&s5km)Zgo9Z$(OiHmO!6L29f45B4`=YSmfGm5wc2 z8}jQ3qy^VcE5T0x%GV0UVd%2m8(~3P#prVHWBW`Fffwwoq_*2Q?0NLgTyJ&4?gHHO z0e%+c&g_>ve;fewK#`=eI+s&Nr8n_GJGbz=-lWBiA0pSmTV2n@vwo%G=zJ_JMn$k?DoF`?JSj zDPN@gmwM}UmK>`+cbe+E=!WTrrr<5kM4HR&8yI%^k4MnRcQhw`7`yW7y>xxGVl_q0 ztnKnEN3+V!2xQu@vZ;ydu{@zkIMHxTZ{v3r$5fjw;ET)0;ZN-eAa`2rfe$*l>&xua z7_;X&zB3l8cw7tZ`_bHhf#Qvu(L&WlNK&j=(Q*5&?1v&0T^%Pk_VCN87BeVS4WIU8 zKpMZ&4jWvFj~x0h))1bBqopV&@ zO}=;|1@wK7=dNnIDtTif)2EnbS$)}lTenm$sNc30j|_+xTG;WeW~OLx?APen?bKW_ zF}!!J$75xHv-&EJ=%IsOiKVih(W|{$Vq>EqRObnM_oih-tx#9X$I);Bvx9lM8!lH| zw&IA8=9S9VAq-uqQN%;PP4579Uf7}*>sJ69H}z_1)kt=2YWG_EsXg$jkBY;R)!r>T zi@OZ@MTk!M+V=bKR>b>F@ZQJc(OVzJdq}4r7@*Cie;^Qu<*qgV-zpkMCQ4e}dB^tE zzMq94PY{fpEnCL)zr6pHF4t9h)a08i(Jj09P|80Dzlzj;4c|pQ4uw;k8Is9IU5D%} zYCQ3@Q6%pQr5i%P#B##<pBX?Q$EYFI;fvKkP z+qB3$wB=`y=C8JPTN<=xwAMXqc9^=~5_nOHp1paW9sY76tiaG~I=>CJ+583qn!ul3 z11fmGg)dfToSw>lTP=3CqiYX*_xRP>{=K`aVlqccSVMsUEgl|e(Uky?_B57P=f{hSrLX$2aC;Uuj*d*Oh8STV&of zmmMtVZ_{X$u6HUGDapnBgyOwB9xY^ zoh`o>&bh%aZ>y=c&{Po+Tj5J6F>{FMILvdTr@Je>^kqvPKihQAxfcg%if5JdZafhE z>M4!hSXx?BX#V=UZTI5kN3|G&flQ3zE!A+<7mlj8qF1!N-VycRZzf?cX9*nAd1Uoy zGur$614NtP?5|Z#uXm__9_M#AlF9If^9{mgcg_wcdZQ*Q8_lwN&2~~)A+^EfuSeBe zg-=qRpS*ieCOwA#%Q@jplT(el3G3quY@319<9PPoGMJ9%xbgVXw+lfm73dX}^!Id+3qhzP|j!Ft0-z zPjKJj+y)C`;XUYp;Yy*1l2Q0>n^w^0jI5Sb^GMf1|x}vvt9IvF;92b(0vpE%7Z&FAfq$7MI1FWU}->Bx_g_#PTX@ z943lL)HWS_64!Tv7# zHY+jV-$@?*%L%O-pR5y1;dwBr?4jH2?z!d5?kCy&rfz9s2Lw%RiBQ?g%%!?}@QrR4 zcq;ynz>fA#rtyNGjAr1BMF1^(D82sq%5l`pRaZFgny1Y-k5ZcfIHLUFy7HUzdc)}M z`ddX4{-5cQ=kS$f?JphDzg*|}rg2Eg=HSJV6+_~_X0N7g~-@3EaeDZR$qDGRdO zAA3~!v=w>u`Ok)zO~m3y>toqlLt!yc)b8Lf3E1B9=a@_jvc5~P!_MlbUvL8t>gJbh zQ)4%YKr06syk8YkP_8xa0@C6u*-IKxpsAsCW91%fZ(5>*V^DSFqzGwIHe$Kf|BkjM zT9b;qv~_q`LsNZeonE!^1x-eXZAkeqH)t8-w%Su+eoh=QAt|+@PFc$t{$OIa1Ic5} zbO_fmr=K^(iZ#dzW%o4S4Ya5{7ca)}56tw+`I+I!(k2AGOlG>Dw@n<59KWsKXQ@rV zul#7V^Z14P)lmK8Ym0WphHDdk>L+AzyPY}S6tB0!Rdt681oG>@vQ4Q}W>x&KPsfdt zO1yLa`Z6)oyi%NAU?_?xKolMZ0H%6b`5_C7T*sn=52;Ix?}_M*4;l|~OD8s1KlC6W zjU$a_CygktGiV{+E?rC0r|4}#O(v?XIJ^U9X<*V=!MMS!yNCG9Ff3L$ZGfyLY=SmN zeZP8!H;56sL1oO-rL5Q$h`>i1JJ5=qk@#m$r|y7qU$9+^fY-IdhgJ%P5fO9qX5Mme z&GEB%z`C+X_zOi{!Sj1NYE3I|iwYkq9;e;X*_s)y;y{9XkcUp9u3tZK%_C?k2Ch)* zwX%bZGo@Bke(G)ZwYas{DO$7TtF51nLwy`cmBk*wkD7RF?hIEfrq1J68!m5o7a75% z$in208N#GtmT?|yDiN);>)mprSp_FM*nkp;hBN>Yu0811nt{ zoDwbH9Cl?7QXSMqu-iK07gLoQ(3IEXvIZm5BmCnWtZ(jZ*UtKsE8D|5K(E?*ji_X` z4g_sI(+OzeKaeM^X#u%i$nP{%cgEyh?S;zYxsSx*PXwwr9mGc0vdlT zT6vBCDB-l`;nuX8Vx%Jx4To|HlQEfx3rHS)DxQr8#Msr|22<#G(?`bVxlVqL>aC>S z?Y^uWK#v|)WTsoN|Ma1A*>9aPbN0aJd*3mt#~zPY`;2YYS(w`OFl2Qz3M!*|&F`cJ z&NNmB4JOLQ2F)Cf9X>kOW0<%f*`Mw8G+@eOc(ZEs_F7g1`CDDrMh&mbEiX+OIAd)d z5zwWdno?$4?>n^24)9o9z0`r;19x9bfK@OO=Kj=p^hTIBCX;}2gGzfuvgI%AXyylw z-@t+Ms`KSL?ctayp#KV`Q{k7xuu4VXPFys;O0)Wr*YA&#gxo{Ekk9cQ+nv%JY?m>y zcH1MkC|s@J}aUn=b6 z4JHL>CH0mn5(aX(eex@*;-^cjs=oSgj%Hf`a9MwaQj;=2n02W^?BVFBeet@pU|X{eYNp z??w3TTj(KY?;4if0wIn!0bEF*`^Cv}COZ(7Oq+vgtM0md-lvG|C}U9~A8_ zWKoc4s9gPtIySbqofKjID*C2S(;)Z5{kB9=VRE4%v)3KLiOUdGIDj=K zpTRA@Mxgdidzrl_7iAPH-GND#Qd!I_^+4TKXszK5K`j%!{+;jOvlLLMFX~ZQwU*hH zSIX%!-iPc3ec=@2SIvaqoxY(xX*%|1DIp!v^`=j@b*FCy6XX7Yxu6}Nh@^Qm2n4yy zKy*wz4&HZycRTv5Y_>&RBrVq7?4eXt??G~5!=F{u+Vs<{ z+uZ`Qr2VoH8LZ~l%It?@CCtUMR77r=qu&$8lu=c+S5u=AOu1^V`=Ug`)QH5CJq|yl z$(Ok3!G=!R+csAxq<1{61-bZF_zzvrOr`NI$H;O$EqQ0DUrv5tu2t_Nx@2kG!aPpu)A5nkpwEN>iIUolnp{X~l+ ztKfujk{fPkdJ6KK4f6t>L=dB zbgn6zdFS0gi1gXEyn3599)r4NmVi!;JfjsG?NL$9y5Gl+c<=eW#j$RaHmV|}h&Eu|D#PWEp`4@T5@SfPCLz@JbZKdUd25{*dn})_lKZUnVvU$YQWHcW6cLE?-DC{U z@6W?8_Sn>i*yUVAZeP~qjP0wTKO8u`z|Mz8=eyA8MOo*Aeh+uPaW#<=SPgj!#*@~am~##tVP2z8Aw@4#bvLQJ^# zH*gW+rry5-%)x!|&_tu_>Cn!a+ovoN&@-!}tC`|l%*MOEyFDLlrUt_j?s!7cZS;(> znxEsNF!rYFsK_u?KI-OyoR<7fgXV9zxk$eHc#xGGM1dU zc^kL>i@)|W7j3b&=YEz7ApcG%dm=xE*MM}1^5kkt7-ePX*=Pqs%8l%km$LH;z1N*E8Dte=PZ+Yg#frB-sB-f6Hfo4v3g=WnE+--~4v#(@uB z)WI_;e?j#J0|@e-eQ!7!qV9e~XTwXCpap?y75PqV2QE!g-x;JNdCVzMM(e!LAJyQ@ zOWoA47!@l1=LeweM9Q0{b@}IZJ5!10dqKM4_~>URdT3AVa#so?FqxU5S6l9pS;r@i z_iDSI58m;G3hY3&i*xTRt)_Oc(qeIJ-A$VB&F4f13jro8qR6jnStEAPd%C+-ws)h= zDX20`r&+!RBR%AT180N@sHK4voMH%jk_WSr+MpH9hqx^<_USTqZ;e@!5q?0 z1J;h_QXi9+M6NR1@*lgo^EBC@zN6}lg{U$URP(0g_$O|o@9Wt@Z|Pg#kL4TQ01=MF z%1~4?f89%5yvrOCjI7oQ=GC-Ji>hPb3)mg-01@D z3oR>}H?7V|iWe+FtJ5S8gubj2gfL>|T zne7`2=@C9TWlw5MeBCz`^8|!lOPt~r+?taE|eFUbmGA2PXG zn;-2~Zdhqq+=zs?4qoqH&RWacya<)IQ-0{YH|I3UIZ`~w?kI2ltEw~6dkZi1zCy@k z5T|Yt&}Dj(J=9O9DDm z9t_nEn`&KcL7epF6Ls_Iv3(OQmQ;9Y>#++X_*IYfns4(;q;J(xN!Pacr2O}z#jK0F zWcB;|^~QN@iN&Ca_k^~7NT*spe`6pP&0Bbl#jRA~uKB}KY3O(V7n6d?=wrom@Aw~+ zCMyZFWI8Y0X7+|^XcYwNItVL#Vq~ptmhwjaw+^YT(e+2e9HnAUI}!}nBH*N>B{3n5 zZ(7o+W43gDrt3A!t{bzxqW~%Yn z@yl9|=9#8X$!~HFZFERW9(8ha*gFpfUloTU*YCd<4BPT1?}Ng)C%Zc+}II z+>-TP1>CZ&#%De*2a9=}JgB;*B1!q#Z0uO6Jki&EyY$cY`=$1&^}L-RvLay|{u~i7 zL9W>E^`v0(6I+Ao<*l~#jY+HCS+jJo(l|4IUf}2V-WRD|?{mhZE5ss9`fpJ>g}qYT zyVk$cq~HIJ4BBlpc4TD)_S_PCp1dI?2p5=DYm0A8yktL0!xw7Z{1H_+oo(gc+F!jC znfG2utjbv6_AQ9p6 zJ-Eljy0A#h;6RMvb9&`$gpxUIe?|3bcbAS>^$PdY#LU#65v+3MoCQKi!@B6=k)jct z+f&0+kVHfxKaZZsxQ-8aBp5GPz7&9SSgdV|2_sG<5PL<#$GN~ASb21`Z4m+)EJl#! zR7|fWrY<@L9dqHmly446RDeP9wo_V;<}KyQOYn@a8oP4uw$`gw*%_2hGxSD>st2V- zW8_n}%ceG^9h2U4Ss2u-zMc9*i=;(;D!eux*FW)CkNldu)^I9^TJF0N^Zja}LM#18 zT9Iy*^@ZZY6$=B!A@hjT{Q)p-$(Z>>h{1Y_V5B!ZI%{)WER&&o>OSuZ&HaL^mAb@f z)dQ;5GGfCe1@_u6AK&*_Xsz58`D)YU@Wy7izNs#M_e}lh;F&s{L>@CKN)`Bwgdc-_ ztLvj-jf|dWw|?omq0wCCcm2jIw^EukMvheYp0-Tx9#U z`3{cuYjo3)K~jkhtxQnXrYrJ~?byjQNK1`Z5bNAZMJXlL`?9IvD8_onPMVXm#-N}+GPLE|=_S=B#mlYwhD-#89GbPRRf z5XU&Xu;Po|u(U(mK}VU}xyPK#eW$WuWg>zLAel|L5L$5<~ zW0|ZtTV7m8S3C2}yCAdKcnu6q$0bDb4Gs6eub)LC;u3vthUis9@fG&ZtkgxHcG48t zoJ)Q7#4O;BiCzZ|Gm+%>tOvit+|~%}csEbF7K1sZrC*t^>#d35)Hjg}i{G@J&-OAZ zHV{R#)J173LXjq~!CVP4$zq`NF`3CN*umd^+#xmQ=7Hx-C1f<$OW9h(a>;S7XJF#a zP_5AQCl;vjH<#vlkuTW$vCV))tJg^6DRSS+%SK`%;7bg4g zjdKB09`34u9pEETYq+7N3_T~ju7m3~gx5rxSf9@_Z35 z5V;no2!Jeo?O_sHvuHfOErDxciU2kNlUbcC2@!|vz^pBt=T|Rr?Xd$qC2RSQ#qiy) zqq*CSF6ABu*;E62PuKF_vD3uJxD5%HRI2Bf2k2%&_@^~!z{th@NK}_N7IH-!`3M76mrWSM#z7Szxn)CHdj7>n;xZ3z$u0=Dy+U@@(P@?kYESsIgo|o z@)>)POSnDo%9r=Xxm;DnP{KJieilc0UJM|W|5|kA!CHQFTXv$r&F8xiSWzc`QVDkd z`!f6EQMV5Cm-GJ&#)N#mFY%+E=6*Pu*qh+A_val;t7tgMR^(?2_*V}oRj52Dleg!# zculCl)kfLMeZeBuQC&2ib*;Zf@7Wv0Ra17D>?s6lJBkU6$Vu=JR!*QsEy>Y*mcmUu zlXYX^4jufOEi-mN7pSbH{YW)aFU>SbQ}d=okl`;Oa$-3(T`APFgh1pdZXZ(3IQo6> z4O)GA(^OsaqgF$BJ4FSwwow-NB6@}wfwW7hfM6HjPQ-1oo>J3zBHh%s{x zQFuG$2cFQO;Q788HNV=S%Q;AHf8g5Tq6w7Wz`g3(TunoT2!VZEAa?-`x7}m71Vg_I zfR^J!z=Q~a5)B@seNhCa@c?2dz!A=v*r+0bqpDS{>PreL7c6&$^UrpqjJWz*bW+AyJQRz1% zAcU`9i$O#SSvZACj|3m5d|d=F4^G~6hYFM08~4C{K&>qOmb3%nZ-u7cfs@+j_rOkYYg6Mzy>TKQqajw;@dv-t~sCxuBqh2r4d_(6^VOgh(CPCXy z5b%G434wOK=)Doe*N&I0;QA5uJUySQ#3BTG0+r79ON$WHDbDk`A(5LL16tzr#b6Qt zZixyI4Ku?UQG-6&pkf@rzITH>(~nydx%&dOkM@@qDDp?T#xw%ET3_~4-s8Lo> zD02`6e6&Cp029co;kc2RL7uotefVIDIIs$<<=@R8nf5vd$UvW zpo*}NBqU95%!RRex`GREJVdUMkbUOYhnuli1G!c)A2@)qeRA^V!-Av#g0|+3VihQ6 z1L!|VV@$SusvEgxLgsEej(R%PEe-Mu^`n^wO5s4`aXMfJ z)cgX;3n)Q#NuVS@<1l-v;0{Ly;iV?>80zU)TTQ|mMi5XF3l>pxeV-`cnGywt2bU?_ zhzRf@_wR!t_>hn&uw;9I1}w|nbOTW$>1Zu~(woxGa<5se8b*_lcj7N+(coYs3ip)U z&l5l068D8Yfq7gd5s@j!ctKAe!R<$mRNgm+LDU{-5 zQMCB$0Dv+M7D0(Anj6nUWz2WX?F2C&K;7-`Lt+N+^CtYqu;^JV$f;|JNr#RhI2s8Jf^>Mj;`RFQ&%wGc@B;Q69(;vyfiUyn1mEZLBNTxmcKBH zNf`Ry_FJ&+0QQT4{lELMghYW=UOPayVm~rX{fuQbd-_o#O?3QZRefe`5avCvaAnta~Vm?_;ev@DjA! zll55LDG46n?n%_sk05JOpip1-z+Wa6F$wj?N&C*kDn#^(p63NVf(v&&0-kde0R__x zT5?jV&~`(R!S4M?88b6z-H+mMPbWH)a?VjRO$3~0P!}BE0yhIU0STeSl=DzZ49KDh zNZ$7#S$Em?!^7X|PzHQ$(vZX?tccn@UYWROQ`0Qlz86%Uh-8QV_bY0wO(w;&4qLMY zV&8b8L4$0&GWe&mte57<-Epod7k5>HIO#CRgaWvtxt%EIsy=K@*08Ty^hF&}jtB`zm~ZO7m8P7+lw7ZS1o;o(GJt#Kw7=ge$a#lC=wN`7 zlun`ec9of3qLAQ}jqQVzQSRbLNV;@zA&p;v=?VoY0Y7Dk!A4t{oQipB=xn=FrXp|q z=5U_LQz$r1ic*o^$9(kcy#S-f+`-yaO6|yX^^5= zmyhR1$j$&G=@jYRP>hW$g*HSzsgH#zy4tE`e1r^R~x$e(c1$=H{wQ@r+S(y`Ezn@&)Oe?v^3) zvr1qEr5I>MO8J2K-y7GV=}rX z)j&?~u0T_~iv4@rz0;4^GWHp_N)OWir#5BFk=~FxFkC&KyIf()d!7tbMV(hy{T`1~AX~$KhFh zPg~G@?ym8ZZOq@wC-VD-W~pPD3Vv|cEX7JfX(yk1^`n~5xtC!d!i?El9R|kT>Vay1 z>z0t|JN1?$T1RUyqO$EX%CekfBpxo!NgYZk|2&+c7t2emd6C zPZaMa2c%6MvB7-a{2I#|;9C$9J>vw{5a13Pi~TDb^y;lKyT~)hPX)hGfNt#B0z+hC zz{l-H-yn9)T_@{tefTVR+BIC`TMi6^6UWZ$-@*BcD%JA0TQK}m7Po&_1yGNG6>e~~ zb1W{aEfx3Y8CfM)+c@OE^XUT1$#;&dy+lCK+dqI55 zj5otu25^BEokPE~MAA&VTWoW1%<&42`UO+dSx{g8@t92cCn(V4wa)wCS4H1W2+I*3# zJ~Psn_3J}LFe(!a!RKEO?soqiuJ#)1ENL`-%WGlD<&#%{YC0XpUbn+<|JhLw{AZP~ z)=;CtU}!76`pe**t;GUjhUg<-D)+Rx7o0Tr?dcni*IFYoccL%}lWE03R|F2{(s6C4 zIFFoMriXi6*J^(VP=NAVcaEe(3+B4;yHpJw(I2^;r@`d;`+@VOtgMZoNmVvImg-(V z7M_V8oEhgD(I>?>nwQOj*Qxq{2E_D<5dNm`E!G>WwN@<<0lVF?-7bxv+JBco2&*Dq zA6-5DN78xtVg}3CsWDLDKs`#QQFK{PEN9!@*vhNbmz~v2n@DY2k<-2`mIH*t8TyKv zDoab_C3Asvp48tOVhytH;L4TI1Sr~Td}vnAB5P)LxT=7H(~qlgfa{Jih)encp&T9zVW_mJ@ z-+ZEnKfQG48}T_ybdCjzTV^8dCzg#UxaFQGZ}sl47KpLdv3dmG(hxK+}` zfCjsfZ+JPJ=&~CPT9;jf+p#}Aa9Pe2->_d(UM|&`+zDRCx_{w;xyNpUUiCJEj%K>A zb@*Eqau-_RUYUqfF`GKW?2c;I`=X7nW$vv zfYjw9?!KV^)@tlPSIy!B{!Bs3Pk>Nxn{t)%I&SB(*K8mpaI$weaQYr+EN^J}6)|Lc zTQ>g~byC@@w$ssHze8n#9n#$xRD_y>)P3>S^2{t&#$zEbtsL+Lm` z(T+HN#LTZu`Wv5b(8Pk4&WdWa&s5km)Zgo9Z$(OiHmO!6L29f45B4`=YSmfGm5wc2 z8}jQ3qy^VcE5T0x%GV0UVd%2m8(~3P#prVHWBW`Fffwwoq_*2Q?0NLgTyJ&4?gHHO z0e%+c&g_>ve;fewK#`=eI+s&Nr8n_GJGbz=-lWBiA0pSmTV2n@vwo%G=zJ_JMn$k?DoF`?JSj zDPN@gmwM}UmK>`+cbe+E=!WTrrr<5kM4HR&8yI%^k4MnRcQhw`7`yW7y>xxGVl_q0 ztnKnEN3+V!2xQu@vZ;ydu{@zkIMHxTZ{v3r$5fjw;ET)0;ZN-eAa`2rfe$*l>&xua z7_;X&zB3l8cw7tZ`_bHhf#Qvu(L&WlNK&j=(Q*5&?1v&0T^%Pk_VCN87BeVS4WIU8 zKpMZ&4jWvFj~x0h))1bBqopV&@ zO}=;|1@wK7=dNnIDtTif)2EnbS$)}lTenm$sNc30j|_+xTG;WeW~OLx?APen?bKW_ zF}!!J$75xHv-&EJ=%IsOiKVih(W|{$Vq>EqRObnM_oih-tx#9X$I);Bvx9lM8!lH| zw&IA8=9S9VAq-uqQN%;PP4579Uf7}*>sJ69H}z_1)kt=2YWG_EsXg$jkBY;R)!r>T zi@OZ@MTk!M+V=bKR>b>F@ZQJc(OVzJdq}4r7@*Cie;^Qu<*qgV-zpkMCQ4e}dB^tE zzMq94PY{fpEnCL)zr6pHF4t9h)a08i(Jj09P|80Dzlzj;4c|pQ4uw;k8Is9IU5D%} zYCQ3@Q6%pQr5i%P#B##<pBX?Q$EYFI;fvKkP z+qB3$wB=`y=C8JPTN<=xwAMXqc9^=~5_nOHp1paW9sY76tiaG~I=>CJ+583qn!ul3 z11fmGg)dfToSw>lTP=3CqiYX*_xRP>{=K`aVlqccSVMsUEgl|e(Uky?_BHgRZ)5qkc3{8judIqyVMX=kS2tHfKo*Sq&MkRdJ&M` zK{`ki2%V6-`1yX%xqrgF=e&R5u|Wh?4ZXGW95zkm1n zx=N?|aH6uVKTHGQERpwBg+qlytc6u0_M}`s#y!mZPazwg@m zI99^tqaLS6@lb1C9(Rji3%R}~*KDGj-g0fLe)fp|Y06p9k2lrPb7DQ@u%bYi)-9H_ zNYWeu@(gXZR+)r@1-^5$$y8{(d;uw~D7D60!v2G|_#fMj5%bs&F;wp_)$zye-zPot ze67=5Qjg}%^&mV-dCgt6Q26L|mHGfZBd;qF*69+>D5L7cB|gu9G!E6FCx*NISnBY` z(>utgOth7k3GLLJHjznE5dA;4$5%u|h}x7s)NSo<)o;z5OAc;rUnM1WUHCBnT_kv6b?9sD*GAVz>+uH*nYkVPm9 z0eJL8h7baY3J(BuEHnfp5Qq#PAq4~yU_=BE1*!Uv;Xf<=2aW&s!+&1m|8K663O;na z>dz$I-RE0AN?=;|<&iPE`Pe+t{3cakK;RE;QW~buy3F4QOt}{&w?3roZi^WI2%v}( z5Ya&kWL}*a`NLd$ey|Q#h>trEo^LxyK4tgZsPjLqU&=Ts(8&1-!;h46wnXSKpkwEY z?7Ax$0%SweZ?Rqds^F?87Ux$u95$b!#UT0VTrYS0q$@quXI`U}JT)Y-C&ss_c!Bxc z`NWO??~r-I=RktHENOU;!imfum97DG$Jc#t{xrJpKbW#!UiZ15xI)FT@Od&rZ=s@p zSAkXCqd31nOlSh@1Bl_)vQxEKelumKbjr)tQOTV+-ADCf_Cn{&m7OBEZ)xdL4k6=uvN-c&KJ&z8{f zdCoTEzY$cyY!s=~Pm_w@zuF)mY>02vl|t@rsZV>XzIK*ksA-o@kN%ZK1jEK-KYua~ za(LJKdXH_k7XRe5!D>Y~LJfV!Kit3D9k-H5nXkaGtj-_&?L*KD?&~dDTz}3_S7-*- zn0lYELK2s<+duX``Ptj8Pu!NjD04eliz2RUagqi`e*C65bi%Xt_x@?*bNnCf^Kz-l zv(l?l=SR9!p1JmSVQjEf%Q0=L_wQ} zd`a2CTB0OvoFr|oRQV@|kk6^|>c_7<^j@WxJVce(i;JdA8z<&XokvgJJl`OzKhKdC zMOqwqGUHd@GFRcxi>8i+yXUwnTp)Fe>$V*fL+wP2_RPHbZg_(j>#cXK7bf za3}`NLr0nXlIlI=TF$Vb<-meLIj2!BIlRYbp-Za@+wAN3(R)2*-T0ti(nR7g@g@#E zh{vt=r|(UtGEK3PNOG{Gc~)O-I|Iu+pK<$N$J=$hOU_YKB;o%T{5@ z{Y%bEqR&wSA9+0C9C8c>`#}o4$LDs1)h;Jxx>F*^L*0hTO*BZkPo;q^Fc$FrHv=}G+Dyq4eknUUm&FZN-!bm+~u_aN7TH!9;9@Fkj^OF;@; z-b>oPPGbi-b)$6~&A!YMr`@Q1!<%O|FV!to(deEON8W2(1SE!bmP0#_JdIA9a1~p} z(tmoK+o$XWKPaUQ>l3%XWQ8>6(MPYXtc6%q!=~&GXTNX8I(d&e;P=T;$i>uI{0jVL z5LqfKi9I_VrsIhL`QOE4NO#O-feqw&e-4BStT+4n^3d zpWR^kP5`mv^2jRhO*O9G3Zt*U9~jQ;mlgW17BcysuAI+za=Zx%4EWjtMAqS<8`)B=+%zwDC znkf?_#w4b_c#wzuew*T7l#Ww*Qgh?eUB&)zf_YHnzgOr@G4kJey|Q_-P;qvKl|FaE zV`ofmuB4*vV|B)0gcL&XSAQow77DVRyw*MNo~)6mhbc~h-*6J}9b!m4M~&pyXORgJ zOKp)|h<$1QQU0k{za_Vi7W;yGTPIndW>0|vUgM9~*X!}ex|_apSd=4QYelEX4fnkr2;5}`n?W0~3ukix(*~DC`m=AWFYY5cK6tXaK8m$`oEQwPU%v#24&ixn*Rd@_qu;_b ze(6X0WBDdF%BUiLeONs<*|)urc^sGPwx@hGuaXpkt^r2noK=6J6hZh zI^g^o5a>Y>RV1Y^;XmC_bI!J8-akpdq{Gmv>3N?K@>%+c;=MK(E>K{{kJm39Zx!No?!1O z+`TJIKyqx6jy`qzcvjqc+}5MOOkipntCaC8hrgw;VXi8D)*nT@P4YiVns~-%#;2Z+ z)g=@`sYa18m)^U;A+*oNpUhdMYbT6J?GEz$G~Y(~&8AA;r+F_c3r?cDp@o$$lZNp$ z^rx)pXo$dSSB4hVXnaD2@nK2bYy#0@&D(%La8}LZHJmw(kgi3+pOH$_%~v81i2(6{ zX2v1nzG4B@Zpr3dip3m(Oa+$CQ1yFBr4a%>AHL9-%An7m|3Jpb%#fcO01~P z4IfTXoc;f;?!(vJWtYcOb@=oO5(?j4NQ?ziRQ|9CQ;xvoytgCqG{ ze>Rpm=mEn=e~0v$q|h(+Cpq*i1fNBeGzZWTK6Wcx>l_WD$LA>G$o(RAAluuTbGYrx zD1T4;-FueYrFUKv1-u9~>=7wYeKzaf&NwGM?VZ&R*9#8_d^F{?T@bpey+qTRmeOnL z@q_{rmPPf%Wk~Nv;l1Z>dv0<^Q_v+EahE6T%fBWxwsikl!BFI{rUgLl^cIMmJDTJ;1^e-ZE7MNeDs z5Rs5TF30sexEFjhQs})mJSmNvfvX?-y!~uKg3y!W&M`I&Tflyfp3!h3V1#gHZn!XY zll9~l`pgy3zzDoO3x$bKVgmxpTJr7kmajvC;-B+G%F!m~v=t(xXrlh|fVECz_>%kc z`Q?DLD8^kwm*W#-Ct|E1u+QYo@rVEnLS^_W5B6{$?Q({YU#fHBuMPkMCy`fGo7Ag~i-Vf=`&QRCD0{uP-E z6us-vMnGtVB5_5)G1`U5`blg_=!$%7B89Zl1Xme55&o0+vUQ0g{oC+*4+K&kNsiD1 z_}3r=+WK^DhpgcWxf6-<4Yq@Cwl z4EjV%FiEQZmKuW?O)rBXak%yRINZGIc2}7eZE_Bbd03##j%PzmFCxA#xEM~RcAT+fSb?)sNtaZj z5X}uD^I-b=EIYQeupNzD))P4!kC|JQu&)?&_vb9s=bMC5a-?^!4B+Qr+v*9Fo z%HC^ISzO~{{2{^s-f!`Lf&M|s3|cpktY#iDQ86q-jkXct>m6=RXBMZf$98fwZ|r2m zGNbuum5gwiqNvOXJ^LWa;N=oD{QN+#@}PWrZX$k*WMPmsP18ve?Dx)i^orYxSMhSlAlr-r#q!Gk8B`lOiZ4!tT*T5Fm-DmF=;ino_!FZ11s?y4{6 zXg||pBmN(FTQnc)-{G=c=MRBD=sf*wz#U{B+#BROd-zf$HYqCeOYiM>TM3MPer0CE zcAAGtkGe9ak>_vW&2y(}?`TlnZcVYm_D-ooO;O5+8LmQ-wd7K#Kjh}-)ENxNBLIV-|t?`e8r1>2jm@QWIH61paLxcrtW$I4Lom8Xgv?0r(jv{EO&H-l;X z2hW{x4!Ra(JB>wM^Tk@Er5rTPb=!DXud}q$@pXNXww>z3s#r2{&C}73;#E{q=cnkv zaVMi2!u2Oz_Ql-q41MwnBQwyi-*foX@&4)qevIt~Wh};(Q@49F=#GjToxya>c(By@ zu=8vx(~W$qr2M?;L@G_xUk<9nyU93H*|8>NWI?+ZV|P-#blSmEw3q-?eFRPaRqZ zH%H=P%q+enAnPg`u7~PW>plH&R;N2bCU_hk{D=fm86slnQOc0B5DR08lY15^(QA%y zXCB>IQfKUD(wRvquYQC&UfYgI8A_3=zQv^Kk6%Gf*Z(qj^+K*VgT|iPI=)%XQ#u8S zdI~?)wV9>7V@akjd9b!H=Uq6SsHzRd48}_8(C1pPMo~X~6HXZwB}W&L9y44Nbp`l> zcf2tvKYLRRZk@NDe<}B8;D9TZV?xFPWn|h zAn%UP*7u$Z@eHixrav9d$3t&kQj(FqG5&C*-}RNr&!$h7xy3g^u}WAh?*AGOM9Hq)z?S72b%Q8diJtUFWZe>J>oDTC+Ya(_qYA zg?A{*$f~V3EhT?e5Log9?j=RLSC(DDLXs=qu@&h5sUCF3F;=S3hCzHb+?MW`^_C znL2Nc$+}WheLyyh>So5T(9I3?R~0$*c@>3GK8f)cRb@_gnMa(yQAMA)JUO}6HHhvD zcF-N0CCwi;=5(;n8A-guF7O%bL^(S2!ldoo&loa&Q;|Z>a_n{$v%7j`X*QC7ZGWWk zO86vMEN>5QaO=3wN*R;C%jT5tr$`wsdz*)mPV}9Qn&eSYsR4oeh)Dj0NqEy#c1G{R zTw|wOiEOysWbxsYg4)}C{zks$6PK$*MiJ$?H6j?Ne$P8!xB5_= zKgH%dKh?x6Y3{SorlX&$ar|RMZ^7|Y>?_CZHMcJcS+-_di(f9QlI9|B>X;XguV1gy zcoj6&${!EH8YU&o(GrDGSnxbDj}{(fu7CG5*^SaZvP z!7ComZPq+s8s(^zcJ)m%fFWFy39xNVOyJc*b zg)*j_S(MyknxgmVSM6`w|B$YB#S@JNG3doeUz@7+B>xvuw1r2 zP^;8;3A{_J)rI;p_1GG=BQH^Q`Z_#(NYA@~ zrW%PIbs__vd_l5?Kfk77b4vOSdnvBxUfz@F`h0fJi1O6amsoN>5MFoxN+EqpwzraF zwq`W_M(lF*^zpOErj+%}=DEI`=S`pNNJ5#HkBi{Oh4~D6-Tyh)i2ihpRDTolw4ms z7ZUrl;ly9ma9fQlFnc;Z?HSh{WMDUpW$r=7)h}49Gi;;#@{V_U)Dz6vBca4in>^v# z`LMs)(o#c-5yUQ)TjkScleZVritQf;60WN-HY{hod z$@VnmN+=`iTteaWrptXQ;}h*toL@c0dAj=`P`wVd&Ntx>XC5@9C@5>Vt#q<4#;C2v z-=r^cRS3LMBcd2E12*>s0UFHFDGvPZ^8@>hcB#MK35aQ42f+gBXw0{siY`3AC$)>N zO!t^KHr;toc#|w<5-ykS_%Lu@d3IrKw9n4CR$OA|?(MFlnLFi&ZH#kI zK-6=1-fy1FOq{rfh~UJbe6c&3qnp=tXGQXiX;tP8P!$8R!`MD$YN$Fn<^l2YZ9eVJk?Q@$}Y?U~dFOW+^*PI6SNN!JF^K3Mxc z><)8oKM(!;XB*{swCKxBl7;?R`2L!)XcJ9>k&Dj^4HLO_+IX5v%BxtV8KA_?Q>CR|U>4XGm$B zVB^SqMK*or`!}`ML-B@YvwN>Ba!-(JPAMQt^5x-4amYJ6I&N6E7IU$|>z%^Nac(&a zOE5amcl5^omLDf1sS~>Mhox`7(|L7s*H!Sn`e;*k{X%uoVH@RK8d@3?q0pA=-&;ES z;S7t*Hwte$r`aOzL$+$0waK}Jyir?CVw95#O}tJLA6sS%{{D;Kr2T|`GMT6lI_Jm49iX3d>ds>O`D+S4k?J;B zc!Dthp7{9AhG?b6iHBevjYx8*%VxfBbVw2j8hPL)vU~h=Zi8~q%%=QK3X-;D&!87bU2+GV+ z6qsP^Qs32<$M-D;*9WXy*_a1w+M7rH_J+-D>Y3D#9()YG8%82~EwXwk4uMOp3NM8c z#-HU(ykrwC&VIKhncf+?@$6NsL|tl$FLV;;@j7dDgJwU&>RY6CNq@7QQynx(y!=)q zqZqv!QwrI|f%sg<#5Pb%bbMCfUS}U~aOZTo*`ZrzQ?CVzKC!?O#7reFKB!_`K5faQ zzOts!mn5k@O^K#u5HfcT_}aB)PLg}?hJ^4$Z_SzuUFOqNRnsf1_4hr%B!6z(Jk2SI znK2NRU*Z(^Ry8lFE3r$8$P>W?`ITJOj4n)z=pF6H7Avj=eO%7Z->`EqpqLKe(N!Mr zQ4v#ckU1NL>C=$%M264_x( zfuU5C?+ryjI&vw|Yi~p;#asS8WI}9*+N0&Kkdr{X^TOIYVNBC|#C0Ie373aK7GX?j zPQ-}SyWlC!U8b}`(7q4aeXCz#wvL4y}uN!fx8)Oy>WG@Q=cx z3J25>(7p+a(cxihuiQLkkMUw4NB?REvWu}0F>v*N3j?8gMu*m{HdUiQF9^<)pCkta zN{v!nbPF?ivhRpsTGD-)((oh*S_qK!ML)D1rzqh_gBIWQrGW$%7 zpk)CRkF6)}T@xZ8xs*wd(WJm|fVo|Q89F>d_NIFUzGh=FgJfKt+UZ4n4IwhUXrM`>Ir zJ_K^ri6&OPF*(3zc$wxqP844v>eY8W|%FdEz3Wlx&b~*wi8q}CUMR}%=q#WoZ zvK|HQP{eJL#%0BY;_rV%(evbCz_gEBUFa~wJWzE8Y7E4lX@|I3PDb{1#?`<5T;zau zP0jE&T7Ut_N&=dfx-Ym`b1)gslas2;G~EJbw0{UVfDeeeXj6f44 zQIu$Mri>D>s3xzHlP4@vng8 z_L{m29^Z;;Bn3;f+OqK8izwxF01+Pxl;EAN*sdNQ+^oGmRG_|&9gvYnH4cU#0_I97 z(5Dhav*zDbbBS#0XPW8ihrB4yZOv zhCB&<0r}i{@=8tu5~FA0P&-9=wJ=1$n@RzuK@6O183GQMuZ;#M$;e4Gu8q5aRd*&K zf<;Xpha@RMcY#G|WdJh+A%$N2D|wwKuttahBbstjVuf5!nt&v4@=u5aKq~+PR&s~f zo(K#Q?I^!Rg@GW|B?#$3MO(ZIcqR>=!N4p%kXL>YYkC2|`I2%j*R|RNa18oFokIKU|Eau5+ z9}-fm`a&rK#G(K;QdN$0MV6qAw*#^5`=G6WC$-kkq5s)TMe|~pv{S! z@Q`A%QgBTTf}2$h6AFwB9`rv=wgEV$H3E-b z>dUKrB^SM|#vn!WCtOiJP9FA)xrJ2&C`0SDMxyVKHxSt&w=%(sfl{m%KV$+aS58_2 z5-M4rFL6&6ACr?{je(C0gMT(9f@#nKnyY{SR`LV#MbtdDsQ01VCOxLk~(ZMoQDN@YRbbOjU+3h#mU62mAd3MjG~n6r2*|B=+yH zcFGx)XjY&!1yGC>a3DZi2ej!xn;5jMK>Hq)qLKl$UxBt5Xj9FT18m9xyscxT64D=N z-4H)ie}@4uxkzkW<0U6q#I&08hPGcy_QEE?O+!c!rr$ylro7xcUIL-*E<405EU(iz zps%fWzk5A&xDAATrzMA6e;-zV+?e-Z`h`)xzE9)jTf9_uAxTBaRPMvMrsz6&)I zk>F)gHN5NvW@dUFN)e_iL70aQAgTl}nN}DR!HBvH;UWt(O%KS=0QIb*lDtqF$@xoH zFkVEJ?tq6;2folSTz z7x$q46Bt%_hq&lIP?$|gT?YIKoJf%i_|vkI1$r~tO&x~41A2-tdJIB4-a;uZ9Pvez zY-HVHB23cB_#x?s?>3ZN{Au^{vtZ$eabt&?dmMYs7v z+Cyy*Dj6X{aREeQMC!gR!6AXFv28CbErvVNfov)uJ0=7%MyD^4P)=IJ28Lz_uk!*J z6djHja|R2fAwg(=nSi9Iv*ffy9%e5B6x;_?)SyhDxcc;iO)kVxS%%OaLk1mSmHHi> z!HOR4Kftw?6^8-wvOqjpzWfD%<7FiXu@_pr0Qvq1FQQeD&-`@>N-+Wl{Z~Po6-u#7 zj)vgmWC*cQA%SIJozFoPv5$&;lOQGNhl73sauOPlYsUfih9w~1 z4eDwv|G=n)RR%>F1wmQ?PZ@%E2dW5GQG^tqZ-ye?sZw7+`}6Dt2OIPb_5Wsv6$gX2 zSCfB=>`J)7eOlNO$N13e2_ojk?Vgc))9*(G?kCDF4Uad(byAyZt?H8-G|2(T=K5`z`rA=Us&m^OwYPGY@?IGG4YKo}AYq7VWQKM;a2 zNOR~hHS(}T z;MA_&1OiQ=(LHhX?Y8N$MGK7<2l-$e5KL(}gKmn7XfFmQw1f`4d;E~#qFaE7?|sL3 z5jYcTD$2XAP3-8|v@ZbQ(^3O95lQF{MeV0h4VM6l2CbRg?z@m=$>oO~AhkY$uWX5Y{!UHCAOJPYuj$DoF%`*nyqr4%;mP z=Mx^LcJ6V`(*Gmji6yx{R}iD(%OomH5DODQCD=OwM&Qge7&0{*iXdCR#+`z%R{?3bR&+#JqO#$lPM^xo+q<|OL1qFx; zoE7C4e0vjNDyPQWP1oAwEwY5W&tu zP-zb>!2=c{fGATrupXVx^MCF2C@_)>DWDgW;F~soXMON|t&|e80!m!MJMw{}8&d(2 zt`A3aJip4(+{x-@Hn#!JFuc)HIp!b%5kaVHcOf2l+c9UG$`9Nb_OM-W1ufutFQUWP zNxL{>M?~m3nfQS^40Qkqd%!*zqq1rEI;d-Xpc<&PyIDo4eD}evDz14QVoGs^rk~oI z(ssZ|R=}s)r#ytl*#2zF)FQP`F(THS5R!Fm{#qNpe~^?Q#sKJ zq&7A9;B#2Yy|m$wX&UcOCFC=UXy?=0lxFuxIu63?rr9AEs4rq0`2A_>5#2r3CFego z#~P_lAsxyeX14Vhd4o4~Bdrf5={$%`bAjEb5vNF9uY zU^Uy`)=HH0X-#{{DQh3239(yNyzCUiOsv76bPTou>1y>*Ws?q+%MAK517Otwe5}^> z&I=4<4(c##(EHYC-gg<}J_jd7ffm#un&4=gYv!X%F*G=AkGwlaEYol+3WAL|Kq6ge zlkR z2!90wQy{tNKPlI%>N2UqSK_GY#$SksulxZiW6L!CurEKnBQ30R9IJoC+H$>D(FQm0 z7>&el7GzgR;1F<_B>KGVk#6f=OQ#TxZqpLv=^Jg9rHEGEi}+>aXeG_GZ>~}xZ{DG! zshYJM$#!4jfj(I*M(gA^1=%7*Kmd$=DV_es+6(dF{(b=|!GzYufelycm~OI&U$!mW z(%Wyc3lhDz7yp>}Y71mOPE=FOkMu|{PO$f_Lh@Tr`NyT#Fc8p!LcxI7gLNo@;mFS7 z4W=e{13P5#ryvW-V?AS~HzhMSx+;`H~N%({7UG~*>6ftWnpj7Y}QSb1b)zKs5 z9hr2U>9d_Tzc+|p(uK#us0VGbmuy9AvWgRk%*C4Y3fS-J^W~p?8AqgpLRAQuYLLt0 zTLq+D?y-Zu)LTvW{qij(|iR$DB5`D}K*b8+kLOsSfGBv20`eta9c(TUxANH=Jz zqN~9htK0g*nw5wZ@`R0rfHN;EQXumXrjxVDnHTwNq$nZa|F55Wm^rxYlJEPlLLi@+L6&*ae)sN5!gZt`QIkOA8i`?fk7b(Zz8pZsATm2uS1DX?ff^qjh7``ScE%p_ z%K>+9J=s~Po4NA8GMzBGo531t(vOh0A@5|dt;S9S|4Lj!(g&I~8UA!;4gZz`g;zOP zT9dH!UpYKM!M=6(|5XX3x!Nzzjih9ZSKjGCz#*KKR(c^7!XSfB2l9q>fPB`N zU*u$dve0kdQ?npgH$(eRQ|FcBv|~3^)^5fvUXI!DycSTOM3uGoJ|XS6BN(;b4^+Q) zp*oGr7IwUEh+RQ;DnxnfEl~hrnA|hI_~wm`d1T!T)kRSl-wVfr-Om2bqN!_X)E9!D zP`0nd)F`Fh;a5mq-EWg=pp0@RPetN8gX@n`vxkl28Lft0O9&Z4nPM2h9eA12Qg^Bs zwqwNy*kT(@H{ZSQbo&#kM}6~1Pn2((VuEb#gHN3%r14#7X*$SAg`#V5rCGA4vc(=Z zFRm!`4234f&TP**clEH8E`odm<#@O1n~Rz)hwg%W=wGpgFkzbyiY&^o&?1Z!1M zQ`6jxI6nkyaSdeoXyT1S-_Ph)ZHM!7ZsWTbU+y_*3PgZ%Pa|&8%=p~l2{E9;t{T5xM%5~B%yOO62FE6VzhzYyC#FK@sNsiRg8 z#G8v(x7mF)dm*fdW~tZ(7_v$s+xJqyP3N z(1|Y;WV~Zq=W9^sPSX2=p149hyZl@#k?wth&n9KjZ+OrjY#Vk8>kn~jUAat?1n~$^ z86GONXU%cG8EzuIx4F01Lldj?gA)e9#_LoYKX%AG`Ma_$x!XN=#>)Y~`5MTNZ=Jt- zzTd89?76-^^XP$bjiB17dD-V15NtRPg|a49PqN{e^z312su-Qh^Pp}h%iONTp&XO% zc7b&D8b?9{YPY3on=f9OX`KLqyX!b&m;nO7>HQ$o@!y)a*}2Z+L@=Wi`|mrw;@kH1 z$JOx-xN6^Br4rLlp}1UwkxM|@%GbQDwN)vDLnUkY0?n^DmgC(TCjMk!(6{Um(f>m zzsqcdTGti^Md^k7vC}t6AckLuZQGXmv;R<{k!MGfTPMTnN!K$An%;rbO!35_T>ai4 z`n+Y^WPjml`jWJsfqgHy{2g<5nF>q)M%NP`&+T+t=jk_J|10J4&_(q!1|*=p^m)$P zw`CxM%%+fzz_e^wEm(Oz+Hvqy*04xt4_mVI zAb%B0M>(pUuG1YZ+g%dPj^G~PjLRgYii1)GT~_k`)H~(dx0599CjPW#afNTt`_ykB z(Oa}4<~Zwk-#2wnf+kaeGQ4%Ax7r)$J(@3860?LihFj>kJ>Z7a7UlR<>UhVlj3f4U z!e^1q+MN!$0+vKDmE@VQg$$?J?_0G_`33`AyM1x``c~#RPAI`|d|?iU?WgNmU%V{+ z*DSMUoHY|fT!zy`1ukFkUE(M;_SwoT!($h;l?Nn`y_aM`DuzG6SVCtrht@y{yEG|e z?D^Pr{+@~`0m;)`>BbV2x9MQ}NS@xX;>{&y?ozAx21yL$FY1~0eyY6)H#LJGHY3$! z@VsN0TZ==L2qy3Zj8j*PM6Z0^E&0CoW^XjL%F~^t;hTMY&|444@0&V09^%W|n5^5w z6J?17r(V@xDjeh`n(nf&%>7zrPKhz8^;uwBaWE>Yc)EoD&{5-mCIoB&t4tR<*!D5cxpQ`IpMRUpO*KYS2Kbk0r{JH*E&xAK~zv5EBr+mX# zIWJ5FuA`s5Wv<!qtxE~DDKz}?{&N) zuD&H*^E20nZrRc1a=?8{ZFE~+PDyic8$I7`76ngvTo*6OC^=}>(>@792tDA)Cf^PkJ!TJ3u9h`&nA^lE^2mZ;?; zr`{_sd9z=kw+zp2>egE}4=;=+d;*CpIugW3e_S2BE=i-&$YkHxX56uc-De)H^~A%O>g}iN(p6@<31i!<<=P*?J*?F` zE6xKQ)(T^1 zOfr)+CjBC7FUbq5Rcif3-fHeUdD`H7N^@wmlfJ&~2USf{;bStpLLMcshjr@XI>?*c z+gMsZ@bljfR&OwCO=YZM@19t>w#_VBn>ZXJ)LplE6;i6Xtuvf6X}@0>b~*gYyIe>A zO-5;7i_HjTTxVZ<>Y#}SmYPwZ8K_s4@MD~;Wj}t_N4Hu6w|Pf5$$fZTw-)Pk!|7&< zw90q6-U`HvA>@?Q(?Dqw8s&gKB;X+4bpiv3{=q-$nARHKkXd4 z1;*aviiLN?aBLjR`;nJ}uh(bLsc|dhzS4Q6MHz#O?X(iGR@5N;+my{NS;9`6Tf!vn z@$km?-60>%V&9`|OyV|fr&)Kl2Df9lRk?g#w}2`{WxaO})K`#c+HluEY#BA|ttE3! z>!ExWY)~HaJMD#1^Y53^+v`iq9*SoO8r6Rp+!87z%qkgF+21jI-tOw^`YaR1LvVy+ zf8k>9^Zu&2_EqzlNu_=Ry@&l?_>QNW;;B9xYwNGhe*Z`;w?9gqj`43>bSAR83v6eI zf(XxzUR!cqAKmijMDilj&(G*pRrn0+otL`Rq?U(jJ7#K+RxcMaF>cj<5j0rt`61r3 zSum|8T8?_Kp8rf|0nhvKCwDVsPg(Hk3BSOB+@kH0yfNM%m)))R&SJrZaZ3F3vAmZofUQ z=TY}P*tWxG`fo7N-rK5onq-}1Mmo>-^5Tov>#;TbBGe1Bc3=9la|GZ3?7k`d?7o-I zM)0-UuRkQftJ6@p_YKD08>@nwmhc9vo#JkFHgRZ)5qkc3{8judIqyVMX=kS2tHfKo*Sq&MkRdJ&M` zK{`ki2%V6-`1yX%xqrgF=e&R5u|Wh?4ZXGW95zkm1n zx=N?|aH6uVKTHGQERpwBg+qlytc6u0_M}`s#y!mZPazwg@m zI99^tqaLS6@lb1C9(Rji3%R}~*KDGj-g0fLe)fp|Y06p9k2lrPb7DQ@u%bYi)-9H_ zNYWeu@(gXZR+)r@1-^5$$y8{(d;uw~D7D60!v2G|_#fMj5%bs&F;wp_)$zye-zPot ze67=5Qjg}%^&mV-dCgt6Q26L|mHGfZBd;qF*69+>D5L7cB|gu9G!E6FCx*NISnBY` z(>utgOth7k3GLLJHjznE5dA;4$5%u|h}x7s)NSo<)o;z5OAc;rUnM1WUHCBnT_kv6b?9sD*GAVz>+uH*nYkVPm9 z0eJL8h7baY3J(BuEHnfp5Qq#PAq4~yU_=BE1*!Uv;Xf<=2aW&s!+&1m|8K663O;na z>dz$I-RE0AN?=;|<&iPE`Pe+t{3cakK;RE;QW~buy3F4QOt}{&w?3roZi^WI2%v}( z5Ya&kWL}*a`NLd$ey|Q#h>trEo^LxyK4tgZsPjLqU&=Ts(8&1-!;h46wnXSKpkwEY z?7Ax$0%SweZ?Rqds^F?87Ux$u95$b!#UT0VTrYS0q$@quXI`U}JT)Y-C&ss_c!Bxc z`NWO??~r-I=RktHENOU;!imfum97DG$Jc#t{xrJpKbW#!UiZ15xI)FT@Od&rZ=s@p zSAkXCqd31nOlSh@1Bl_)vQxEKelumKbjr)tQOTV+-ADCf_Cn{&m7OBEZ)xdL4k6=uvN-c&KJ&z8{f zdCoTEzY$cyY!s=~Pm_w@zuF)mY>02vl|t@rsZV>XzIK*ksA-o@kN%ZK1jEK-KYua~ za(LJKdXH_k7XRe5!D>Y~LJfV!Kit3D9k-H5nXkaGtj-_&?L*KD?&~dDTz}3_S7-*- zn0lYELK2s<+duX``Ptj8Pu!NjD04eliz2RUagqi`e*C65bi%Xt_x@?*bNnCf^Kz-l zv(l?l=SR9!p1JmSVQjEf%Q0=L_wQ} zd`a2CTB0OvoFr|oRQV@|kk6^|>c_7<^j@WxJVce(i;JdA8z<&XokvgJJl`OzKhKdC zMOqwqGUHd@GFRcxi>8i+yXUwnTp)Fe>$V*fL+wP2_RPHbZg_(j>#cXK7bf za3}`NLr0nXlIlI=TF$Vb<-meLIj2!BIlRYbp-Za@+wAN3(R)2*-T0ti(nR7g@g@#E zh{vt=r|(UtGEK3PNOG{Gc~)O-I|Iu+pK<$N$J=$hOU_YKB;o%T{5@ z{Y%bEqR&wSA9+0C9C8c>`#}o4$LDs1)h;Jxx>F*^L*0hTO*BZkPo;q^Fc$FrHv=}G+Dyq4eknUUm&FZN-!bm+~u_aN7TH!9;9@Fkj^OF;@; z-b>oPPGbi-b)$6~&A!YMr`@Q1!<%O|FV!to(deEON8W2(1SE!bmP0#_JdIA9a1~p} z(tmoK+o$XWKPaUQ>l3%XWQ8>6(MPYXtc6%q!=~&GXTNX8I(d&e;P=T;$i>uI{0jVL z5LqfKi9I_VrsIhL`QOE4NO#O-feqw&e-4BStT+4n^3d zpWR^kP5`mv^2jRhO*O9G3Zt*U9~jQ;mlgW17BcysuAI+za=Zx%4EWjtMAqS<8`)B=+%zwDC znkf?_#w4b_c#wzuew*T7l#Ww*Qgh?eUB&)zf_YHnzgOr@G4kJey|Q_-P;qvKl|FaE zV`ofmuB4*vV|B)0gcL&XSAQow77DVRyw*MNo~)6mhbc~h-*6J}9b!m4M~&pyXORgJ zOKp)|h<$1QQU0k{za_Vi7W;yGTPIndW>0|vUgM9~*X!}ex|_apSd=4QYelEX4fnkr2;5}`n?W0~3ukix(*~DC`m=AWFYY5cK6tXaK8m$`oEQwPU%v#24&ixn*Rd@_qu;_b ze(6X0WBDdF%BUiLeONs<*|)urc^sGPwx@hGuaXpkt^r2noK=6J6hZh zI^g^o5a>Y>RV1Y^;XmC_bI!J8-akpdq{Gmv>3N?K@>%+c;=MK(E>K{{kJm39Zx!No?!1O z+`TJIKyqx6jy`qzcvjqc+}5MOOkipntCaC8hrgw;VXi8D)*nT@P4YiVns~-%#;2Z+ z)g=@`sYa18m)^U;A+*oNpUhdMYbT6J?GEz$G~Y(~&8AA;r+F_c3r?cDp@o$$lZNp$ z^rx)pXo$dSSB4hVXnaD2@nK2bYy#0@&D(%La8}LZHJmw(kgi3+pOH$_%~v81i2(6{ zX2v1nzG4B@Zpr3dip3m(Oa+$CQ1yFBr4a%>AHL9-%An7m|3Jpb%#fcO01~P z4IfTXoc;f;?!(vJWtYcOb@=oO5(?j4NQ?ziRQ|9CQ;xvoytgCqG{ ze>Rpm=mEn=e~0v$q|h(+Cpq*i1fNBeGzZWTK6Wcx>l_WD$LA>G$o(RAAluuTbGYrx zD1T4;-FueYrFUKv1-u9~>=7wYeKzaf&NwGM?VZ&R*9#8_d^F{?T@bpey+qTRmeOnL z@q_{rmPPf%Wk~Nv;l1Z>dv0<^Q_v+EahE6T%fBWxwsikl!BFI{rUgLl^cIMmJDTJ;1^e-ZE7MNeDs z5Rs5TF30sexEFjhQs})mJSmNvfvX?-y!~uKg3y!W&M`I&Tflyfp3!h3V1#gHZn!XY zll9~l`pgy3zzDoO3x$bKVgmxpTJr7kmajvC;-B+G%F!m~v=t(xXrlh|fVECz_>%kc z`Q?DLD8^kwm*W#-Ct|E1u+QYo@rVEnLS^_W5B6{$?Q({YU#fHBuMPkMCy`fGo7Ag~i-Vf=`&QRCD0{uP-E z6us-vMnGtVB5_5)G1`U5`blg_=!$%7B89Zl1Xme55&o0+vUQ0g{oC+*4+K&kNsiD1 z_}3r=+WK^DhpgcWxf6-<4Yq@Cwl z4EjV%FiEQZmKuW?O)rBXak%yRINZGIc2}7eZE_Bbd03##j%PzmFCxA#xEM~RcAT+fSb?)sNtaZj z5X}uD^I-b=EIYQeupNzD))P4!kC|JQu&)?&_vb9s=bMC5a-?^!4B+Qr+v*9Fo z%HC^ISzO~{{2{^s-f!`Lf&M|s3|cpktY#iDQ86q-jkXct>m6=RXBMZf$98fwZ|r2m zGNbuum5gwiqNvOXJ^LWa;N=oD{QN+#@}PWrZX$k*WMPmsP18ve?Dx)i^orYxSMhSlAlr-r#q!Gk8B`lOiZ4!tT*T5Fm-DmF=;ino_!FZ11s?y4{6 zXg||pBmN(FTQnc)-{G=c=MRBD=sf*wz#U{B+#BROd-zf$HYqCeOYiM>TM3MPer0CE zcAAGtkGe9ak>_vW&2y(}?`TlnZcVYm_D-ooO;O5+8LmQ-wd7K#Kjh}-)ENxNBLIV-|t?`e8r1>2jm@QWIH61paLxcrtW$I4Lom8Xgv?0r(jv{EO&H-l;X z2hW{x4!Ra(JB>wM^Tk@Er5rTPb=!DXud}q$@pXNXww>z3s#r2{&C}73;#E{q=cnkv zaVMi2!u2Oz_Ql-q41MwnBQwyi-*foX@&4)qevIt~Wh};(Q@49F=#GjToxya>c(By@ zu=8vx(~W$qr2M?;L@G_xUk<9nyU93H*|8>NWI?+ZV|P-#blSmEw3q-?eFRPaRqZ zH%H=P%q+enAnPg`u7~PW>plH&R;N2bCU_hk{D=fm86slnQOc0B5DR08lY15^(QA%y zXCB>IQfKUD(wRvquYQC&UfYgI8A_3=zQv^Kk6%Gf*Z(qj^+K*VgT|iPI=)%XQ#u8S zdI~?)wV9>7V@akjd9b!H=Uq6SsHzRd48}_8(C1pPMo~X~6HXZwB}W&L9y44Nbp`l> zcf2tvKYLRRZk@NDe<}B8;D9TZV?xFPWn|h zAn%UP*7u$Z@eHixrav9d$3t&kQj(FqG5&C*-}RNr&!$h7xy3g^u}WAh?*AGOM9Hq)z?S72b%Q8diJtUFWZe>J>oDTC+Ya(_qYA zg?A{*$f~V3EhT?e5Log9?j=RLSC(DDLXs=qu@&h5sUCF3F;=S3hCzHb+?MW`^_C znL2Nc$+}WheLyyh>So5T(9I3?R~0$*c@>3GK8f)cRb@_gnMa(yQAMA)JUO}6HHhvD zcF-N0CCwi;=5(;n8A-guF7O%bL^(S2!ldoo&loa&Q;|Z>a_n{$v%7j`X*QC7ZGWWk zO86vMEN>5QaO=3wN*R;C%jT5tr$`wsdz*)mPV}9Qn&eSYsR4oeh)Dj0NqEy#c1G{R zTw|wOiEOysWbxsYg4)}C{zks$6PK$*MiJ$?H6j?Ne$P8!xB5_= zKgH%dKh?x6Y3{SorlX&$ar|RMZ^7|Y>?_CZHMcJcS+-_di(f9QlI9|B>X;XguV1gy zcoj6&${!EH8YU&o(GrDGSnxbDj}{(fu7CG5*^SaZvP z!7ComZPq+s8s(^zcJ)m%fFWFy39xNVOyJc*b zg)*j_S(MyknxgmVSM6`w|B$YB#S@JNG3doeUz@7+B>xvuw1r2 zP^;8;3A{_J)rI;p_1GG=BQH^Q`Z_#(NYA@~ zrW%PIbs__vd_l5?Kfk77b4vOSdnvBxUfz@F`h0fJi1O6amsoN>5MFoxN+EqpwzraF zwq`W_M(lF*^zpOErj+%}=DEI`=S`pNNJ5#HkBi{Oh4~D6-Tyh)i2ihpRDTolw4ms z7ZUrl;ly9ma9fQlFnc;Z?HSh{WMDUpW$r=7)h}49Gi;;#@{V_U)Dz6vBca4in>^v# z`LMs)(o#c-5yUQ)TjkScleZVritQf;60WN-HY{hod z$@VnmN+=`iTteaWrptXQ;}h*toL@c0dAj=`P`wVd&Ntx>XC5@9C@5>Vt#q<4#;C2v z-=r^cRS3LMBcd2E12*>s0UFHFDGvPZ^8@>hcB#MK35aQ42f+gBXw0{siY`3AC$)>N zO!t^KHr;toc#|w<5-ykS_%Lu@d3IrKw9n4CR$OA|?(MFlnLFi&ZH#kI zK-6=1-fy1FOq{rfh~UJbe6c&3qnp=tXGQXiX;tP8P!$8R!`MD$YN$Fn<^l2YZ9eVJk?Q@$}Y?U~dFOW+^*PI6SNN!JF^K3Mxc z><)8oKM(!;XB*{swCKxBl7;?R`2L!)XcJ9>k&Dj^4HLO_+IX5v%BxtV8KA_?Q>CR|U>4XGm$B zVB^SqMK*or`!}`ML-B@YvwN>Ba!-(JPAMQt^5x-4amYJ6I&N6E7IU$|>z%^Nac(&a zOE5amcl5^omLDf1sS~>Mhox`7(|L7s*H!Sn`e;*k{X%uoVH@RK8d@3?q0pA=-&;ES z;S7t*Hwte$r`aOzL$+$0waK}Jyir?CVw95#O}tJLA6sS%{{D;Kr2T|`GMT6lI_Jm49iX3d>ds>O`D+S4k?J;B zc!Dthp7{9AhG?b6iHBevjYx8*%VxfBbVw2j8hPL)vU~h=Zi8~q%%=QK3X-;D&!87bU2+GV+ z6qsP^Qs32<$M-D;*9WXy*_a1w+M7rH_J+-D>Y3D#9()YG8%82~EwXwk4uMOp3NM8c z#-HU(ykrwC&VIKhncf+?@$6NsL|tl$FLV;;@j7dDgJwU&>RY6CNq@7QQynx(y!=)q zqZqv!QwrI|f%sg<#5Pb%bbMCfUS}U~aOZTo*`ZrzQ?CVzKC!?O#7reFKB!_`K5faQ zzOts!mn5k@O^K#u5HfcT_}aB)PLg}?hJ^4$Z_SzuUFOqNRnsf1_4hr%B!6z(Jk2SI znK2NRU*Z(^Ry8lFE3r$8$P>W?`ITJOj4n)z=pF6H7Avj=eO%7Z->`EqpqLKe(N!Mr zQ4v#ckU1NL>C=$%M264_x( zfuU5C?+ryjI&vw|Yi~p;#asS8WI}9*+N0&Kkdr{X^TOIYVNBC|#C0Ie373aK7GX?j zPQ-}SyWlC!U8b}`(7q4aeXCz#wvL4y}uN!fx8)Oy>WG@Q=cx z3J25>(7p+a(cxihuiQLkkMUw4NB?REvWu}0F>v*N3j?8gMu*m{HdUiQF9^<)pCkta zN{v!nbPF?ivhRpsTGD-)((oh*S_qK!ML)D1rzqh_gBIWQrGW$%7 zpk)CRkF6)}T@xZ8xs*wd(WJm|fVo|Q89F>d_NIFUzGh=FgJfKt+UZ4n4IwhUXrM`>Ir zJ_K^ri6&OPF*(3zc$wxqP844v>eY8W|%FdEz3Wlx&b~*wi8q}CUMR}%=q#WoZ zvK|HQP{eJL#%0BY;_rV%(evbCz_gEBUFa~wJWzE8Y7E4lX@|I3PDb{1#?`<5T;zau zP0jE&T7Ut_N&=dfx-Ym`b1)gslas2;G~EJbw0{UVfDeeeXj6f44 zQIu$Mri>D>s3xzHlP4@vng8 z_L{m29^Z;;Bn3;f+OqK8izwxF01+Pxl;EAN*sdNQ+^oGmRG_|&9gvYnH4cU#0_I97 z(5Dhav*zDbbBS#0XPW8ihrB4yZOv zhCB&<0r}i{@=8tu5~FA0P&-9=wJ=1$n@RzuK@6O183GQMuZ;#M$;e4Gu8q5aRd*&K zf<;Xpha@RMcY#G|WdJh+A%$N2D|wwKuttahBbstjVuf5!nt&v4@=u5aKq~+PR&s~f zo(K#Q?I^!Rg@GW|B?#$3MO(ZIcqR>=!N4p%kXL>YYkC2|`I2%j*R|RNa18oFokIKU|Eau5+ z9}-fm`a&rK#G(K;QdN$0MV6qAw*#^5`=G6WC$-kkq5s)TMe|~pv{S! z@Q`A%QgBTTf}2$h6AFwB9`rv=wgEV$H3E-b z>dUKrB^SM|#vn!WCtOiJP9FA)xrJ2&C`0SDMxyVKHxSt&w=%(sfl{m%KV$+aS58_2 z5-M4rFL6&6ACr?{je(C0gMT(9f@#nKnyY{SR`LV#MbtdDsQ01VCOxLk~(ZMoQDN@YRbbOjU+3h#mU62mAd3MjG~n6r2*|B=+yH zcFGx)XjY&!1yGC>a3DZi2ej!xn;5jMK>Hq)qLKl$UxBt5Xj9FT18m9xyscxT64D=N z-4H)ie}@4uxkzkW<0U6q#I&08hPGcy_QEE?O+!c!rr$ylro7xcUIL-*E<405EU(iz zps%fWzk5A&xDAATrzMA6e;-zV+?e-Z`h`)xzE9)jTf9_uAxTBaRPMvMrsz6&)I zk>F)gHN5NvW@dUFN)e_iL70aQAgTl}nN}DR!HBvH;UWt(O%KS=0QIb*lDtqF$@xoH zFkVEJ?tq6;2folSTz z7x$q46Bt%_hq&lIP?$|gT?YIKoJf%i_|vkI1$r~tO&x~41A2-tdJIB4-a;uZ9Pvez zY-HVHB23cB_#x?s?>3ZN{Au^{vtZ$eabt&?dmMYs7v z+Cyy*Dj6X{aREeQMC!gR!6AXFv28CbErvVNfov)uJ0=7%MyD^4P)=IJ28Lz_uk!*J z6djHja|R2fAwg(=nSi9Iv*ffy9%e5B6x;_?)SyhDxcc;iO)kVxS%%OaLk1mSmHHi> z!HOR4Kftw?6^8-wvOqjpzWfD%<7FiXu@_pr0Qvq1FQQeD&-`@>N-+Wl{Z~Po6-u#7 zj)vgmWC*cQA%SIJozFoPv5$&;lOQGNhl73sauOPlYsUfih9w~1 z4eDwv|G=n)RR%>F1wmQ?PZ@%E2dW5GQG^tqZ-ye?sZw7+`}6Dt2OIPb_5Wsv6$gX2 zSCfB=>`J)7eOlNO$N13e2_ojk?Vgc))9*(G?kCDF4Uad(byAyZt?H8-G|2(T=K5`z`rA=Us&m^OwYPGY@?IGG4YKo}AYq7VWQKM;a2 zNOR~hHS(}T z;MA_&1OiQ=(LHhX?Y8N$MGK7<2l-$e5KL(}gKmn7XfFmQw1f`4d;E~#qFaE7?|sL3 z5jYcTD$2XAP3-8|v@ZbQ(^3O95lQF{MeV0h4VM6l2CbRg?z@m=$>oO~AhkY$uWX5Y{!UHCAOJPYuj$DoF%`*nyqr4%;mP z=Mx^LcJ6V`(*Gmji6yx{R}iD(%OomH5DODQCD=OwM&Qge7&0{*iXdCR#+`z%R{?3bR&+#JqO#$lPM^xo+q<|OL1qFx; zoE7C4e0vjNDyPQWP1oAwEwY5W&tu zP-zb>!2=c{fGATrupXVx^MCF2C@_)>DWDgW;F~soXMON|t&|e80!m!MJMw{}8&d(2 zt`A3aJip4(+{x-@Hn#!JFuc)HIp!b%5kaVHcOf2l+c9UG$`9Nb_OM-W1ufutFQUWP zNxL{>M?~m3nfQS^40Qkqd%!*zqq1rEI;d-Xpc<&PyIDo4eD}evDz14QVoGs^rk~oI z(ssZ|R=}s)r#ytl*#2zF)FQP`F(THS5R!Fm{#qNpe~^?Q#sKJ zq&7A9;B#2Yy|m$wX&UcOCFC=UXy?=0lxFuxIu63?rr9AEs4rq0`2A_>5#2r3CFego z#~P_lAsxyeX14Vhd4o4~Bdrf5={$%`bAjEb5vNF9uY zU^Uy`)=HH0X-#{{DQh3239(yNyzCUiOsv76bPTou>1y>*Ws?q+%MAK517Otwe5}^> z&I=4<4(c##(EHYC-gg<}J_jd7ffm#un&4=gYv!X%F*G=AkGwlaEYol+3WAL|Kq6ge zlkR z2!90wQy{tNKPlI%>N2UqSK_GY#$SksulxZiW6L!CurEKnBQ30R9IJoC+H$>D(FQm0 z7>&el7GzgR;1F<_B>KGVk#6f=OQ#TxZqpLv=^Jg9rHEGEi}+>aXeG_GZ>~}xZ{DG! zshYJM$#!4jfj(I*M(gA^1=%7*Kmd$=DV_es+6(dF{(b=|!GzYufelycm~OI&U$!mW z(%Wyc3lhDz7yp>}Y71mOPE=FOkMu|{PO$f_Lh@Tr`NyT#Fc8p!LcxI7gLNo@;mFS7 z4W=e{13P5#ryvW-V?AS~HzhMSx+;`H~N%({7UG~*>6ftWnpj7Y}QSb1b)zKs5 z9hr2U>9d_Tzc+|p(uK#us0VGbmuy9AvWgRk%*C4Y3fS-J^W~p?8AqgpLRAQuYLLt0 zTLq+D?y-Zu)LTvW{qij(|iR$DB5`D}K*b8+kLOsSfGBv20`eta9c(TUxANH=Jz zqN~9htK0g*nw5wZ@`R0rfHN;EQXumXrjxVDnHTwNq$nZa|F55Wm^rxYlJEPlLLi@+L6&*ae)sN5!gZt`QIkOA8i`?fk7b(Zz8pZsATm2uS1DX?ff^qjh7``ScE%p_ z%K>+9J=s~Po4NA8GMzBGo531t(vOh0A@5|dt;S9S|4Lj!(g&I~8UA!;4gZz`g;zOP zT9dH!UpYKM!M=6(|5XX3x!Nzzjih9ZSKjGCz#*KKR(c^7!XSfB2l9q>fPB`N zU*u$dve0kdQ?npgH$(eRQ|FcBv|~3^)^5fvUXI!DycSTOM3uGoJ|XS6BN(;b4^+Q) zp*oGr7IwUEh+RQ;DnxnfEl~hrnA|hI_~wm`d1T!T)kRSl-wVfr-Om2bqN!_X)E9!D zP`0nd)F`Fh;a5mq-EWg=pp0@RPetN8gX@n`vxkl28Lft0O9&Z4nPM2h9eA12Qg^Bs zwqwNy*kT(@H{ZSQbo&#kM}6~1Pn2((VuEb#gHN3%r14#7X*$SAg`#V5rCGA4vc(=Z zFRm!`4234f&TP**clEH8E`odm<#@O1n~Rz)hwg%W=wGpgFkzbyiY&^o&?1Z!1M zQ`6jxI6nkyaSdeoXyT1S-_Ph)ZHM!7ZsWTbU+y_*3PgZ%Pa|&8%=p~l2{E9;t{T5xM%5~B%yOO62FE6VzhzYyC#FK@sNsiRg8 z#G8v(x7mF)dm*fdW~tZ(7_v$s+xJqyP3N z(1|Y;WV~Zq=W9^sPSX2=p149hyZl@#k?wth&n9KjZ+OrjY#Vk8>kn~jUAat?1n~$^ z86GONXU%cG8EzuIx4F01Lldj?gA)e9#_LoYKX%AG`Ma_$x!XN=#>)Y~`5MTNZ=Jt- zzTd89?76-^^XP$bjiB17dD-V15NtRPg|a49PqN{e^z312su-Qh^Pp}h%iONTp&XO% zc7b&D8b?9{YPY3on=f9OX`KLqyX!b&m;nO7>HQ$o@!y)a*}2Z+L@=Wi`|mrw;@kH1 z$JOx-xN6^Br4rLlp}1UwkxM|@%GbQDwN)vDLnUkY0?n^DmgC(TCjMk!(6{Um(f>m zzsqcdTGti^Md^k7vC}t6AckLuZQGXmv;R<{k!MGfTPMTnN!K$An%;rbO!35_T>ai4 z`n+Y^WPjml`jWJsfqgHy{2g<5nF>q)M%NP`&+T+t=jk_J|10J4&_(q!1|*=p^m)$P zw`CxM%%+fzz_e^wEm(Oz+Hvqy*04xt4_mVI zAb%B0M>(pUuG1YZ+g%dPj^G~PjLRgYii1)GT~_k`)H~(dx0599CjPW#afNTt`_ykB z(Oa}4<~Zwk-#2wnf+kaeGQ4%Ax7r)$J(@3860?LihFj>kJ>Z7a7UlR<>UhVlj3f4U z!e^1q+MN!$0+vKDmE@VQg$$?J?_0G_`33`AyM1x``c~#RPAI`|d|?iU?WgNmU%V{+ z*DSMUoHY|fT!zy`1ukFkUE(M;_SwoT!($h;l?Nn`y_aM`DuzG6SVCtrht@y{yEG|e z?D^Pr{+@~`0m;)`>BbV2x9MQ}NS@xX;>{&y?ozAx21yL$FY1~0eyY6)H#LJGHY3$! z@VsN0TZ==L2qy3Zj8j*PM6Z0^E&0CoW^XjL%F~^t;hTMY&|444@0&V09^%W|n5^5w z6J?17r(V@xDjeh`n(nf&%>7zrPKhz8^;uwBaWE>Yc)EoD&{5-mCIoB&t4tR<*!D5cxpQ`IpMRUpO*KYS2Kbk0r{JH*E&xAK~zv5EBr+mX# zIWJ5FuA`s5Wv<!qtxE~DDKz}?{&N) zuD&H*^E20nZrRc1a=?8{ZFE~+PDyic8$I7`76ngvTo*6OC^=}>(>@792tDA)Cf^PkJ!TJ3u9h`&nA^lE^2mZ;?; zr`{_sd9z=kw+zp2>egE}4=;=+d;*CpIugW3e_S2BE=i-&$YkHxX56uc-De)H^~A%O>g}iN(p6@<31i!<<=P*?J*?F` zE6xKQ)(T^1 zOfr)+CjBC7FUbq5Rcif3-fHeUdD`H7N^@wmlfJ&~2USf{;bStpLLMcshjr@XI>?*c z+gMsZ@bljfR&OwCO=YZM@19t>w#_VBn>ZXJ)LplE6;i6Xtuvf6X}@0>b~*gYyIe>A zO-5;7i_HjTTxVZ<>Y#}SmYPwZ8K_s4@MD~;Wj}t_N4Hu6w|Pf5$$fZTw-)Pk!|7&< zw90q6-U`HvA>@?Q(?Dqw8s&gKB;X+4bpiv3{=q-$nARHKkXd4 z1;*aviiLN?aBLjR`;nJ}uh(bLsc|dhzS4Q6MHz#O?X(iGR@5N;+my{NS;9`6Tf!vn z@$km?-60>%V&9`|OyV|fr&)Kl2Df9lRk?g#w}2`{WxaO})K`#c+HluEY#BA|ttE3! z>!ExWY)~HaJMD#1^Y53^+v`iq9*SoO8r6Rp+!87z%qkgF+21jI-tOw^`YaR1LvVy+ zf8k>9^Zu&2_EqzlNu_=Ry@&l?_>QNW;;B9xYwNGhe*Z`;w?9gqj`43>bSAR83v6eI zf(XxzUR!cqAKmijMDilj&(G*pRrn0+otL`Rq?U(jJ7#K+RxcMaF>cj<5j0rt`61r3 zSum|8T8?_Kp8rf|0nhvKCwDVsPg(Hk3BSOB+@kH0yfNM%m)))R&SJrZaZ3F3vAmZofUQ z=TY}P*tWxG`fo7N-rK5onq-}1Mmo>-^5Tov>#;TbBGe1Bc3=9la|GZ3?7k`d?7o-I zM)0-UuRkQftJ6@p_YKD08>@nwmhc9vo#JkF - - - - - + + + + + + diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..ae290f6 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..dbc9ea9 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..3c1ffc0 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 1e6bf33..46a2df0 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -5,6 +5,10 @@ @drawable/launch_background + false + false + false + shortEdges diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6c6987e..1ed4185 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 52323bf..f880684 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -1,5 +1,4 @@ - + diff --git a/android/build.gradle b/android/build.gradle index 36033de..98061a2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -12,7 +12,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.3.1' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } From 4bd4f1f756e9c6a74b4c958fb05891ea9ac2b7d3 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Thu, 28 Dec 2023 08:05:53 +0100 Subject: [PATCH 89/95] chore: hopefully found matching Java version from https://docs.gradle.org/current/userguide/compatibility.html --- .github/workflows/flutter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index e2bc952..2f8a2c9 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/setup-java@v2 with: distribution: 'zulu' - java-version: '21' + java-version: '17' - uses: subosito/flutter-action@v2 with: channel: 'stable' From 8d0db8ae9c29b76138c65793eeb957e307853e60 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Thu, 28 Dec 2023 08:31:34 +0100 Subject: [PATCH 90/95] chore: work on CI --- .github/workflows/flutter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 2f8a2c9..e75d469 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -17,12 +17,12 @@ jobs: PROPERTIES_PATH: ${{ github.workspace }}/android/key.properties STORE_PATH: ${{ github.workspace }}/android/keystore.jks run: | - ls -la ${{github.workspace}} echo keyPassword=\${{secrets.PLAY_UPLOAD_KEY_PASSWORD}} > ${{env.PROPERTIES_PATH}} echo storePassword=\${{secrets.PLAY_UPLOAD_STORE_PASSWORD}} >> ${{env.PROPERTIES_PATH}} echo keyAlias=\${{secrets.PLAY_KEY_ALIAS}} >> ${{env.PROPERTIES_PATH}} echo storeFile=\${{env.STORE_PATH}} >> ${{env.PROPERTIES_PATH}} echo "${{env.BASE64_STORE}}" | base64 --decode > ${{env.STORE_PATH}} + cat ${{env.PROPERTIES_PATH}} - uses: actions/setup-java@v2 with: distribution: 'zulu' From fee43625880a54ff7cf4c1855136f982c94f7d35 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Thu, 28 Dec 2023 08:34:39 +0100 Subject: [PATCH 91/95] chore: work on CI --- .github/workflows/flutter.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index e75d469..955b4df 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -16,8 +16,9 @@ jobs: BASE64_STORE: ${{ secrets.PLAY_UPLOAD_KEYSTORE }} PROPERTIES_PATH: ${{ github.workspace }}/android/key.properties STORE_PATH: ${{ github.workspace }}/android/keystore.jks + KEY_PASSWORD: ${{secrets.PLAY_UPLOAD_KEY_PASSWORD}} run: | - echo keyPassword=\${{secrets.PLAY_UPLOAD_KEY_PASSWORD}} > ${{env.PROPERTIES_PATH}} + echo keyPassword=\${{env.KEY_PASSWORD}} > ${{env.PROPERTIES_PATH}} echo storePassword=\${{secrets.PLAY_UPLOAD_STORE_PASSWORD}} >> ${{env.PROPERTIES_PATH}} echo keyAlias=\${{secrets.PLAY_KEY_ALIAS}} >> ${{env.PROPERTIES_PATH}} echo storeFile=\${{env.STORE_PATH}} >> ${{env.PROPERTIES_PATH}} From c3e3c8745fd3171e2a53bd30533d9d6ba7422546 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Thu, 28 Dec 2023 08:46:14 +0100 Subject: [PATCH 92/95] chore: learning GitHub actions --- .github/workflows/flutter.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 955b4df..7866e0b 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -23,7 +23,11 @@ jobs: echo keyAlias=\${{secrets.PLAY_KEY_ALIAS}} >> ${{env.PROPERTIES_PATH}} echo storeFile=\${{env.STORE_PATH}} >> ${{env.PROPERTIES_PATH}} echo "${{env.BASE64_STORE}}" | base64 --decode > ${{env.STORE_PATH}} + echo "storePath: $STORE_PATH" + ls -la ${{env.STORE_PATH}} + echo "propertiesPath: $PROPERTIES_PATH / ${{env.PROPERTIES_PATH}}}" cat ${{env.PROPERTIES_PATH}} + echo "Size of PW: ${#KEY_PASSWORD} ${#${{secrets.PLAY_UPLOAD_KEY_PASSWORD}}}" - uses: actions/setup-java@v2 with: distribution: 'zulu' From 877cfead3fa609a29e04c7d49506200effdfb82d Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Thu, 28 Dec 2023 09:12:54 +0100 Subject: [PATCH 93/95] chore: work on CI --- .github/workflows/flutter.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 7866e0b..6e6686a 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -13,21 +13,16 @@ jobs: - uses: actions/checkout@v3 - name: Decode Keystore and Create key.properties env: - BASE64_STORE: ${{ secrets.PLAY_UPLOAD_KEYSTORE }} PROPERTIES_PATH: ${{ github.workspace }}/android/key.properties STORE_PATH: ${{ github.workspace }}/android/keystore.jks - KEY_PASSWORD: ${{secrets.PLAY_UPLOAD_KEY_PASSWORD}} run: | - echo keyPassword=\${{env.KEY_PASSWORD}} > ${{env.PROPERTIES_PATH}} + echo keyPassword=\${{secrets.PLAY_UPLOAD_KEY_PASSWORD}} > ${{env.PROPERTIES_PATH}} echo storePassword=\${{secrets.PLAY_UPLOAD_STORE_PASSWORD}} >> ${{env.PROPERTIES_PATH}} echo keyAlias=\${{secrets.PLAY_KEY_ALIAS}} >> ${{env.PROPERTIES_PATH}} echo storeFile=\${{env.STORE_PATH}} >> ${{env.PROPERTIES_PATH}} - echo "${{env.BASE64_STORE}}" | base64 --decode > ${{env.STORE_PATH}} + echo "${{ secrets.PLAY_UPLOAD_KEYSTORE }}" | base64 --decode > ${{env.STORE_PATH}} echo "storePath: $STORE_PATH" ls -la ${{env.STORE_PATH}} - echo "propertiesPath: $PROPERTIES_PATH / ${{env.PROPERTIES_PATH}}}" - cat ${{env.PROPERTIES_PATH}} - echo "Size of PW: ${#KEY_PASSWORD} ${#${{secrets.PLAY_UPLOAD_KEY_PASSWORD}}}" - uses: actions/setup-java@v2 with: distribution: 'zulu' From 5f6878203aed595cf1dae78b7c190cd8e2055866 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Fri, 5 Jan 2024 11:06:44 +0100 Subject: [PATCH 94/95] chore: improve code style --- .github/workflows/flutter.yml | 2 - lib/hoster/service.dart | 4 +- lib/lock/view.dart | 2 +- lib/models/async_mime_source.dart | 6 +- lib/models/hive/hive_mime_storage.dart | 2 +- lib/models/mail_operation.dart | 2 +- lib/models/message_source.dart | 4 +- lib/models/offline_mime_source.dart | 14 +-- lib/screens/account_edit_screen.dart | 4 +- lib/screens/welcome_screen.dart | 2 +- lib/widgets/account_hoster_selector.dart | 2 +- lib/widgets/ical_composer.dart | 4 +- lib/widgets/ical_interactive_media.dart | 16 +-- pubspec.lock | 114 +++++++++---------- test/model/fake_mime_source.dart | 38 +++---- test/model/multiple_message_source_test.dart | 12 +- 16 files changed, 113 insertions(+), 115 deletions(-) diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 6e6686a..4928f9e 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -21,8 +21,6 @@ jobs: echo keyAlias=\${{secrets.PLAY_KEY_ALIAS}} >> ${{env.PROPERTIES_PATH}} echo storeFile=\${{env.STORE_PATH}} >> ${{env.PROPERTIES_PATH}} echo "${{ secrets.PLAY_UPLOAD_KEYSTORE }}" | base64 --decode > ${{env.STORE_PATH}} - echo "storePath: $STORE_PATH" - ls -la ${{env.STORE_PATH}} - uses: actions/setup-java@v2 with: distribution: 'zulu' diff --git a/lib/hoster/service.dart b/lib/hoster/service.dart index ffb6e12..e0fb7e0 100644 --- a/lib/hoster/service.dart +++ b/lib/hoster/service.dart @@ -158,7 +158,7 @@ class MailHoster { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: PlatformText(buttonText), + child: Text(buttonText), ), ], ), @@ -240,7 +240,7 @@ class GmailMailHoster extends MailHoster { ), Padding( padding: const EdgeInsets.only(left: 8, right: 16), - child: PlatformText( + child: Text( localizations.addAccountOauthSignInGoogle, style: GoogleFonts.roboto( color: googleText, diff --git a/lib/lock/view.dart b/lib/lock/view.dart index 98a85de..5309fa0 100644 --- a/lib/lock/view.dart +++ b/lib/lock/view.dart @@ -59,7 +59,7 @@ class _LockScreenState extends State { child: Text(localizations.lockScreenIntro), ), PlatformTextButton( - child: PlatformText(localizations.lockScreenUnlockAction), + child: Text(localizations.lockScreenUnlockAction), onPressed: () => _authenticate(context), ), ], diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index e3b2f62..612e2ec 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -977,20 +977,20 @@ class AsyncSearchMimeSource extends AsyncMimeSource { @override Future moveMessages( List messages, Mailbox targetMailbox) { - // TODO: implement moveMessages + // TODO(RV): implement moveMessages throw UnimplementedError(); } @override Future moveMessagesToFlag( List messages, MailboxFlag targetMailboxFlag) { - // TODO: implement moveMessagesToFlag + // TODO(RV): implement moveMessagesToFlag throw UnimplementedError(); } @override Future undoMoveMessages(MoveResult moveResult) { - // TODO: implement undoMoveMessages + // TODO(RV): implement undoMoveMessages throw UnimplementedError(); } diff --git a/lib/models/hive/hive_mime_storage.dart b/lib/models/hive/hive_mime_storage.dart index 6134b5a..f034a69 100644 --- a/lib/models/hive/hive_mime_storage.dart +++ b/lib/models/hive/hive_mime_storage.dart @@ -219,7 +219,7 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { @override Future moveMessages(List messages, Mailbox targetMailbox) { - // TODO: implement moveMessages + // TODO(RV): implement moveMessages throw UnimplementedError(); } } diff --git a/lib/models/mail_operation.dart b/lib/models/mail_operation.dart index 1bd3a11..91845b0 100644 --- a/lib/models/mail_operation.dart +++ b/lib/models/mail_operation.dart @@ -130,7 +130,7 @@ class StoreFlagsOperation extends MailOperation { @override Future execute(MailClient mailClient, OfflineMimeStorage storage) { - // TODO: implement execute + // TODO(RV): implement execute throw UnimplementedError(); } } diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index 9091fe3..fbf95f5 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -1268,7 +1268,7 @@ class SingleMessageSource extends MessageSource { @override void onMailCacheInvalidated(AsyncMimeSource source) { - // TODO: implement onMailCacheInvalidated + // TODO(RV): implement onMailCacheInvalidated } } @@ -1350,7 +1350,7 @@ class ListMessageSource extends MessageSource { @override void onMailCacheInvalidated(AsyncMimeSource source) { - // TODO: implement onMailCacheInvalidated + // TODO(RV): implement onMailCacheInvalidated } } diff --git a/lib/models/offline_mime_source.dart b/lib/models/offline_mime_source.dart index 54f1bb5..79d6d78 100644 --- a/lib/models/offline_mime_source.dart +++ b/lib/models/offline_mime_source.dart @@ -166,31 +166,31 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { List messages, MailboxFlag targetMailboxFlag, ) { - // TODO: implement moveMessagesToFlag + // TODO(RV): implement moveMessagesToFlag throw UnimplementedError(); } @override Future undoMoveMessages(MoveResult moveResult) { - // TODO: implement undoMoveMessages + // TODO(RV): implement undoMoveMessages throw UnimplementedError(); } @override Future> deleteAllMessages({bool expunge = false}) { - // TODO: implement deleteAllMessages + // TODO(RV): implement deleteAllMessages throw UnimplementedError(); } @override Future deleteMessages(List messages) { - // TODO: implement deleteMessages + // TODO(RV): implement deleteMessages throw UnimplementedError(); } @override Future undoDeleteMessages(DeleteResult deleteResult) { - // TODO: implement undoDeleteMessages + // TODO(RV): implement undoDeleteMessages throw UnimplementedError(); } @@ -199,7 +199,7 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { @override AsyncMimeSource search(MailSearch search) { - // TODO: implement search + // TODO(RV): implement search throw UnimplementedError(); } @@ -228,7 +228,7 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { List flags, { StoreAction action = StoreAction.add, }) { - // TODO: implement storeAll + // TODO(RV): implement storeAll throw UnimplementedError(); } diff --git a/lib/screens/account_edit_screen.dart b/lib/screens/account_edit_screen.dart index ce529e5..02ecba6 100644 --- a/lib/screens/account_edit_screen.dart +++ b/lib/screens/account_edit_screen.dart @@ -93,7 +93,7 @@ class AccountEditScreen extends HookConsumerWidget { isRetryingToConnectState, ), icon: Icon(iconService.retry), - label: PlatformText( + label: Text( localizations .editAccountFailureToConnectRetryAction, ), @@ -107,7 +107,7 @@ class AccountEditScreen extends HookConsumerWidget { account, isRetryingToConnectState, ), - child: PlatformText( + child: Text( localizations .editAccountFailureToConnectChangePasswordAction, ), diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart index 79cce1e..53a91f4 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -103,7 +103,7 @@ class WelcomeScreen extends StatelessWidget { child: PlatformFilledButtonIcon( icon: Icon(IconService.instance.email), label: Center( - child: PlatformText(localizations.welcomeActionSignIn), + child: Text(localizations.welcomeActionSignIn), ), onPressed: () { context.goNamed(Routes.accountAdd); diff --git a/lib/widgets/account_hoster_selector.dart b/lib/widgets/account_hoster_selector.dart index 3bcff99..8f706d6 100644 --- a/lib/widgets/account_hoster_selector.dart +++ b/lib/widgets/account_hoster_selector.dart @@ -22,7 +22,7 @@ class MailHosterSelector extends StatelessWidget { if (index == 0) { return Center( child: PlatformTextButton( - child: PlatformText(localizations.accountProviderCustom), + child: Text(localizations.accountProviderCustom), onPressed: () => onSelected(null), ), ); diff --git a/lib/widgets/ical_composer.dart b/lib/widgets/ical_composer.dart index 35ee3ac..816db32 100644 --- a/lib/widgets/ical_composer.dart +++ b/lib/widgets/ical_composer.dart @@ -328,7 +328,7 @@ class _DateTimePicker extends StatelessWidget { children: [ // set date button: PlatformTextButton( - child: PlatformText( + child: Text( dt == null ? localizations.composeAppointmentLabelDay : context.formatDate(dt.toLocal(), useLongFormat: true), @@ -355,7 +355,7 @@ class _DateTimePicker extends StatelessWidget { if (!onlyDate) // set time button: PlatformTextButton( - child: PlatformText( + child: Text( dt == null ? localizations.composeAppointmentLabelTime : context.formatTimeOfDay( diff --git a/lib/widgets/ical_interactive_media.dart b/lib/widgets/ical_interactive_media.dart index 3cc9168..e947a23 100644 --- a/lib/widgets/ical_interactive_media.dart +++ b/lib/widgets/ical_interactive_media.dart @@ -107,7 +107,7 @@ class _IcalInteractiveMediaState extends State { Row( children: [ PlatformTextButton( - child: PlatformText(localizations.actionAccept), + child: Text(localizations.actionAccept), onPressed: () => _changeParticipantStatus( ParticipantStatus.accepted, localizations, @@ -122,7 +122,7 @@ class _IcalInteractiveMediaState extends State { ), ), PlatformTextButton( - child: PlatformText(localizations.actionDecline), + child: Text(localizations.actionDecline), onPressed: () => _changeParticipantStatus( ParticipantStatus.declined, localizations, @@ -140,7 +140,7 @@ class _IcalInteractiveMediaState extends State { ), ), PlatformTextButton( - child: PlatformText( + child: Text( localizations.icalendarActionChangeParticipantStatus, ), onPressed: () => _queryParticipantStatus(localizations), @@ -354,7 +354,7 @@ class _IcalInteractiveMediaState extends State { ), if (!isReply) PlatformElevatedButton( - child: PlatformText(localizations.icalendarExportAction), + child: Text(localizations.icalendarExportAction), onPressed: () => _exportToNativeCalendar(_calendar), ), ], @@ -436,19 +436,19 @@ class _IcalInteractiveMediaState extends State { localizations.icalendarParticipantStatusChangeText, actions: [ PlatformTextButton( - child: PlatformText(localizations.actionAccept), + child: Text(localizations.actionAccept), onPressed: () => context.pop(ParticipantStatus.accepted), ), PlatformTextButton( - child: PlatformText(localizations.icalendarAcceptTentatively), + child: Text(localizations.icalendarAcceptTentatively), onPressed: () => context.pop(ParticipantStatus.tentative), ), PlatformTextButton( - child: PlatformText(localizations.actionDecline), + child: Text(localizations.actionDecline), onPressed: () => context.pop(ParticipantStatus.declined), ), PlatformTextButton( - child: PlatformText(localizations.actionCancel), + child: Text(localizations.actionCancel), onPressed: () => context.pop(), ), ], diff --git a/pubspec.lock b/pubspec.lock index 8e90681..e83d6a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: "7b875fd4a20b165a3084bd2d210439b22ebc653f21cea4842729c0c30c82596b" + sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" url: "https://pub.dev" source: hosted - version: "3.4.9" + version: "3.4.10" args: dependency: transitive description: @@ -173,26 +173,26 @@ packages: dependency: "direct main" description: name: cached_network_image - sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.3.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" characters: dependency: transitive description: @@ -638,10 +638,10 @@ packages: dependency: "direct main" description: name: flutter_hooks - sha256: "7c8db779c2d1010aa7f9ea3fbefe8f86524fcb87b69e8b0af31e1a4b55422dec" + sha256: "09f64db63fee3b2ab8b9038a1346be7d8986977fae3fec601275bf32455ccfc0" url: "https://pub.dev" source: hosted - version: "0.20.3" + version: "0.20.4" flutter_inappwebview: dependency: transitive description: @@ -758,10 +758,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: bb5cd63ff7c91d6efe452e41d0d0ae6348925c82eafd10ce170ef585ea04776e + sha256: "892ada16046d641263f30c72e7432397088810a84f34479f6677494802a2b535" url: "https://pub.dev" source: hosted - version: "16.2.0" + version: "16.3.0" flutter_local_notifications_linux: dependency: transitive description: @@ -787,10 +787,10 @@ packages: dependency: "direct dev" description: name: flutter_native_splash - sha256: "141b20f15a2c4fe6e33c49257ca1bc114fc5c500b04fcbc8d75016bb86af672f" + sha256: "9cdb5d9665dab5d098dc50feab74301c2c228cd02ca25c9b546ab572cebcd6af" url: "https://pub.dev" source: hosted - version: "2.3.8" + version: "2.3.9" flutter_platform_widgets: dependency: transitive description: @@ -885,10 +885,10 @@ packages: dependency: "direct main" description: name: flutter_widget_from_html_core - sha256: "86d40a9f26d10011664df057c950e9c348ee1a7dbf141f295a07b0075ffd780b" + sha256: "0e281196f962fd951da5b9d3fa50e0674fabf8fda92eafd8745d050d70877c68" url: "https://pub.dev" source: hosted - version: "0.14.9" + version: "0.14.10+1" fluttercontactpicker: dependency: "direct main" description: @@ -933,10 +933,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: ca7e4a2249f96773152f1853fa25933ac752495cdd7fdf5dafb9691bd05830fd + sha256: "3b40e751eaaa855179b416974d59d29669e750d2e50fcdb2b37f1cb0ca8c803a" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "13.0.1" google_fonts: dependency: "direct main" description: @@ -1125,10 +1125,10 @@ packages: dependency: transitive description: name: local_auth_platform_interface - sha256: fc5bd537970a324260fda506cfb61b33ad7426f37a8ea5c461cf612161ebba54 + sha256: "3215f9a97aa532aca91ea7591e9ee6a553bdc66ff9b11f19d14b6dffc4fdf45b" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" local_auth_windows: dependency: transitive description: @@ -1301,10 +1301,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_foundation: dependency: transitive description: @@ -1342,7 +1342,7 @@ packages: description: path: "packages/pdfx" ref: HEAD - resolved-ref: "8b105a7dfc6b90220c1d79fcb805fb764cab00c5" + resolved-ref: d637108a2a6e3e97a70304f00f1eda9511fb4f92 url: "https://github.com/ScerIO/packages.flutter" source: git version: "2.5.0" @@ -1366,18 +1366,18 @@ packages: dependency: transitive description: name: platform - sha256: "0a279f0707af40c890e80b1e9df8bb761694c074ba7e1d4ab1bc4b728e200b59" + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface - sha256: f4f88d4a900933e7267e2b353594774fc0d07fb072b47eedcd5b54e1ea3269f8 + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.7" + version: "2.1.8" pointycastle: dependency: transitive description: @@ -1747,18 +1747,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "31222ffb0063171b526d3e569079cf1f8b294075ba323443fdc690842bfd4def" + sha256: c0766a55ab42cefaa728cabc951e82919ab41a3a4fee0aaa96176ca82da8cc51 url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "6.2.1" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: bba3373219b7abb6b5e0d071b0fe66dfbe005d07517a68e38d4fc3638f35c6d3 + sha256: "46b81e3109cbb2d6b81702ad3077540789a3e74e22795eb9f0b7d494dbaa72ea" url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "6.2.2" url_launcher_linux: dependency: transitive description: @@ -1779,18 +1779,18 @@ packages: dependency: transitive description: name: url_launcher_platform_interface - sha256: "980e8d9af422f477be6948bdfb68df8433be71f5743a188968b0c1b887807e50" + sha256: f099b552bd331eacd69affed7ff2f23bfa6b0cb825b629edf3d844375a7501ad url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "7286aec002c8feecc338cc33269e96b73955ab227456e9fb2a91f7fab8a358e9" + sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" url_launcher_windows: dependency: transitive description: @@ -1819,26 +1819,26 @@ packages: dependency: transitive description: name: video_player - sha256: e16f0a83601a78d165dabc17e4dac50997604eb9e4cc76e10fa219046b70cef3 + sha256: fbf28ce8bcfe709ad91b5789166c832cb7a684d14f571a81891858fefb5bb1c2 url: "https://pub.dev" source: hosted - version: "2.8.1" + version: "2.8.2" video_player_android: dependency: transitive description: name: video_player_android - sha256: "3fe89ab07fdbce786e7eb25b58532d6eaf189ceddc091cb66cba712f8d9e8e55" + sha256: "7f8f25d7ad56819a82b2948357f3c3af071f6a678db33833b26ec36bbc221316" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.4.11" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "01a57940e1dabc8769ccd457c4ae9ea50274e7d5a7617f7820dae5fe1d8436ae" + sha256: "08da93071ef322603839aa42e90e23d4820b03cf2db7eb6a45de5d41fe85c2aa" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" video_player_platform_interface: dependency: transitive description: @@ -1851,10 +1851,10 @@ packages: dependency: transitive description: name: video_player_web - sha256: ab7a462b07d9ca80bed579e30fb3bce372468f1b78642e0911b10600f2c5cb5b + sha256: "34beb3a07d4331a24f7e7b2f75b8e2b103289038e07e65529699a671b6a6e2cb" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" vm_service: dependency: transitive description: @@ -1899,50 +1899,50 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "045ec2137c27bf1a32e6ffa0e734d532a6677bf9016a0d1a406c54e499ff945b" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" webview_flutter: dependency: "direct main" description: name: webview_flutter - sha256: "42393b4492e629aa3a88618530a4a00de8bb46e50e7b3993fedbfdc5352f0dbf" + sha256: "60e23976834e995c404c0b21d3b9db37ecd77d3303ef74f8b8d7a7b19947fc04" url: "https://pub.dev" source: hosted - version: "4.4.2" + version: "4.4.3" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: e313dcdf45d4c95bcb8960351ef2389b7f0687b90bc92483f7f7983ae5758456 + sha256: "161af93c2abaf94ef2192bffb53a3658b2d721a3bf99b69aa1e47814ee18cc96" url: "https://pub.dev" source: hosted - version: "3.13.0" + version: "3.13.2" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - sha256: "68e86162aa8fc646ae859e1585995c096c95fc2476881fa0c4a8d10f56013a5a" + sha256: dbe745ee459a16b6fec296f7565a8ef430d0d681001d8ae521898b9361854943 url: "https://pub.dev" source: hosted - version: "2.8.0" + version: "2.9.0" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: accdaaa49a2aca2dc3c3230907988954cdd23fed0a19525d6c9789d380f4dc76 + sha256: "02d8f3ebbc842704b2b662377b3ee11c0f8f1bbaa8eab6398262f40049819160" url: "https://pub.dev" source: hosted - version: "3.9.4" + version: "3.10.1" win32: dependency: transitive description: name: win32 - sha256: b0f37db61ba2f2e9b7a78a1caece0052564d1bc70668156cf3a29d676fe4e574 + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.2.0" win32_registry: dependency: transitive description: @@ -1955,10 +1955,10 @@ packages: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" xml: dependency: "direct overridden" description: diff --git a/test/model/fake_mime_source.dart b/test/model/fake_mime_source.dart index f618704..722d223 100644 --- a/test/model/fake_mime_source.dart +++ b/test/model/fake_mime_source.dart @@ -159,19 +159,19 @@ class FakeMimeSource extends PagedCachedMimeSource { Future init() => Future.value(); @override - // TODO: implement isArchive + // TODO(RV): implement isArchive bool get isArchive => throw UnimplementedError(); @override - // TODO: implement isJunk + // TODO(RV): implement isJunk bool get isJunk => throw UnimplementedError(); @override - // TODO: implement isSent + // TODO(RV): implement isSent bool get isSent => throw UnimplementedError(); @override - // TODO: implement isTrash + // TODO(RV): implement isTrash bool get isTrash => throw UnimplementedError(); @override @@ -202,7 +202,7 @@ class FakeMimeSource extends PagedCachedMimeSource { @override AsyncMimeSource search(MailSearch search) { - // TODO: implement search + // TODO(RV): implement search throw UnimplementedError(); } @@ -210,16 +210,16 @@ class FakeMimeSource extends PagedCachedMimeSource { bool get supportsDeleteAll => true; @override - // TODO: implement supportsMessageFolders + // TODO(RV): implement supportsMessageFolders bool get supportsMessageFolders => throw UnimplementedError(); @override - // TODO: implement supportsSearching + // TODO(RV): implement supportsSearching bool get supportsSearching => throw UnimplementedError(); @override void dispose() { - // TODO: implement dispose + // TODO(RV): implement dispose } @override @@ -231,7 +231,7 @@ class FakeMimeSource extends PagedCachedMimeSource { List flags, { StoreAction action = StoreAction.add, }) { - // TODO: implement store + // TODO(RV): implement store throw UnimplementedError(); } @@ -240,13 +240,13 @@ class FakeMimeSource extends PagedCachedMimeSource { List flags, { StoreAction action = StoreAction.add, }) { - // TODO: implement storeAll + // TODO(RV): implement storeAll throw UnimplementedError(); } @override Future undoDeleteMessages(DeleteResult deleteResult) { - // TODO: implement undoDeleteMessages + // TODO(RV): implement undoDeleteMessages throw UnimplementedError(); } @@ -255,7 +255,7 @@ class FakeMimeSource extends PagedCachedMimeSource { List messages, Mailbox targetMailbox, ) { - // TODO: implement moveMessages + // TODO(RV): implement moveMessages throw UnimplementedError(); } @@ -264,13 +264,13 @@ class FakeMimeSource extends PagedCachedMimeSource { List messages, MailboxFlag targetMailboxFlag, ) { - // TODO: implement moveMessagesToFlag + // TODO(RV): implement moveMessagesToFlag throw UnimplementedError(); } @override Future undoMoveMessages(MoveResult moveResult) { - // TODO: implement undoMoveMessages + // TODO(RV): implement undoMoveMessages throw UnimplementedError(); } @@ -282,12 +282,12 @@ class FakeMimeSource extends PagedCachedMimeSource { List? includedInlineTypes, Duration? responseTimeout, }) { - // TODO: implement fetchMessageContents + // TODO(RV): implement fetchMessageContents throw UnimplementedError(); } @override - // TODO: implement isInbox + // TODO(RV): implement isInbox bool get isInbox => throw UnimplementedError(); @override @@ -296,7 +296,7 @@ class FakeMimeSource extends PagedCachedMimeSource { required String fetchId, Duration? responseTimeout, }) { - // TODO: implement fetchMessagePart + // TODO(RV): implement fetchMessagePart throw UnimplementedError(); } @@ -309,11 +309,11 @@ class FakeMimeSource extends PagedCachedMimeSource { bool use8BitEncoding = false, List? recipients, }) { - // TODO: implement sendMessage + // TODO(RV): implement sendMessage throw UnimplementedError(); } @override - // TODO: implement mailbox + // TODO(RV): implement mailbox Mailbox get mailbox => throw UnimplementedError(); } diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index d5ab676..3459eee 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -1254,11 +1254,11 @@ void main() async { class TestScaffoldMessengerService implements ScaffoldMessengerService { @override void popStatusBarState() { - // TODO: implement popStatusBarState + // TODO(RV): implement popStatusBarState } @override - // TODO: implement scaffoldMessengerKey + // TODO(RV): implement scaffoldMessengerKey GlobalKey get scaffoldMessengerKey => throw UnimplementedError(); @@ -1268,12 +1268,12 @@ class TestScaffoldMessengerService implements ScaffoldMessengerService { String text, { Function()? undo, }) { - // TODO: implement showTextSnackBar + // TODO(RV): implement showTextSnackBar } @override set statusBarState(CupertinoStatusBarState state) { - // TODO: implement statusBarState + // TODO(RV): implement statusBarState } } @@ -1305,7 +1305,7 @@ class TestNotificationService implements NotificationService { @override Future> getActiveMailNotifications() { - // TODO: implement getActiveMailNotifications + // TODO(RV): implement getActiveMailNotifications throw UnimplementedError(); } @@ -1314,7 +1314,7 @@ class TestNotificationService implements NotificationService { bool checkForLaunchDetails = true, BuildContext? context, }) { - // TODO: implement init + // TODO(RV): implement init throw UnimplementedError(); } From 4fed245483a69dcc19f03620a9b4c17329572f86 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 29 Jan 2024 09:51:53 +0100 Subject: [PATCH 95/95] chore: improve code style --- lib/app_styles.dart | 48 ------------------- lib/location/view.dart | 1 - lib/models/async_mime_source.dart | 34 ++++++++----- lib/models/date_sectioned_message_source.dart | 5 +- lib/models/hive/hive_mime_storage.dart | 8 ++-- lib/models/mail_operation.dart | 5 +- lib/models/message.dart | 8 +++- lib/models/message_source.dart | 41 ++-------------- lib/notification/service.dart | 4 +- lib/screens/compose_screen.dart | 7 +-- lib/screens/message_source_screen.dart | 30 ++++-------- lib/util/gravatar.dart | 11 +++-- lib/util/localized_dialog_helper.dart | 6 ++- lib/util/string_helper.dart | 3 +- lib/util/validator.dart | 1 + lib/widgets/attachment_chip.dart | 5 +- lib/widgets/attachment_compose_bar.dart | 4 +- lib/widgets/expansion_wrap.dart | 33 ++++++++----- lib/widgets/ical_interactive_media.dart | 3 +- lib/widgets/icon_text.dart | 14 ++---- lib/widgets/legalese.dart | 1 + lib/widgets/message_stack.dart | 3 +- test/model/async_mime_source_test.dart | 33 ++++++++----- test/model/fake_mime_source.dart | 4 +- test/model/multiple_message_source_test.dart | 19 +++----- 25 files changed, 142 insertions(+), 189 deletions(-) delete mode 100644 lib/app_styles.dart diff --git a/lib/app_styles.dart b/lib/app_styles.dart deleted file mode 100644 index d661400..0000000 --- a/lib/app_styles.dart +++ /dev/null @@ -1,48 +0,0 @@ -// import 'package:enough_style/enough_style.dart'; -// import 'package:flutter/material.dart'; - -// class AppStyles { -// static AppStyles instance = AppStyles._(); -// StyleSheetManager styleSheetManager = StyleSheetManager.instance; - -// AppStyles._() { -// var defaultPrimarySwatch = Colors.green; -// var brightColorScheme = ColorScheme.fromSwatch( -// primarySwatch: defaultPrimarySwatch, -// backgroundColor: Color(0xfff0f0f0), -// errorColor: Colors.redAccent, -// brightness: Brightness.light); -// var darkColorScheme = ColorScheme.fromSwatch( -// primarySwatch: defaultPrimarySwatch, -// backgroundColor: Color(0xff3a3a3a), -// errorColor: Colors.redAccent, -// brightness: Brightness.dark); -// var chocoladeColorScheme = ColorScheme.fromSwatch( -// primarySwatch: Colors.brown, -// backgroundColor: Colors.brown[600], -// errorColor: Colors.redAccent, -// brightness: Brightness.dark); -// var neomorphismBright = StyleSheet('neo bright', -// themeData: ThemeData( -// colorScheme: brightColorScheme, -// primarySwatch: defaultPrimarySwatch)); -// neomorphismBright.addStyle( -// Style('page', padding: EdgeInsets.all(20), decorator: FlatDecorator())); -// neomorphismBright.addStyle(Style('settings', -// decorator: NeomorphismDecorator( -// borderRadius: BorderRadius.only( -// topRight: Radius.circular(50), -// bottomLeft: Radius.circular(50))), -// padding: EdgeInsets.all(20), -// margin: EdgeInsets.fromLTRB(10, 0, 10, 0))); -// neomorphismBright.addStyle(Style('settingsOption', -// textStyler: NeomorphismTextStyler( -// color: const Color(0xff3A3A3A), -// textStyle: TextStyle(fontWeight: FontWeight.bold)))); -// styleSheetManager.add(neomorphismBright); -// styleSheetManager -// .add(neomorphismBright.copyWith('neo dark', darkColorScheme)); -// styleSheetManager -// .add(neomorphismBright.copyWith('neo chocolade', chocoladeColorScheme)); -// } -// } diff --git a/lib/location/view.dart b/lib/location/view.dart index 8f56d87..1148233 100644 --- a/lib/location/view.dart +++ b/lib/location/view.dart @@ -183,7 +183,6 @@ class _LocationScreenState extends State { void _onScaleUpdate(ScaleUpdateDetails details, MapTransformer transformer) { final scaleDiff = details.scale - _scaleStart; - //print('on scale update: scaleDiff=$scaleDiff focal=${details.focalPoint}'); _scaleStart = details.scale; if (scaleDiff > 0) { diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index 612e2ec..ee72ffb 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -148,12 +148,14 @@ abstract class AsyncMimeSource { Duration? responseTimeout, }); - /// Informs this source about a new incoming [message] at the optional [index]. + /// Informs this source about a new incoming [message] + /// at the optional [index]. /// /// Note this message does not necessarily match to this sources. Future onMessageArrived(MimeMessage message, {int? index}); - /// Informs this source about the [sequence] having been removed on the server. + /// Informs this source about the [sequence] having been removed + /// on the server. Future onMessagesVanished(MessageSequence sequence); /// Is called when message flags have been updated on the server. @@ -381,11 +383,14 @@ abstract class CachedMimeSource extends AsyncMimeSource { // fetch and compare the 20 latest messages: // For each message check for the following cases: - // - message can be new (it will have a higher UID that the known first message) + // - message can be new (it will have a higher UID that the known + // first message) // - message can have updated flags (GUID will still be the same) - // - a previously cached message can now be deleted (sequence ID will match, but not the UID/GUID) + // - a previously cached message can now be deleted (sequence ID will match, + // but not the UID/GUID) // - // Additional complications occur when not the same number of first messages are cached, + // Additional complications occur when not the same number of first messages + // are cached, // in that case the GUID/UID cannot be compared. // // Also, previously there might have been less messages in this @@ -395,7 +400,8 @@ abstract class CachedMimeSource extends AsyncMimeSource { final firstCachedUid = firstCached?.uid; if (firstCachedUid == null) { // When the latest message is not known, better reload all. - // TODO(RV): Should a reload also be triggered when other messages are not cached? + // TODO(RV): Should a reload also be triggered when other messages are + // not cached? cache.clear(); notifySubscribersOnCacheInvalidated(); @@ -930,11 +936,11 @@ class AsyncSearchMimeSource extends AsyncMimeSource { @override Future onMessagesVanished(MessageSequence sequence) { if (sequence.isUidSequence == searchResult.pagedSequence.isUidSequence) { - final removedMessages = searchResult.removeMessageSequence(sequence); - for (final removed in removedMessages) { - notifySubscribersOnMessageVanished(removed); - } + searchResult + .removeMessageSequence(sequence) + .forEach(notifySubscribersOnMessageVanished); } + return Future.value(); } @@ -976,14 +982,18 @@ class AsyncSearchMimeSource extends AsyncMimeSource { @override Future moveMessages( - List messages, Mailbox targetMailbox) { + List messages, + Mailbox targetMailbox, + ) { // TODO(RV): implement moveMessages throw UnimplementedError(); } @override Future moveMessagesToFlag( - List messages, MailboxFlag targetMailboxFlag) { + List messages, + MailboxFlag targetMailboxFlag, + ) { // TODO(RV): implement moveMessagesToFlag throw UnimplementedError(); } diff --git a/lib/models/date_sectioned_message_source.dart b/lib/models/date_sectioned_message_source.dart index b070f4f..972c51c 100644 --- a/lib/models/date_sectioned_message_source.dart +++ b/lib/models/date_sectioned_message_source.dart @@ -130,6 +130,7 @@ class DateSectionedMessageSource extends ChangeNotifier { if (message != null) { return SectionElement(null, message); } + return null; } @@ -148,11 +149,13 @@ class DateSectionedMessageSource extends ChangeNotifier { } } final message = await messageSource.getMessageAt(messageIndex); + return SectionElement(null, message); } Future> getMessagesForSection( - MessageDateSection section) async { + MessageDateSection section, + ) async { final index = _sections.indexOf(section); if (index == -1) { return []; diff --git a/lib/models/hive/hive_mime_storage.dart b/lib/models/hive/hive_mime_storage.dart index f034a69..0b8ce3c 100644 --- a/lib/models/hive/hive_mime_storage.dart +++ b/lib/models/hive/hive_mime_storage.dart @@ -14,7 +14,8 @@ part 'hive_mime_storage.g.dart'; /// 1) list of SequenceId-UID-GUID elements - to be loaded when mailbox is /// opened, possibly along with envelope data of first page to speed up /// loading -/// 2) possibly envelope data by GUID (contains flags, subject, senders, recipients, date, has-attachment, possibly message preview) +/// 2) possibly envelope data by GUID (contains flags, subject, senders, +/// recipients, date, has-attachment, possibly message preview) /// 3) downloaded message data by GUID - this may not (yet) contain attachments /// /// new message: @@ -144,8 +145,8 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { if (guid != null) { final existingMessageId = allMessageIds.firstWhereOrNull((id) => id.guid == guid); - final sequenceId = message.sequenceId!; - final uid = message.uid!; + final sequenceId = message.sequenceId ?? 0; + final uid = message.uid ?? 0; if (existingMessageId == null) { addedMessageIds++; final messageId = @@ -338,6 +339,7 @@ class StorageMessageEnvelope { flags: message.flags, ); } + return StorageMessageEnvelope( uid: uid, guid: guid, diff --git a/lib/models/mail_operation.dart b/lib/models/mail_operation.dart index 91845b0..17892bd 100644 --- a/lib/models/mail_operation.dart +++ b/lib/models/mail_operation.dart @@ -85,14 +85,15 @@ class _QueuedMailOperation { operation = StoreFlagsOperation.fromJson(data); break; // case MailOperationType.moveToFlag: - // // TODO: Handle this case. + // TODO(RV): Handle this case. // break; // case MailOperationType.moveToFolder: - // // TODO: Handle this case. + // TODO(RV): Handle this case. // break; default: throw FormatException('Unsupported type $type'); } + return _QueuedMailOperation(operation, email); } diff --git a/lib/models/message.dart b/lib/models/message.dart index 61f5d25..8cf859a 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -128,7 +128,9 @@ class Message extends ChangeNotifier { bool get hasAttachment { final mime = mimeMessage; final size = mime.size; - // when only the envelope is downloaded, the content-type header ergo mediaType is not yet available + // when only the envelope is downloaded, the content-type header ergo + // mediaType is not yet available + return mime.hasAttachments() || (mime.mimeData == null && mime.body == null && @@ -172,7 +174,8 @@ extension NewsLetter on MimeMessage { String? decodeListName() { final listPost = decodeHeaderValue('list-post'); if (listPost != null) { - // typically only mailing lists that allow posting have a human understandable List-ID header: + // typically only mailing lists that allow posting have a + // human understandable List-ID header: final id = decodeHeaderValue('list-id'); if (id != null && id.isNotEmpty) { return id; @@ -189,6 +192,7 @@ extension NewsLetter on MimeMessage { if (sender.isNotEmpty) { return sender.first.toString(); } + return null; } diff --git a/lib/models/message_source.dart b/lib/models/message_source.dart index fbf95f5..52efdec 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -90,7 +90,8 @@ abstract class MessageSource extends ChangeNotifier /// Only available when [supportsDeleteAll] is `true` Future> deleteAllMessages({bool expunge = false}); - /// Marks all messages as seen (read) `true` or unseen (unread) when `false` is given + /// Marks all messages as seen (read) `true` or unseen (unread) + /// when `false` is given /// /// Only available when [supportsDeleteAll] is `true` Future markAllMessagesSeen(bool seen); @@ -546,19 +547,6 @@ abstract class MessageSource extends ChangeNotifier return mimesBySource; } - // Map orderByClient(List messages) { - // final sequenceByClient = {}; - // for (final msg in messages) { - // final client = msg!.mailClient; - // if (sequenceByClient.containsKey(client)) { - // sequenceByClient[client]!.addMessage(msg.mimeMessage); - // } else { - // sequenceByClient[client] = MessageSequence.fromMessage(msg.mimeMessage); - // } - // } - // return sequenceByClient; - // } - Future storeMessageFlags( List messages, List flags, { @@ -936,8 +924,6 @@ class MultipleMessageSource extends MessageSource { return _UnifiedMessage(mime, this, index, id.source); } - // print( - // 'get uncached $index with lastUncachedIndex=$_lastUncachedIndex and size $size'); int diff = index - _indicesCache.length; while (diff > 0) { final sourceIndex = index - diff; @@ -1115,22 +1101,6 @@ class MultipleMessageSource extends MessageSource { multipleSource.clear(); } } - - // @override - // Future loadSingleMessage(MailNotificationPayload payload) async { - // final mimeSource = mimeSources.firstWhereOrNull( - // (source) => source.mailClient.account.email == payload.accountEmail, - // ); - // if (mimeSource == null) { - // throw Exception('Unable to find mime source for ${payload.accountEmail}'); - // } - // final payloadMime = MimeMessage() - // ..sequenceId = payload.sequenceId - // ..uid = payload.uid; - // final mime = await mimeSource.fetchMessageContents(payloadMime); - - // return createMessage(mime, mimeSource, 0); - // } } class _UnifiedMessage extends Message { @@ -1157,11 +1127,8 @@ class _MultipleMimeSource { int _currentIndex = 0; _MultipleMimeSourceMessage? _currentMessage; - Future<_MultipleMimeSourceMessage?> peek() async { - _currentMessage ??= await _next(); - - return _currentMessage; - } + Future<_MultipleMimeSourceMessage?> peek() async => + _currentMessage ??= await _next(); void pop() { _currentMessage = null; diff --git a/lib/notification/service.dart b/lib/notification/service.dart index ff7aa7a..b49ee62 100644 --- a/lib/notification/service.dart +++ b/lib/notification/service.dart @@ -61,7 +61,9 @@ class NotificationService { // print( // 'got notification launched details: $launchDetails // with payload ${response.payload}'); - _selectNotification(response, context: context); + if (context != null && context.mounted) { + _selectNotification(response, context: context); + } return NotificationServiceInitResult.appLaunchedByNotification; } diff --git a/lib/screens/compose_screen.dart b/lib/screens/compose_screen.dart index 65058b3..09a4c7c 100644 --- a/lib/screens/compose_screen.dart +++ b/lib/screens/compose_screen.dart @@ -177,9 +177,10 @@ class _ComposeScreenState extends ConsumerState { final currentAccount = ref.read(currentRealAccountProvider)!; _realAccount = currentAccount; final defaultSender = ref.read(settingsProvider).defaultSender; - mb.from ??= [defaultSender ?? currentAccount.fromAddress]; + final mbFrom = mb.from ?? [defaultSender ?? currentAccount.fromAddress]; + mb.from ??= mbFrom; Sender? from; - if (mb.from?.first == defaultSender) { + if (mbFrom.first == defaultSender) { from = _senders .firstWhereOrNull((sender) => sender.address == defaultSender); } else { @@ -189,7 +190,7 @@ class _ComposeScreenState extends ConsumerState { ); } if (from == null) { - from = Sender(mb.from!.first, currentAccount); + from = Sender(mbFrom.first, currentAccount); _senders = [from, ..._senders]; } _from = from; diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index b164053..50124ef 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -265,7 +265,7 @@ class _MessageSourceScreenState extends ConsumerState ? CupertinoStatusBar( info: CupertinoStatusBar.createInfo(source.description), rightAction: PlatformIconButton( - // TODO(RV): use CupertinoIcons.create once it's not buggy anymore + // TODO(RV): use CupertinoIcons.create once available icon: const Icon(CupertinoIcons.pen), onPressed: () => context.pushNamed( Routes.mailCompose, @@ -383,19 +383,6 @@ class _MessageSourceScreenState extends ConsumerState SliverToBoxAdapter( child: zeroPosWidget, ), - - // TODO(RV): Plan: use individual slivers for each section - // and use SliverVariedExtentList() for the messages, - // SliverVariedExtentList( - // itemExtentBuilder: (index, dimension) { - // final element = - // _sectionedMessageSource.getCachedElementAt(index); - // if (element == null || element.section == null) { - // return 48; - // } - - // return 52; - // }, SliverFixedExtentList.builder( itemExtent: 52, itemBuilder: (context, index) => @@ -495,8 +482,6 @@ class _MessageSourceScreenState extends ConsumerState if (message == null) { return const SizedBox.shrink(); } - // print( - // '$index subject=${message.mimeMessage?.decodeSubject()}'); return Dismissible( key: ValueKey(message), @@ -729,7 +714,8 @@ class _MessageSourceScreenState extends ConsumerState child: IconText( icon: Icon(iconService.messageIsNotFlagged), label: Text( - localizations.messageActionMultipleMarkUnflagged), + localizations.messageActionMultipleMarkUnflagged, + ), ), ), if (source.supportsMessageFolders) ...[ @@ -1296,9 +1282,13 @@ enum _MultipleChoice { class MessageOverview extends StatefulWidget { MessageOverview( - this.message, this.isInSelectionMode, this.onTap, this.onLongPress, - {this.animationController, required this.isSentMessage}) - : super(key: ValueKey(message.sourceIndex)); + this.message, + this.isInSelectionMode, + this.onTap, + this.onLongPress, { + this.animationController, + required this.isSentMessage, + }) : super(key: ValueKey(message.sourceIndex)); final Message message; final bool isInSelectionMode; final void Function(Message message) onTap; diff --git a/lib/util/gravatar.dart b/lib/util/gravatar.dart index 39c25a8..0ba7ab7 100644 --- a/lib/util/gravatar.dart +++ b/lib/util/gravatar.dart @@ -1,4 +1,5 @@ import 'dart:convert'; + import 'package:crypto/crypto.dart'; enum GravatarImage { @@ -37,13 +38,16 @@ class Gravatar { if (rating != null) query['r'] = _ratingString(rating); if (fileExtension) hashDigest += '.png'; - return Uri.https('www.gravatar.com', '/avatar/$hashDigest', - query.isEmpty ? null : query) - .toString(); + return Uri.https( + 'www.gravatar.com', + '/avatar/$hashDigest', + query.isEmpty ? null : query, + ).toString(); } static String _generateHash(String email) { final preparedEmail = email.trim().toLowerCase(); + return md5.convert(utf8.encode(preparedEmail)).toString(); } @@ -67,6 +71,7 @@ class Gravatar { return 'mp'; case GravatarImage.identicon: return 'identicon'; + // cSpell: ignore monsterid, wavatar, robohash case GravatarImage.monsterid: return 'monsterid'; case GravatarImage.wavatar: diff --git a/lib/util/localized_dialog_helper.dart b/lib/util/localized_dialog_helper.dart index 102f581..95c9364 100644 --- a/lib/util/localized_dialog_helper.dart +++ b/lib/util/localized_dialog_helper.dart @@ -41,7 +41,8 @@ class LocalizedDialogHelper { child: Text(localizations.feedbackActionHelpDeveloping), onPressed: () async { await launcher.launchUrl(Uri.parse( - 'https://github.com/Enough-Software/enough_mail_app')); + 'https://github.com/Enough-Software/enough_mail_app', + )); }, ), const Legalese(), @@ -53,7 +54,8 @@ class LocalizedDialogHelper { /// Asks the user for confirmation with the given [title] and [query]. /// /// Specify the [action] in case it's different from the title. - /// Set [isDangerousAction] to `true` for marking the action as dangerous on Cupertino + /// Set [isDangerousAction] to `true` for marking the action as + /// dangerous on Cupertino static Future askForConfirmation( BuildContext context, { required String title, diff --git a/lib/util/string_helper.dart b/lib/util/string_helper.dart index 8d1fdd9..b264aca 100644 --- a/lib/util/string_helper.dart +++ b/lib/util/string_helper.dart @@ -28,7 +28,8 @@ class StringHelper { // print('lcs of "$first" and "$second"'); // problem: the longest sequence between first and second is not //necessarily the longest sequence between all - String shorter, longer; + String shorter; + String longer; if (first.length <= second.length) { shorter = first; longer = second; diff --git a/lib/util/validator.dart b/lib/util/validator.dart index 87bb314..933cb21 100644 --- a/lib/util/validator.dart +++ b/lib/util/validator.dart @@ -5,6 +5,7 @@ class Validator { } final atIndex = value.lastIndexOf('@'); final dotIndex = value.lastIndexOf('.'); + return atIndex > 0 && dotIndex > atIndex && dotIndex < value.length - 2; } } diff --git a/lib/widgets/attachment_chip.dart b/lib/widgets/attachment_chip.dart index c7fc4cb..b008982 100644 --- a/lib/widgets/attachment_chip.dart +++ b/lib/widgets/attachment_chip.dart @@ -276,7 +276,9 @@ class _AttachmentChipState extends State { } Widget? _buildInteractiveMedia( - BuildContext context, MediaProvider mediaProvider) { + BuildContext context, + MediaProvider mediaProvider, + ) { if (mediaProvider.mediaType == 'text/calendar' || mediaProvider.mediaType == 'application/ics') { return IcalInteractiveMedia( @@ -284,6 +286,7 @@ class _AttachmentChipState extends State { message: widget.message, ); } + return null; } } diff --git a/lib/widgets/attachment_compose_bar.dart b/lib/widgets/attachment_compose_bar.dart index 7e46eb3..596c1f2 100644 --- a/lib/widgets/attachment_compose_bar.dart +++ b/lib/widgets/attachment_compose_bar.dart @@ -306,8 +306,8 @@ class AddAttachmentPopupButton extends ConsumerWidget { final appointment = await IcalComposer.createOrEditAppointment(context, ref); if (appointment != null) { - // idea: add some sort of finalizer that updates the appointment at the end - // to set the organizer and the attendees + // idea: add some sort of finalizer that updates the appointment + // at the end to set the organizer and the attendees final text = appointment.toString(); final attachmentBuilder = composeData.messageBuilder.addText( text, diff --git a/lib/widgets/expansion_wrap.dart b/lib/widgets/expansion_wrap.dart index 5556b8c..ae58b44 100644 --- a/lib/widgets/expansion_wrap.dart +++ b/lib/widgets/expansion_wrap.dart @@ -244,6 +244,22 @@ class _WrapParentData extends BoxParentData { bool _isVisible = true; } +extension _WrapParentDataExtension on ParentData? { + set isVisible(bool value) { + final data = this; + if (data is _WrapParentData) { + data._isVisible = value; + } + } + + _WrapParentData toWrapParentData() { + final data = this; + if (data is _WrapParentData) return data; + + throw Exception('ParentData $data is not a _WrapParentData'); + } +} + /// Renders the children in a wrap layout and adds an indicator at the end if /// the children do not fit in the available space. class RenderExpansionWrap extends RenderBox { @@ -478,11 +494,8 @@ class RenderExpansionWrap extends RenderBox { // https://material.io/design/components/lists.html#specs @override void performLayout() { - // print('performLayout'); final BoxConstraints constraints = this.constraints; - final BoxConstraints looseConstraints = constraints.loosen(); - final double availableWidth = looseConstraints.maxWidth; final children = _wrapChildren; final expanded = _isExpanded; @@ -490,10 +503,10 @@ class RenderExpansionWrap extends RenderBox { final compressIndicator = _compressIndicator; if (expanded) { if (expandIndicator != null) { - (expandIndicator.parentData! as _WrapParentData)._isVisible = false; + expandIndicator.parentData.isVisible = false; } } else if (compressIndicator != null) { - (compressIndicator.parentData! as _WrapParentData)._isVisible = false; + compressIndicator.parentData.isVisible = false; } final spacing = _spacing; final runSpacing = _runSpacing; @@ -503,7 +516,6 @@ class RenderExpansionWrap extends RenderBox { final indicator = expanded ? compressIndicator : expandIndicator; final indicatorSize = expanded ? compressIndicatorSize : expandIndicatorSize; - final indicatorWith = indicatorSize.width; final originalMaxRuns = _maxRuns ?? double.maxFinite.floor(); final maxRuns = expanded ? double.maxFinite.floor() : originalMaxRuns; @@ -520,7 +532,7 @@ class RenderExpansionWrap extends RenderBox { for (var i = 0; i <= lastChildIndex; i++) { final child = children[i]; final childSize = _layoutBox(child, looseConstraints); - final parentData = child.parentData! as _WrapParentData + final parentData = child.parentData.toWrapParentData() .._isVisible = currentRun <= maxRuns; if (currentRunNumberOfChildren > 0 && ((currentRunWidth + childSize.width > availableWidth) || @@ -548,7 +560,7 @@ class RenderExpansionWrap extends RenderBox { if (indicator != null) { // this is the last visible run, add indicator: final indicatorParentData = - indicator.parentData! as _WrapParentData.._isVisible = true; + indicator.parentData.toWrapParentData().._isVisible = true; final dx = _indicatorPosition == ExpansionWrapIndicatorPosition.border ? availableWidth - indicatorWith @@ -580,7 +592,7 @@ class RenderExpansionWrap extends RenderBox { } if (expanded && currentRun >= originalMaxRuns && indicator != null) { // add compress indicator at the end: - final indicatorParentData = indicator.parentData! as _WrapParentData + final indicatorParentData = indicator.parentData.toWrapParentData() .._isVisible = true; final dx = _indicatorPosition == ExpansionWrapIndicatorPosition.border ? availableWidth - indicatorWith @@ -591,8 +603,7 @@ class RenderExpansionWrap extends RenderBox { ); } if (!expanded && currentRun <= originalMaxRuns && indicator != null) { - final indicatorParentData = indicator.parentData! as _WrapParentData - .._isVisible = false; + indicator.parentData.isVisible = false; } size = crossAxisMaxInCompressedState != null ? constraints diff --git a/lib/widgets/ical_interactive_media.dart b/lib/widgets/ical_interactive_media.dart index e947a23..1a0afbb 100644 --- a/lib/widgets/ical_interactive_media.dart +++ b/lib/widgets/ical_interactive_media.dart @@ -494,7 +494,8 @@ class _IcalInteractiveMediaState extends State { } extension ExtensionParticipantStatusTextStyle on ParticipantStatus { - // static const TextStyle _styleAccepted = const TextStyle(color: Colors.green); + // static const TextStyle _styleAccepted = + // const TextStyle(color: Colors.green); static const TextStyle _styleDeclined = TextStyle(color: Colors.red, decorationStyle: TextDecorationStyle.dashed); static const TextStyle _styleTentative = diff --git a/lib/widgets/icon_text.dart b/lib/widgets/icon_text.dart index 58381cf..755b8b9 100644 --- a/lib/widgets/icon_text.dart +++ b/lib/widgets/icon_text.dart @@ -27,17 +27,13 @@ class IconText extends StatelessWidget { padding: horizontalPadding, child: label, ), - ) + ), ], ), ); - if (brightness != null) { - return Theme( - data: ThemeData(brightness: brightness), - child: content, - ); - } else { - return content; - } + + return brightness != null + ? Theme(data: ThemeData(brightness: brightness), child: content) + : content; } } diff --git a/lib/widgets/legalese.dart b/lib/widgets/legalese.dart index 2fb5ae1..7af3076 100644 --- a/lib/widgets/legalese.dart +++ b/lib/widgets/legalese.dart @@ -25,6 +25,7 @@ class Legalese extends StatelessWidget { TextLink(termsAndConditions, urlTermsAndConditions), TextLink(legaleseUsage.substring(tcIndex + '[TC]'.length)), ]; + return TextWithNamedLinks( parts: legaleseParts, ); diff --git a/lib/widgets/message_stack.dart b/lib/widgets/message_stack.dart index 10ff3f2..8a2935b 100644 --- a/lib/widgets/message_stack.dart +++ b/lib/widgets/message_stack.dart @@ -341,6 +341,7 @@ class _MessageDragTargetState extends State<_MessageDragTarget> { ), onWillAccept: (data) { startAccepting(); + return true; }, onAccept: (data) async { @@ -552,7 +553,7 @@ class _MessageCardState extends State { // }, // onPageFinished: (url) { // print('finished loading page'); - // // TODO(RV): inject JS to query size? + // TODO(RV): inject JS to query size? // }, // ); // } diff --git a/test/model/async_mime_source_test.dart b/test/model/async_mime_source_test.dart index 8439782..e00aa86 100644 --- a/test/model/async_mime_source_test.dart +++ b/test/model/async_mime_source_test.dart @@ -29,8 +29,8 @@ void main() async { await source.getMessage(0); final message = source.cache[0]; expect(message, isNotNull); - expect(message!.sequenceId, 101); - expect(message.decodeSubject(), 'Subject 101'); + expect(message?.sequenceId, 101); + expect(message?.decodeSubject(), 'Subject 101'); }); test('load second message size 101', () async { @@ -54,7 +54,7 @@ void main() async { for (int i = 0; i < 101; i += 15) { final message = cache[i]; expect(message, isNotNull); - expect(message!.sequenceId, 101 - i); + expect(message?.sequenceId, 101 - i); } }); @@ -131,7 +131,8 @@ void main() async { final firstMessage = await source.getMessage(0); final newMessage = source.createMessage(101); final oldDate = - firstMessage.decodeDate()!.subtract(const Duration(seconds: 30)); + firstMessage.decodeDate()?.subtract(const Duration(seconds: 30)) ?? + DateTime.now(); newMessage.setHeader( MailConventions.headerDate, DateCodec.encodeDate(oldDate), @@ -161,7 +162,7 @@ void main() async { final length = numberToTest ?? source.size; for (int i = 0; i < length; i++) { final message = await source.getMessage(i); - final messageDate = message.decodeDate(); + final messageDate = message.decodeDate() ?? DateTime.now(); final subject = message.decodeSubject() ?? ''; expect( messageDate, @@ -169,7 +170,7 @@ void main() async { reason: 'no date for message at index $i $subject', ); expect( - messageDate!.isBefore(lastDate), + messageDate.isBefore(lastDate), isTrue, reason: 'wrong date for message at $i: $messageDate of "$subject" should be before $lastDate of "$lastSubject"', @@ -184,10 +185,14 @@ void main() async { expect(source.size, 100); final firstMessage = source.messages[0]; final oldDate = firstMessage - .decodeDate()! - .subtract(const Duration(days: 120, seconds: 30)); - source.messages[97] - .setHeader(MailConventions.headerDate, DateCodec.encodeDate(oldDate)); + .decodeDate() + ?.subtract(const Duration(days: 120, seconds: 30)); + source.messages[97].setHeader( + MailConventions.headerDate, + DateCodec.encodeDate( + oldDate ?? DateTime.now(), + ), + ); // first page should be sorted: await expectMessagesOrderedByDate(source, numberToTest: 20); }); @@ -473,7 +478,8 @@ void main() async { final message = await source.getMessage(i); messages.add(message); } - final copy = source.createMessage(messages[1].sequenceId!)..isSeen = true; + final copy = source.createMessage(messages[1].sequenceId ?? -1) + ..isSeen = true; messages[1] = copy; await source.resyncMessagesManually(messages); expect(source.size, 100); @@ -498,7 +504,7 @@ void main() async { } messages.add(message); } - final copy = source.createMessage(messages[1].sequenceId!); + final copy = source.createMessage(messages[1].sequenceId ?? -1); messages[1] = copy; await source.resyncMessagesManually(messages); expect(source.size, 100); @@ -521,7 +527,8 @@ void main() async { messages.add(message); } - final copy = source.createMessage(messages[1].sequenceId!)..isSeen = true; + final copy = source.createMessage(messages[1].sequenceId ?? -1) + ..isSeen = true; messages[1] = copy; messages.removeAt(2); final newMessage = source.createMessage(101); diff --git a/test/model/fake_mime_source.dart b/test/model/fake_mime_source.dart index 722d223..fd726a1 100644 --- a/test/model/fake_mime_source.dart +++ b/test/model/fake_mime_source.dart @@ -102,9 +102,9 @@ class FakeMimeSource extends PagedCachedMimeSource { @override Future deleteMessages(List messages) { - messages.sort((a, b) => b.sequenceId!.compareTo(a.sequenceId!)); + messages.sort((a, b) => (b.sequenceId ?? 0).compareTo(a.sequenceId ?? 0)); for (final message in messages) { - final sequenceId = message.sequenceId!; + final sequenceId = message.sequenceId ?? -1; this.messages.removeAt(sequenceId - 1); for (var i = sequenceId - 1; i < this.messages.length; i++) { this.messages[i].sequenceId = i + 1; diff --git a/test/model/multiple_message_source_test.dart b/test/model/multiple_message_source_test.dart index 3459eee..3976d3b 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -71,12 +71,12 @@ void main() async { reason: 'no date for message at index $i $subject', ); expect( - messageDate!.isBefore(lastDate), + messageDate?.isBefore(lastDate), isTrue, reason: 'wrong date for message at $i: $messageDate of "$subject" should be before $lastDate of "$lastSubject"', ); - lastDate = messageDate; + lastDate = messageDate ?? DateTime.now(); lastSubject = subject; } } @@ -499,7 +499,7 @@ void main() async { }); final updatedMime = (secondMimeSource as FakeMimeSource) - .createMessage(firstMime.sequenceId!) + .createMessage(firstMime.sequenceId ?? 0) ..setFlag(MessageFlags.seen, true); await secondMimeSource.onMessageFlagsUpdated(updatedMime); expect(notifyCounter, 1); @@ -520,7 +520,7 @@ void main() async { }); final updatedMime = (secondMimeSource as FakeMimeSource) - .createMessage(firstMime.sequenceId!) + .createMessage(firstMime.sequenceId ?? 0) ..setFlag(MessageFlags.seen, false); await secondMimeSource.onMessageFlagsUpdated(updatedMime); expect(notifyCounter, 1); @@ -680,7 +680,7 @@ void main() async { messages.add(message); } final copy = (firstMimeSource as FakeMimeSource) - .createMessage(messages[1].sequenceId!) + .createMessage(messages[1].sequenceId ?? 0) ..isSeen = true; messages[1] = copy; @@ -717,7 +717,7 @@ void main() async { } messages[1].isSeen = true; final copy = (firstMimeSource as FakeMimeSource) - .createMessage(messages[1].sequenceId!); + .createMessage(messages[1].sequenceId ?? 0); messages[1] = copy; var message = await source.getMessageAt(2); @@ -1318,13 +1318,6 @@ class TestNotificationService implements NotificationService { throw UnimplementedError(); } - @override - Future _sendLocalNotification(int id, String title, String? text) { - _sendNotifications++; - - return Future.value(); - } - @override Future sendLocalNotificationForMail( MimeMessage mimeMessage,