diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json new file mode 100644 index 0000000..2c8cf02 --- /dev/null +++ b/.fvm/fvm_config.json @@ -0,0 +1,4 @@ +{ + "flutterSdkVersion": "3.16.4", + "flavors": {} +} \ No newline at end of file diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml new file mode 100644 index 0000000..4928f9e --- /dev/null +++ b/.github/workflows/flutter.yml @@ -0,0 +1,51 @@ + +name: CI Workflow +on: + push: + branches: [main, rewrite] + pull_request: + branches: [main] +jobs: + android: + name: Build Android + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Decode Keystore and Create key.properties + env: + PROPERTIES_PATH: ${{ github.workspace }}/android/key.properties + STORE_PATH: ${{ github.workspace }}/android/keystore.jks + run: | + 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 "${{ secrets.PLAY_UPLOAD_KEYSTORE }}" | base64 --decode > ${{env.STORE_PATH}} + - uses: actions/setup-java@v2 + with: + distribution: 'zulu' + java-version: '17' + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + - run: flutter --version + - run: flutter pub get + #- run: flutter test + - run: flutter build apk --release --dart-define GIPHY_API_KEY=${{ secrets.GIPHY_API_KEY }} --dart-define OAUTH_GMAIL=${{ secrets.OAUTH_GMAIL }} --dart-define OAUTH_OUTLOOK=${{ secrets.OAUTH_OUTLOOK }} + + apple: + name: Build iOS + runs-on: macos-13 # required for xcode 15, macos-latest does not yet support it, compare https://github.com/maxim-lobanov/setup-xcode/issues/73 + steps: + - uses: actions/checkout@v3 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.1' + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + #architecture: x64 + - run: flutter --version + - run: flutter pub get + #- run: flutter test + - run: flutter build ios --release --no-codesign diff --git a/.gitignore b/.gitignore index c0d4923..02d1790 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,7 @@ assets/keys.txt .pub-cache/ .pub/ /build/ -pubspec.lock +.fvm/flutter_sdk # Web related lib/generated_plugin_registrant.dart diff --git a/.vscode/launch.json b/.vscode/launch.json index 8bd83ab..3756278 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,15 @@ { "name": "Maily", "request": "launch", - "type": "dart" + "type": "dart", + "args": [ + "--dart-define", + "GIPHY_API_KEY=${env:GIPHY_API_KEY}", + "--dart-define", + "OAUTH_GMAIL=${env:OAUTH_GMAIL}", + "--dart-define", + "OAUTH_OUTLOOK=${env:OAUTH_OUTLOOK}", + ], }, { "name": "Maily (profile mode)", diff --git a/.vscode/settings.json b/.vscode/settings.json index 0337d9a..e7687ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,13 +1,37 @@ { + "dart.flutterSdkPath": ".fvm/flutter_sdk", + // Remove .fvm files from search + "search.exclude": { + "**/.fvm": true + }, + // Remove from file watching + "files.watcherExclude": { + "**/.fvm": true + }, "cSpell.words": [ - "autofocus", - "Cupertino", - "Ical", - "icalendar", - "Imap", - "LTRB", - "Maily", - "riverpod", - "unawaited" + "autocorrect", + "autofocus", + "Cupertino", + "datetime", + "finalizer", + "finalizers", + "fluttercontactpicker", + "giphy", + "hoster", + "hosters", + "Ical", + "icalendar", + "Imap", + "lerp", + "LTRB", + "Maily", + "mocktail", + "QRESYNC", + "redepth", + "riverpod", + "Toptype", + "uids", + "unawaited", + "unfocus" ] } \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 18e0a91..d3101ad 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -170,7 +170,7 @@ linter: - prefer_typing_uninitialized_variables - prefer_void_to_null - provide_deprecation_message - - public_member_api_docs + #- public_member_api_docs - recursive_getters - sized_box_for_whitespace - slash_for_doc_comments diff --git a/android/app/build.gradle b/android/app/build.gradle index 760f2ae..a96ad41 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -26,75 +26,69 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" def keystorePropertiesFile = rootProject.file('key.properties') +def keystoreProperties = new Properties() + if (keystorePropertiesFile.exists()) { - def keystoreProperties = new Properties() - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) - - android { - compileSdkVersion rootProject.ext.compileSdkVersion - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } + keystorePropertiesFile.withReader('UTF-8') { reader -> + keystoreProperties.load(reader) + } +} - lintOptions { - disable 'InvalidPackage' - } +android { + defaultConfig { + multiDexEnabled true + } - defaultConfig { - applicationId "de.enough.enough_mail_app" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } + 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 + } - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile file(keystoreProperties['storeFile']) - storePassword keystoreProperties['storePassword'] - } - } + compileSdkVersion rootProject.ext.compileSdkVersion - buildTypes { - release { - signingConfig signingConfigs.release - } - } + sourceSets { + main.java.srcDirs += 'src/main/kotlin' } -} else { - System.out.println("warning: android/key.properties not found, now using debug key.") - android { - compileSdkVersion rootProject.ext.compileSdkVersion - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - lintOptions { - disable 'InvalidPackage' - } + defaultConfig { + applicationId "de.enough.enough_mail_app" + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } - defaultConfig { - applicationId "de.enough.enough_mail_app" - minSdkVersion rootProject.ext.minSdkVersion - targetSdkVersion rootProject.ext.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] } + } - buildTypes { - release { - signingConfig signingConfigs.debug + buildTypes { + release { + if (keystorePropertiesFile.exists()) { + signingConfig signingConfigs.release + } else { + System.err.println("WARNING: android/key.properties not found, now using debug key.") + signingConfig signingConfigs.debug } } } + namespace 'de.enough.enough_mail_app' + lint { + disable 'InvalidPackage' + } } + flutter { source '../..' } @@ -103,4 +97,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/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 52323bf..f880684 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,4 @@ - + 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/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..b88353c Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ 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 0000000..b88353c Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ 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 0000000..8488423 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ 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 0000000..8488423 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ 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 0000000..b88353c Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ 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 0000000..8488423 Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ 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 0000000..06d77bb Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..1434356 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..5440b0e Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..4e3ec6e Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + 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 0000000..06d77bb Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..06d77bb Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..1434356 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..1434356 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..5440b0e Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..5440b0e Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..4e3ec6e Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 5d56ddf..3cc4948 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,9 @@ - - - - - + + + + + + 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/build.gradle b/android/build.gradle index c40a8a0..98061a2 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() @@ -12,7 +12,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/enough_mail_app.code-workspace b/enough_mail_app.code-workspace new file mode 100644 index 0000000..2f9f1d9 --- /dev/null +++ b/enough_mail_app.code-workspace @@ -0,0 +1,24 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "../enough_mail" + }, + { + "path": "../enough_mail_discovery" + }, + { + "path": "../enough_mail_flutter" + }, + { + "path": "../enough_mail_html" + } + ], + "settings": { + "cSpell.words": [ + "unflag" + ] + } +} \ No newline at end of file diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml new file mode 100644 index 0000000..a5e168a --- /dev/null +++ b/flutter_native_splash.yaml @@ -0,0 +1,91 @@ +flutter_native_splash: + + # This package generates native code to customize Flutter's default white native splash screen + # with background color and splash image. + # Customize the parameters below, and run the following command in the terminal: + # dart run flutter_native_splash:create + # To restore Flutter's default white splash screen, run the following command in the terminal: + # dart run flutter_native_splash:remove + + # color or background_image is the only required parameter. Use color to set the background + # of your splash screen to a solid color. Use background_image to set the background of your + # splash screen to a png image. This is useful for gradients. The image will be stretch to the + # size of the app. Only one parameter can be used, color and background_image cannot both be set. + color: "#99cc00" + # background_image: assets/background.png + + # Optional parameters are listed below. To enable a parameter, uncomment the line by removing + # the leading # character. + + # The image parameter allows you to specify an image used in the splash screen. It must be a + # png file and should be sized for 4x pixel density. + image: store/logo_padded.png + + # This property allows you to specify an image used as branding in the splash screen. It must be + # a png file. Currently, it is only supported for Android and iOS. + #branding: store/branding.png + + # Specify your branding image for dark mode. + #branding_dark: store/branding_dark.png + + # To position the branding image at the bottom of the screen you can use bottom, bottomRight, + # and bottomLeft. The default values is bottom if not specified or specified something else. + # + # Make sure this content mode value should not be similar to android_gravity value and ios_content_mode + # value. + branding_mode: bottom + + android_12: + image: store/logo_padded.png + # icon_background_color: "#1984d3" + #image_dark: assets/source/logo.png + # icon_background_color_dark: "#121212" + color: "#99cc00" + + # The color_dark, background_image_dark, and image_dark are parameters that set the background + # and image when the device is in dark mode. If they are not specified, the app will use the + # parameters from above. If the image_dark parameter is specified, color_dark or + # background_image_dark must be specified. color_dark and background_image_dark cannot both be + # set. + #color_dark: "#042a49" + #background_image_dark: "assets/dark-background.png" + #image_dark: assets/splash-invert.png + + # The android, ios and web parameters can be used to disable generating a splash screen on a given + # platform. + #android: false + #ios: false + #web: false + + # The position of the splash image can be set with android_gravity, ios_content_mode, and + # web_image_mode parameters. All default to center. + # + # android_gravity can be one of the following Android Gravity (see + # https://developer.android.com/reference/android/view/Gravity): bottom, center, + # center_horizontal, center_vertical, clip_horizontal, clip_vertical, end, fill, fill_horizontal, + # fill_vertical, left, right, start, or top. + #android_gravity: center + # + # ios_content_mode can be one of the following iOS UIView.ContentMode (see + # https://developer.apple.com/documentation/uikit/uiview/contentmode): scaleToFill, + # scaleAspectFit, scaleAspectFill, center, top, bottom, left, right, topLeft, topRight, + # bottomLeft, or bottomRight. + #ios_content_mode: center + # + # web_image_mode can be one of the following modes: center, contain, stretch, and cover. + #web_image_mode: center + + # To hide the notification bar, use the fullscreen parameter. Has no affect in web since web + # has no notification bar. Defaults to false. + # NOTE: Unlike Android, iOS will not automatically show the notification bar when the app loads. + # To show the notification bar, add the following code to your Flutter app: + # WidgetsFlutterBinding.ensureInitialized(); + # SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]); + fullscreen: false + + # If you have changed the name(s) of your info.plist file(s), you can specify the filename(s) + # with the info_plist_files parameter. Remove only the # characters in the three lines below, + # do not remove any spaces: + #info_plist_files: + # - 'ios/Runner/Info.plist' + # - 'ios/Runner/Info-Release.plist' \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5d4dcf0..266c474 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,7 +1,7 @@ PODS: - add_2_calendar (0.0.1): - Flutter - - background_fetch (1.1.5): + - background_fetch (1.2.1): - Flutter - device_info_plus (0.0.1): - Flutter @@ -40,17 +40,19 @@ PODS: - DKImagePickerController/PhotoGallery - Flutter - Flutter (1.0.0) - - flutter_inappwebview (0.0.1): + - flutter_inappwebview_ios (0.0.1): - Flutter - - flutter_inappwebview/Core (= 0.0.1) + - flutter_inappwebview_ios/Core (= 0.0.1) - OrderedSet (~> 5.0) - - flutter_inappwebview/Core (0.0.1): + - flutter_inappwebview_ios/Core (0.0.1): - Flutter - OrderedSet (~> 5.0) - flutter_keyboard_visibility (0.0.1): - Flutter - flutter_local_notifications (0.0.1): - Flutter + - flutter_native_splash (0.0.1): + - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_web_auth (0.5.0): @@ -82,7 +84,7 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.2): + - sqflite (0.0.3): - Flutter - FMDB (>= 2.7.5) - SwiftyGif (5.4.3) @@ -90,7 +92,8 @@ PODS: - Flutter - video_player_avfoundation (0.0.1): - Flutter - - wakelock (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): - Flutter - webview_flutter_wkwebview (0.0.1): - Flutter @@ -101,9 +104,10 @@ DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`) - fluttercontactpicker (from `.symlinks/plugins/fluttercontactpicker/ios`) @@ -111,14 +115,14 @@ DEPENDENCIES: - location (from `.symlinks/plugins/location/ios`) - open_settings (from `.symlinks/plugins/open_settings/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - pdfx (from `.symlinks/plugins/pdfx/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) - - wakelock (from `.symlinks/plugins/wakelock/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) SPEC REPOS: @@ -141,12 +145,14 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter - flutter_inappwebview: - :path: ".symlinks/plugins/flutter_inappwebview/ios" + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_web_auth: @@ -162,54 +168,55 @@ EXTERNAL SOURCES: package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/ios" + :path: ".symlinks/plugins/path_provider_foundation/darwin" pdfx: :path: ".symlinks/plugins/pdfx/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/ios" + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: :path: ".symlinks/plugins/sqflite/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/ios" - wakelock: - :path: ".symlinks/plugins/wakelock/ios" + :path: ".symlinks/plugins/video_player_avfoundation/darwin" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" SPEC CHECKSUMS: - add_2_calendar: e9d68636aed37fb18e12f5a3d74c2e0589487af0 - background_fetch: 9a9963128952bfdd197e21786983c7c7a30e1478 - device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed + add_2_calendar: 5eee66d5a3b99cd5e1487a7e03abd4e3ac4aff11 + background_fetch: 896944864b038d2837fc750d470e9841e1e6a363 + device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95 + file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - flutter_inappwebview: 4fe74e5e65809c3d363febfd9e2b21aa79bb0f1c + flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 + flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d fluttercontactpicker: d582836dea6b5d489f3d259f35d7817ae82ee5e6 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a local_auth_ios: c6cf091ded637a88f24f86a8875d8b0f526e2605 - location: 3a2eed4dd2fab25e7b7baf2a9efefe82b512d740 + location: d5cf8598915965547c3f36761ae9cc4f4e87d22e open_settings: 9628e736cb2738fd1dc84159565755c214478158 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e - path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 - pdfx: 1cf9d07304b44d47676e6c6c4e13707eff394847 + package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + pdfx: 7b876b09de8b7a0bf444a4f82b439ffcff4ee1ec SDWebImage: af5bbffef2cde09f148d826f9733dcde1a9414cd - share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 - shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472 - sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780 - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 - video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126 - wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f + url_launcher_ios: bf5ce03e0e2088bad9cc378ea97fa0ed5b49673b + video_player_avfoundation: e9e6f9cae7d7a6d9b43519b0aab382bca60fcfd1 + wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47 webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a PODFILE CHECKSUM: cc1f88378b4bfcf93a6ce00d2c587857c6008d3b diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f7d4c21..cc2e789 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -141,7 +141,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -190,6 +190,7 @@ files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a..a6b826d 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ - - + + - - + + + + + + + + @@ -32,6 +38,7 @@ - + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index dd428e8..93dd241 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,72 +1,74 @@ - - BGTaskSchedulerPermittedIdentifiers - - com.transistorsoft.fetch - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - Maily - CFBundleDisplayName - Maily - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - NSCalendarsUsageDescription - Allows you to add appointment invitations - NSLocationWhenInUseUsageDescription - Allows you to attach your location to a message - NSLocationAlwaysAndWhenInUseUsageDescription - Allows you to attach your location to a message - NSPhotoLibraryUsageDescription - Allows you to attach photos to a message - NSFaceIDUsageDescription - To help users protect mail access a Face ID check can be enabled. - UIBackgroundModes - - fetch - remote-notification - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - io.flutter.embedded_views_preview - yes - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - + + BGTaskSchedulerPermittedIdentifiers + + com.transistorsoft.fetch + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Maily + CFBundleDisplayName + Maily + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSCalendarsUsageDescription + Allows you to add appointment invitations + NSLocationWhenInUseUsageDescription + Allows you to attach your location to a message + NSLocationAlwaysAndWhenInUseUsageDescription + Allows you to attach your location to a message + NSPhotoLibraryUsageDescription + Allows you to attach photos to a message + NSFaceIDUsageDescription + To help users protect mail access a Face ID check can be enabled. + UIBackgroundModes + + fetch + remote-notification + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + io.flutter.embedded_views_preview + yes + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + UIStatusBarHidden + + 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/models/account.dart b/lib/account/model.dart similarity index 82% rename from lib/models/account.dart rename to lib/account/model.dart index 1800a1d..b30d0fe 100644 --- a/lib/models/account.dart +++ b/lib/account/model.dart @@ -2,13 +2,10 @@ 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 '../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 { @@ -17,11 +14,29 @@ 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; + /// 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 @@ -32,8 +47,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) => @@ -58,6 +72,16 @@ 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? + @JsonKey(includeToJson: false, includeFromJson: false) + bool hasError = false; + @override bool get isVirtual => false; @@ -72,8 +96,9 @@ class RealAccount extends Account { } /// Should this account be excluded from the unified account? + @JsonKey(includeToJson: false, includeFromJson: false) bool get excludeFromUnified => - _account.hasAttribute(attributeExcludeFromUnified); + getAttribute(attributeExcludeFromUnified) ?? false; set excludeFromUnified(bool value) => setAttribute(attributeExcludeFromUnified, value); @@ -83,7 +108,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) { @@ -100,15 +125,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; for (final ext in extensions) { - final signature = ext.getSignatureHtml(languageCode); + final signature = ext.getSignatureHtml(languageCode ?? 'en'); if (signature != null) { return signature; } @@ -119,6 +142,8 @@ 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); @@ -139,6 +164,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) { @@ -174,17 +200,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 @@ -205,49 +229,41 @@ 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, - 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, ); } /// 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 +271,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 83% rename from lib/models/account.g.dart rename to lib/account/model.g.dart index 43ec47c..e4d8247 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 @@ -11,11 +11,10 @@ RealAccount _$RealAccountFromJson(Map json) => RealAccount( appExtensions: (json['appExtensions'] as List?) ?.map((e) => AppExtension.fromJson(e as Map)) .toList(), - )..excludeFromUnified = json['excludeFromUnified'] as bool; + ); Map _$RealAccountToJson(RealAccount instance) => { 'mailAccount': instance.mailAccount, - 'excludeFromUnified': instance.excludeFromUnified, 'appExtensions': instance.appExtensions, }; diff --git a/lib/account/provider.dart b/lib/account/provider.dart new file mode 100644 index 0000000..91652b2 --- /dev/null +++ b/lib/account/provider.dart @@ -0,0 +1,192 @@ +import 'package:collection/collection.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../logger.dart'; +import '../models/sender.dart'; +import 'model.dart'; +import 'storage.dart'; + +part 'provider.g.dart'; + +/// 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(); + if (accounts.isNotEmpty) { + ref.read(currentAccountProvider.notifier).state = accounts.first; + } + state = accounts; + } + + /// Adds a new account + void addAccount(RealAccount account) { + final cleanState = state.toList()..removeWhere((a) => a.key == account.key); + state = [...cleanState, account]; + if (state.length == 1) { + ref.read(currentAccountProvider.notifier).state = account; + } + _saveAccounts(); + } + + /// Removes the given [account] + void removeAccount(RealAccount account) { + state = state.where((a) => a.key != account.key).toList(); + if (ref.read(currentAccountProvider) == account) { + final replacement = state.isEmpty ? null : state.first; + ref.read(currentAccountProvider.notifier).state = replacement; + } + _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 (ref.read(currentAccountProvider) == oldAccount) { + ref.read(currentAccountProvider.notifier).state = newAccount; + } + 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(); + + Future _saveAccounts() async { + await _storage.saveAccounts(state); + } +} + +/// 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) { + final allRealAccounts = ref.watch(realAccountsProvider); + final accounts = allRealAccounts.where((a) => !a.excludeFromUnified).toList(); + if (accounts.length <= 1) { + return null; + } + final account = UnifiedAccount(accounts); + final currentAccount = ref.read(currentAccountProvider); + Future.delayed(const Duration(milliseconds: 20)).then((_) { + if (currentAccount == null || currentAccount is RealAccount) { + ref.read(currentAccountProvider.notifier).state = account; + } + }); + + return account; +} + +/// Provides all accounts +@Riverpod(keepAlive: true) +class AllAccounts extends _$AllAccounts { + @override + List build() { + final realAccounts = ref.watch(realAccountsProvider); + final unifiedAccount = ref.watch(unifiedAccountProvider); + logger.d('Creating all accounts'); + + return [ + if (unifiedAccount != null) unifiedAccount, + ...realAccounts, + ]; + } +} + +//// 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); + + return realAccounts.firstWhereOrNull((a) => a.key == key) ?? + ((unifiedAccount?.key == key) ? unifiedAccount : null); +} + +//// 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.firstWhereOrNull((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); +} + +/// Provides the locally current active account +final currentAccountProvider = StateProvider((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 new file mode 100644 index 0000000..2bf6e7d --- /dev/null +++ b/lib/account/provider.g.dart @@ -0,0 +1,414 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$sendersHash() => r'7d45f5bd244bb17ed18983d9eac9a6170dfde855'; + +/// 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'5380f681599f9354b8ecd0cbda4c40dedd9de535'; + +/// Provides the unified account, if any +/// +/// 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 UnifiedAccountRef = ProviderRef; +String _$findAccountByEmailHash() => + r'692760656b2f9223f3ef929e040c413f2dd4c571'; + +/// 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'4fbe9680f101417c67bc9eebda553005f78d77c1'; + +//// 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 _$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'cf98cca42c7239746aea0af704cbf02a96108a7f'; + +/// 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/providers.dart b/lib/account/providers.dart deleted file mode 100644 index 9ce9523..0000000 --- a/lib/account/providers.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import '../models/account.dart'; - -part 'providers.g.dart'; - -/// Retrieves the list of real accounts -@Riverpod(keepAlive: true) -List realAccounts(RealAccountsRef ref) { - throw UnimplementedError(); -} - -/// Retrieves the current account -@riverpod -Raw currentAccount(CurrentAccountRef ref) { - throw UnimplementedError(); -} diff --git a/lib/account/providers.g.dart b/lib/account/providers.g.dart deleted file mode 100644 index f64d3da..0000000 --- a/lib/account/providers.g.dart +++ /dev/null @@ -1,43 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'providers.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$realAccountsHash() => r'8203d71da59bfb83b1ca5a4948b23f3b3d6c4428'; - -/// Retrieves the list of real accounts -/// -/// Copied from [realAccounts]. -@ProviderFor(realAccounts) -final realAccountsProvider = Provider>.internal( - realAccounts, - name: r'realAccountsProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') ? null : _$realAccountsHash, - dependencies: null, - allTransitiveDependencies: null, -); - -typedef RealAccountsRef = ProviderRef>; -String _$currentAccountHash() => r'0fb8b802c624f8d611d1f30b0871a9d34c6632cd'; - -/// Retrieves the current 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; -// 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/app_lifecycle/provider.dart b/lib/app_lifecycle/provider.dart index 4b6938d..0816b4f 100644 --- a/lib/app_lifecycle/provider.dart +++ b/lib/app_lifecycle/provider.dart @@ -1,6 +1,67 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; -/// Allows to retrieve the current app life cycle -final appLifecycleStateProvider = +import '../logger.dart'; + +part 'provider.g.dart'; + +/// Allows to retrieve the current (raw) app life cycle +final rawAppLifecycleStateProvider = StateProvider((ref) => AppLifecycleState.resumed); + +/// Allows to retrieve the current (filtered) app life cycle +@Riverpod(keepAlive: true) +class AppLifecycle extends _$AppLifecycle { + /// Should the next resume event be ignored? + var _ignoreNextInActivationCycle = false; + var _ignoreTimestamp = DateTime.now(); + var _ignoreDuration = const Duration(seconds: 30); + + @override + AppLifecycleState build() { + final state = ref.watch(rawAppLifecycleStateProvider); + if (_ignoreNextInActivationCycle) { + final difference = DateTime.now().difference(_ignoreTimestamp); + if (difference > _ignoreDuration) { + _ignoreNextInActivationCycle = false; + logger.d('too long pause for ignoring next inactivation cycle'); + + return state; + } + + if (state == AppLifecycleState.resumed) { + logger.d('ignored inactivation cycle completed'); + _ignoreNextInActivationCycle = false; + } + + return AppLifecycleState.resumed; + } + logger.d('emitting non-ignored state: $state'); + + return state; + } + + /// Ignores the next inactivation -> resume event + void ignoreNextInactivationCycle({ + Duration timeout = const Duration(seconds: 30), + }) { + _ignoreNextInActivationCycle = true; + _ignoreTimestamp = DateTime.now(); + _ignoreDuration = timeout; + } +} + +/// Easy access to be notified when the app is resumed +@Riverpod(keepAlive: true) +bool appIsResumed(AppIsResumedRef ref) => ref.watch( + appLifecycleProvider + .select((value) => value == AppLifecycleState.resumed), + ); + +/// Easy access to be notified when the app is put to the background +@Riverpod(keepAlive: true) +bool appIsInactivated(AppIsInactivatedRef ref) => ref.watch( + appLifecycleProvider + .select((value) => value == AppLifecycleState.inactive), + ); diff --git a/lib/app_lifecycle/provider.g.dart b/lib/app_lifecycle/provider.g.dart new file mode 100644 index 0000000..526470e --- /dev/null +++ b/lib/app_lifecycle/provider.g.dart @@ -0,0 +1,60 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$appIsResumedHash() => r'2b8853b672a6faf4f961d546241be25a23bb8ebe'; + +/// Easy access to be notified when the app is resumed +/// +/// Copied from [appIsResumed]. +@ProviderFor(appIsResumed) +final appIsResumedProvider = Provider.internal( + appIsResumed, + name: r'appIsResumedProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$appIsResumedHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AppIsResumedRef = ProviderRef; +String _$appIsInactivatedHash() => r'c13bdf5ad0eec6c95c5a85d2fe88ad84f9b3792f'; + +/// Easy access to be notified when the app is put to the background +/// +/// Copied from [appIsInactivated]. +@ProviderFor(appIsInactivated) +final appIsInactivatedProvider = Provider.internal( + appIsInactivated, + name: r'appIsInactivatedProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appIsInactivatedHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AppIsInactivatedRef = ProviderRef; +String _$appLifecycleHash() => r'1a695a26a70dd1d815c73f9281063bc8b7ee98f1'; + +/// Allows to retrieve the current (filtered) app life cycle +/// +/// Copied from [AppLifecycle]. +@ProviderFor(AppLifecycle) +final appLifecycleProvider = + NotifierProvider.internal( + AppLifecycle.new, + name: r'appLifecycleProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$appLifecycleHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AppLifecycle = 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/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/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..6da51ce --- /dev/null +++ b/lib/background/provider.dart @@ -0,0 +1,292 @@ +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: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(appIsInactivatedProvider); + 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 _checkForNewMail(); + } + + 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'; + +/// Fetches data in the background when the app is not running +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, account.name, 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..3003276 --- /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'14e47ad6c285728ca04916d97fff9239be504a3c'; + +/// 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/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..3887b68 --- /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, account.name, 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..b8ae8dd --- /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'2205f8a929faafca4bbffe075c2e3f2961194cbb'; + +/// 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/events/app_event_bus.dart b/lib/events/app_event_bus.dart deleted file mode 100644 index 90762b6..0000000 --- a/lib/events/app_event_bus.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:event_bus/event_bus.dart'; - -class AppEventBus { - static EventBus eventBus = EventBus(); -} diff --git a/lib/events/base_event.dart b/lib/events/base_event.dart deleted file mode 100644 index ec6fbd3..0000000 --- a/lib/events/base_event.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:flutter/material.dart'; - -class BaseEvent { - final BuildContext context; - BaseEvent(this.context); -} diff --git a/lib/extensions/extension_action_tile.dart b/lib/extensions/extension_action_tile.dart index 2816e7a..73314c5 100644 --- a/lib/extensions/extension_action_tile.dart +++ b/lib/extensions/extension_action_tile.dart @@ -1,27 +1,27 @@ -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:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart' hide WebViewConfiguration; -import '../locator.dart'; -import '../routes.dart'; +import '../account/model.dart'; +import '../localization/extension.dart'; +import '../models/models.dart'; +import '../routes/routes.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) { + BuildContext context, + RealAccount? currentAccount, + ) { if (currentAccount == null || currentAccount.isVirtual) { return Container(); } final actions = currentAccount.appExtensionsAccountSideMenu; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: buildActionWidgets(context, actions), @@ -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,38 +43,48 @@ 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; + 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 (!useAppDrawerAsRoot) { + // 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 27325ba..5d0b008 100644 --- a/lib/extensions/extensions.dart +++ b/lib/extensions/extensions.dart @@ -2,20 +2,23 @@ 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:http/http.dart' as http; import 'package:json_annotation/json_annotation.dart'; -import '../models/account.dart'; +import '../account/model.dart'; +import '../logger.dart'; +import '../util/http_helper.dart'; part 'extensions.g.dart'; /// Server side mail account extensions extension MailAccountExtension on RealAccount { + /// The forgot password app extension for this account AppExtensionActionDescription? get appExtensionForgotPassword => appExtensions ?.firstWhereOrNull((ext) => ext.forgotPasswordAction != null) ?.forgotPasswordAction; + /// The menu app extensions for this account List get appExtensionsAccountSideMenu { final entries = []; final extensions = appExtensions; @@ -27,12 +30,16 @@ extension MailAccountExtension on RealAccount { } } } + return entries; } } +/// [AppExtension]s allow to dynamically configure the app for a +/// given [MailAccount] @JsonSerializable() class AppExtension { + /// Creates a new [AppExtension] const AppExtension({ this.version, this.accountSideMenu, @@ -40,55 +47,86 @@ class AppExtension { this.signatureHtml, }); + /// Creates a new [AppExtension] from the given [json] factory AppExtension.fromJson(Map json) => _$AppExtensionFromJson(json); + /// The version of the app extension final int? version; + + /// Elements to add to the account side/hamburger menu final List? accountSideMenu; + + /// The action to perform when the user forgot the password @JsonKey(name: 'forgotPassword') final AppExtensionActionDescription? forgotPasswordAction; + + /// The signature html for each language final Map? signatureHtml; + /// The signature html for the given [languageCode]. + /// + /// Falls back to `en` if no signature for the given [languageCode] is found. String? getSignatureHtml(String languageCode) { final map = signatureHtml; 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; } + /// Converts this [AppExtension] to JSON. Map toJson() => _$AppExtensionToJson(this); - static String urlFor(String domain) { - return 'https://$domain/.maily.json'; - } + /// REtrieves the app extension url for the given [domain] + static String urlFor(String domain) => 'https://$domain/.maily.json'; + //// Loads the app extensions for the given [mailAccount] 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 allExtensions = await Future.wait(domains.values); - final appExtensions = []; - for (final ext in allExtensions) { - if (ext != null) { - appExtensions.add(ext); + try { + final domains = >{}; + _addEmail(mailAccount.email, domains); + final incomingHostname = mailAccount.incoming.serverConfig.hostname; + _addHostname(incomingHostname, domains); + final outgoingHostname = mailAccount.outgoing.serverConfig.hostname; + _addHostname(outgoingHostname, domains); + final allExtensions = await Future.wait(domains.values); + final appExtensions = []; + for (final ext in allExtensions) { + if (ext != null) { + appExtensions.add(ext); + } } + + return appExtensions; + } catch (e, s) { + logger.e( + 'Unable to load app extensions for mail account ' + '${mailAccount.email}: $e', + error: e, + stackTrace: s, + ); + + return const []; } - return appExtensions; } static void _addEmail( - String email, Map> domains) { + String email, + Map> domains, + ) { _addDomain(email.substring(email.indexOf('@') + 1), domains); } static void _addHostname( - String hostname, Map> domains) { + String hostname, + Map> domains, + ) { final domainIndex = hostname.indexOf('.'); if (domainIndex != -1) { _addDomain(hostname.substring(domainIndex + 1), domains); @@ -96,22 +134,31 @@ class AppExtension { } static void _addDomain( - String domain, Map> domains) { + String domain, + Map> domains, + ) { if (!domains.containsKey(domain)) { domains[domain] = loadFrom(domain); } } - static Future loadFrom(String domain) async { - return loadFromUrl(urlFor(domain)); - } + /// Loads the app extension from the given [domain] + static Future loadFrom(String domain) async => + loadFromUrl(urlFor(domain)); - static Future loadFromUrl(String url) async { + /// Loads the app extension from the given [url] + static Future loadFromUrl( + String url, { + Duration timeout = const Duration(seconds: 10), + }) async { String? text = '<>'; try { - final httpResult = await HttpHelper.httpGet(url); - text = httpResult.text; - if (httpResult.statusCode != 200 || text == null || text.isEmpty) { + final response = await http.get(Uri.parse(url)).timeout(timeout); + if (response.statusCode != 200) { + return null; + } + text = response.text; + if (text == null || text.isEmpty) { return null; } @@ -120,61 +167,92 @@ class AppExtension { return result; } } catch (e, s) { - if (kDebugMode) { - print('Unable to load extension from $url / text $text: $e $s'); - } + logger.e( + 'Unable to load extension from $url / text $text: $e', + error: e, + stackTrace: s, + ); + + return null; } + return null; } } +/// Defines a translatable action @JsonSerializable() class AppExtensionActionDescription { + /// Creates a new [AppExtensionActionDescription] const AppExtensionActionDescription({ this.action, this.icon, this.labelByLanguage, }); + /// Creates a new [AppExtensionActionDescription] from the given [json] factory AppExtensionActionDescription.fromJson(Map json) => _$AppExtensionActionDescriptionFromJson(json); + /// The action to perform @JsonKey( fromJson: AppExtensionAction._parse, toJson: AppExtensionAction._toJson, ) final AppExtensionAction? action; + + /// The icon to display final String? icon; + /// The label to display for each language @JsonKey(name: 'label') final Map? labelByLanguage; + /// The label to display for the given [languageCode] + /// + /// Falls back to `en` if no label for the given [languageCode] is found. String? getLabel(String languageCode) { final map = labelByLanguage; if (map == null) { return null; } + return map[languageCode] ?? map['en']; } + /// Converts this [AppExtensionActionDescription] to JSON. Map toJson() => _$AppExtensionActionDescriptionToJson(this); } -enum AppExtensionActionMechanism { inApp, external } +/// Defines an action +enum AppExtensionActionMechanism { + /// An action is opened in-app + inApp, + /// An action is opened in an external app/browser + external, +} + +/// Defines an action @JsonSerializable() class AppExtensionAction { + /// Creates a new [AppExtensionAction] const AppExtensionAction({ required this.mechanism, required this.url, }); + /// Creates a new [AppExtensionAction] from the given [json] factory AppExtensionAction.fromJson(Map json) => _$AppExtensionActionFromJson(json); + /// The action mechanism final AppExtensionActionMechanism mechanism; + + /// The url to open final String url; + /// Converts this [AppExtensionAction] to JSON. Map toJson() => _$AppExtensionActionToJson(this); static AppExtensionAction? _parse(String? link) { diff --git a/lib/services/providers.dart b/lib/hoster/service.dart similarity index 81% rename from lib/services/providers.dart rename to lib/hoster/service.dart index bb0b556..e0fb7e0 100644 --- a/lib/services/providers.dart +++ b/lib/hoster/service.dart @@ -1,67 +1,80 @@ import 'package:enough_mail/discover.dart'; -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/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; +import '../localization/extension.dart'; +import '../oauth/oauth.dart'; - ProviderService() { +/// Allows to resolve mail-hoster specific settings like Oauth. +class MailHosterService { + MailHosterService._() { addAll([ - GmailProvider(), - OutlookProvider(), - YahooProvider(), - AolProvider(), - AppleProvider(), - GmxProvider(), - MailboxOrgProvider(), + GmailMailHoster(), + OutlookMailHoster(), + YahooMailHoster(), + AolMailHoster(), + AppleMailHoster(), + GmxMailHoster(), + MailboxOrMailHoster(), ]); } + static final _instance = MailHosterService._(); + + /// Retrieves access to the singleton instance + static MailHosterService get instance => _instance; + + final _providersByDomains = {}; + final _providers = []; + + /// Retrieves the supported hosters + List get hosters => _providers; + /// Retrieves the provider for the given [incomingHostName] - Provider? operator [](String incomingHostName) => + MailHoster? operator [](String incomingHostName) => _providersByDomains[incomingHostName]; - Future discover(String email) async { + /// Discovers a hoster by the given [email] + Future discover(String email) async { final emailDomain = email.substring(email.indexOf('@') + 1); final providerEmail = _providersByDomains[emailDomain]; if (providerEmail != null) { return providerEmail; } try { - final clientConfig = await Discover.discover(email, - forceSslConnection: true, isLogEnabled: true); + final clientConfig = await Discover.discover( + email, + forceSslConnection: true, + isLogEnabled: true, + ); if (clientConfig == null || clientConfig.preferredIncomingServer == null) { return null; } - final hostName = clientConfig.preferredIncomingServer!.hostname!; + final hostName = clientConfig.preferredIncomingServer?.hostname ?? ''; final providerHostName = _providersByDomains[hostName]; if (providerHostName != null) { return providerHostName; } final id = email.substring(email.indexOf('@') + 1); - return Provider(id, hostName, clientConfig); + + return MailHoster(id, hostName, clientConfig); } catch (e, s) { if (kDebugMode) { print('Unable to discover settings for [$email]: $e $s'); } + return null; } } - void addAll(Iterable providers) { - for (var p in providers) { - add(p); - } + void addAll(Iterable providers) { + providers.forEach(add); } - void add(Provider provider) { + void add(MailHoster provider) { _providers.add(provider); _providersByDomains[provider.incomingHostName] = provider; final domains = provider.domains; @@ -73,31 +86,42 @@ class ProviderService { } } -class Provider { - /// The key of the provider, help to resolves image resources and possibly other settings like branding guidelines +/// Provides access information about a mail hoster +class MailHoster { + /// Creates a new [MailHoster] + const MailHoster( + this.key, + this.incomingHostName, + this.clientConfig, { + this.oauthClient, + this.appSpecificPasswordSetupUrl, + this.manualImapAccessSetupUrl, + this.domains, + }); + + /// The key of the mail hoster, 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 { + final oauthClient = this.oauthClient; + + return oauthClient != null && oauthClient.isEnabled; + } + final String? appSpecificPasswordSetupUrl; final String? manualImapAccessSetupUrl; final List? domains; - String? get displayName => (clientConfig.emailProviders == null || - clientConfig.emailProviders!.isEmpty) - ? null - : clientConfig.emailProviders!.first.displayName; + String? get displayName { + final emailProviders = clientConfig.emailProviders; - const Provider( - this.key, - this.incomingHostName, - this.clientConfig, { - this.oauthClient, - this.appSpecificPasswordSetupUrl, - this.manualImapAccessSetupUrl, - this.domains, - }); + return (emailProviders == null || emailProviders.isEmpty) + ? null + : emailProviders.first.displayName; + } /// Builds the sign in button for this provider /// @@ -112,6 +136,7 @@ class Provider { final buttonText = isSignInButton ? localizations.addAccountOauthSignIn(providerName) : providerName; + return Theme( data: ThemeData(brightness: Brightness.light), child: PlatformTextButton( @@ -121,7 +146,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,8 +157,8 @@ class Provider { errorBuilder: (context, error, stacktrace) => Container(), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: PlatformText(buttonText), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text(buttonText), ), ], ), @@ -144,8 +169,8 @@ class Provider { } } -class GmailProvider extends Provider { - GmailProvider() +class GmailMailHoster extends MailHoster { + GmailMailHoster() : super( 'gmail', 'imap.gmail.com', @@ -155,26 +180,26 @@ class GmailProvider extends Provider { displayName: 'Google Mail', displayShortName: 'Gmail', incomingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.imap, hostname: 'imap.gmail.com', port: 993, socketType: SocketType.ssl, authentication: Authentication.oauth2, usernameType: UsernameType.emailAddress, - ) + ), ], outgoingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.smtp, hostname: 'smtp.gmail.com', port: 465, socketType: SocketType.ssl, authentication: Authentication.oauth2, usernameType: UsernameType.emailAddress, - ) + ), ], - ) + ), ], appSpecificPasswordSetupUrl: 'https://support.google.com/accounts/answer/185833', @@ -191,11 +216,12 @@ class GmailProvider extends Provider { final localizations = context.text; const googleBlue = Color(0xff4285F4); const googleText = Color(0x89000000); + return Theme( data: ThemeData( - brightness: Brightness.light, - colorScheme: - ColorScheme.fromSwatch().copyWith(secondary: googleBlue)), + brightness: Brightness.light, + colorScheme: ColorScheme.fromSwatch().copyWith(secondary: googleBlue), + ), child: PlatformTextButton( onPressed: onPressed, child: Container( @@ -213,8 +239,8 @@ class GmailProvider extends Provider { errorBuilder: (context, error, stacktrace) => Container(), ), Padding( - padding: const EdgeInsets.only(left: 8.0, right: 16.0), - child: PlatformText( + padding: const EdgeInsets.only(left: 8, right: 16), + child: Text( localizations.addAccountOauthSignInGoogle, style: GoogleFonts.roboto( color: googleText, @@ -230,8 +256,8 @@ class GmailProvider extends Provider { } } -class OutlookProvider extends Provider { - OutlookProvider() +class OutlookMailHoster extends MailHoster { + OutlookMailHoster() : super( 'outlook', 'outlook.office365.com', @@ -241,26 +267,26 @@ class OutlookProvider extends Provider { displayName: 'Outlook.com', displayShortName: 'Outlook', incomingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.imap, hostname: 'outlook.office365.com', port: 993, socketType: SocketType.ssl, authentication: Authentication.oauth2, usernameType: UsernameType.emailAddress, - ) + ), ], outgoingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.smtp, hostname: 'smtp.office365.com', port: 587, socketType: SocketType.starttls, authentication: Authentication.oauth2, usernameType: UsernameType.emailAddress, - ) + ), ], - ) + ), ], oauthClient: OutlookOAuthClient(), appSpecificPasswordSetupUrl: @@ -367,13 +393,13 @@ class OutlookProvider extends Provider { 'live.com.pt', 'live.com.sg', 'livemail.tw', - 'olc.protection.outlook.com' + 'olc.protection.outlook.com', ], ); } -class YahooProvider extends Provider { - YahooProvider() +class YahooMailHoster extends MailHoster { + YahooMailHoster() : super( 'yahoo', 'imap.mail.yahoo.com', @@ -383,26 +409,26 @@ class YahooProvider extends Provider { displayName: 'Yahoo! Mail', displayShortName: 'Yahoo', incomingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.imap, hostname: 'imap.mail.yahoo.com', port: 993, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], outgoingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.smtp, hostname: 'smtp.mail.yahoo.com', port: 465, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], - ) + ), ], appSpecificPasswordSetupUrl: 'https://help.yahoo.com/kb/SLN15241.html', @@ -423,13 +449,13 @@ class YahooProvider extends Provider { 'rocketmail.com', 'mail.am0.yahoodns.net', 'am0.yahoodns.net', - 'yahoodns.net' + 'yahoodns.net', ], ); } -class AolProvider extends Provider { - AolProvider() +class AolMailHoster extends MailHoster { + AolMailHoster() : super( 'aol', 'imap.aol.com', @@ -439,26 +465,26 @@ class AolProvider extends Provider { displayName: 'AOL Mail', displayShortName: 'AOL', incomingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.imap, hostname: 'imap.aol.com', port: 993, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], outgoingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.smtp, hostname: 'smtp.aol.com', port: 465, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], - ) + ), ], appSpecificPasswordSetupUrl: 'https://help.aol.com/articles/Create-and-manage-app-password', @@ -481,13 +507,13 @@ class AolProvider extends Provider { 'aol.com.ar', 'aol.com.br', 'aol.com.mx', - 'mail.gm0.yahoodns.net' + 'mail.gm0.yahoodns.net', ], ); } -class AppleProvider extends Provider { - AppleProvider() +class AppleMailHoster extends MailHoster { + AppleMailHoster() : super( 'apple', 'imap.mail.me.com', @@ -497,26 +523,26 @@ class AppleProvider extends Provider { displayName: 'Apple iCloud', displayShortName: 'Apple', incomingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.imap, hostname: 'imap.mail.me.com', port: 993, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], outgoingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.smtp, hostname: 'smtp.mail.me.com', port: 587, socketType: SocketType.starttls, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], - ) + ), ], appSpecificPasswordSetupUrl: 'https://support.apple.com/en-us/HT204397', @@ -524,8 +550,8 @@ class AppleProvider extends Provider { ); } -class GmxProvider extends Provider { - GmxProvider() +class GmxMailHoster extends MailHoster { + GmxMailHoster() : super( 'gmx', 'imap.gmx.net', @@ -535,26 +561,26 @@ class GmxProvider extends Provider { displayName: 'GMX Freemail', displayShortName: 'GMX', incomingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.imap, hostname: 'imap.gmx.net', port: 993, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], outgoingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.smtp, hostname: 'mail.gmx.net', port: 465, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], - ) + ), ], manualImapAccessSetupUrl: 'https://hilfe.gmx.net/pop-imap/einschalten.html', @@ -566,13 +592,13 @@ class GmxProvider extends Provider { 'gmx.eu', 'gmx.biz', 'gmx.org', - 'gmx.info' + 'gmx.info', ], ); } -class MailboxOrgProvider extends Provider { - MailboxOrgProvider() +class MailboxOrMailHoster extends MailHoster { + MailboxOrMailHoster() : super( 'mailbox_org', 'imap.gmx.net', @@ -582,26 +608,26 @@ class MailboxOrgProvider extends Provider { displayName: 'mailbox.org', displayShortName: 'mailbox', incomingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.imap, hostname: 'imap.mailbox.org', port: 993, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], outgoingServers: [ - ServerConfig( + const ServerConfig( type: ServerType.smtp, hostname: 'smtp.mailbox.org', port: 465, socketType: SocketType.ssl, authentication: Authentication.passwordClearText, usernameType: UsernameType.emailAddress, - ) + ), ], - ) + ), ], domains: ['mailbox.org'], ); diff --git a/lib/keys/service.dart b/lib/keys/service.dart new file mode 100644 index 0000000..c4f4ad9 --- /dev/null +++ b/lib/keys/service.dart @@ -0,0 +1,57 @@ +// ignore_for_file: do_not_use_environment + +import '../oauth/oauth.dart'; + +/// Allows to load the keys from assets/keys.txt +class KeyService { + /// Creates a new [KeyService] + KeyService._(); + + static final _instance = KeyService._(); + + /// Retrieves access to the [KeyService] singleton + static KeyService get instance => _instance; + + /// Loads the key data + Future init() async { + void addOauth(String key, String value) { + if (value.isEmpty) { + return; + } + final valueIndex = value.indexOf(':'); + if (valueIndex == -1) { + oauth[key] = OauthClientId(value, null); + } else { + oauth[key] = OauthClientId( + value.substring(0, valueIndex), + value.substring(valueIndex + 1), + ); + } + } + + const giphyApiKey = String.fromEnvironment('GIPHY_API_KEY'); + _giphy = giphyApiKey.isEmpty ? null : giphyApiKey; + addOauth( + 'imap.gmail.com', + const String.fromEnvironment('OAUTH_GMAIL'), + ); + addOauth( + 'outlook.office365.com', + const String.fromEnvironment('OAUTH_OUTLOOK'), + ); + } + + String? _giphy; + + /// The giphy API key + String? get giphy => _giphy; + + /// Whether the giphy API key is available + bool get hasGiphy => _giphy != null; + + /// The oauth client ids + final oauth = {}; + + /// Whether the oauth client id is available for the given [incomingHostname] + bool hasOauthFor(String incomingHostname) => oauth[incomingHostname] != null; +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb deleted file mode 100644 index 9a5be8f..0000000 --- a/lib/l10n/app_de.arb +++ /dev/null @@ -1,494 +0,0 @@ -{ - "_notUsed": "cSpell:disable", - "signature": "Mit Maily gesendet", - "actionCancel": "Abbrechen", - "actionOk": "OK", - "actionDone": "Fertig", - "actionNext": "Weiter", - "actionSkip": "Überspringen", - "actionUndo": "Rückgängig", - "actionDelete": "Löschen", - "actionAccept": "Akzeptieren", - "actionDecline": "Ablehnen", - "actionEdit": "Bearbeiten", - "actionAddressCopy": "Kopieren", - "actionAddressCompose": "Neue Nachricht", - "actionAddressSearch": "Suchen", - "splashLoading1": "Maily startet...", - "splashLoading2": "Maily fängt an zu arbeiten...", - "splashLoading3": "Maily startet in 10, 9, 8...", - "welcomePanel1Title": "Maily", - "welcomePanel1Text": "Willkommen zu Maily, deinem freundlichen und schnellen E-Mail Helferlein!", - "welcomePanel2Title": "Konten", - "welcomePanel2Text": "Verwalte beliebig viele E-Mail Konten. Lese und suche Mails in allen Konten gleichzeitig", - "welcomePanel3Title": "Wisch und drück mich!", - "welcomePanel3Text": "Wische eine E-Mail um sie zu löschen oder als gelesen zu markieren. Halte eine E-Mail lange um mehrere gleichzeitig zu bearbeiten.", - "welcomePanel4Title": "Halte Deinen Posteingang sauber", - "welcomePanel4Text": "Melde Dich von Newslettern mit einem Klick ab.", - "welcomeActionSignIn": "Melde dich bei deinem E-Mail Konto an", - "homeSearchHint": "Deine Suche", - "homeActionsShowAsStack": "Stapel Modus", - "homeActionsShowAsList": "Listen Modus", - "homeEmptyFolderMessage": "Alles fertig!\n\nEs gibt keine E-Mails in diesem Ordner.", - "homeEmptySearchMessage": "Keine E-Mails gefunden.", - "homeDeleteAllTitle": "Bestätigung", - "homeDeleteAllQuestion": "Wirklich alle E-Mails löschen?", - "homeDeleteAllAction": "Alle löschen", - "homeDeleteAllScrubOption": "Endgültig löschen", - "homeDeleteAllSuccess": "Alle E-Mails gelöscht.", - "homeMarkAllSeenAction": "Gelesen", - "homeMarkAllUnseenAction": "Ungelesen", - "homeFabTooltip": "Neue E-Mail", - "homeLoadingMessageSourceTitle": "Lade Daten...", - "homeLoading": "Lade {name}...", - "swipeActionToggleRead": "Gelesen / ungelesen", - "swipeActionDelete": "Löschen", - "swipeActionMarkJunk": "Als Spam markieren", - "swipeActionArchive": "Archivieren", - "swipeActionFlag": "Markieren", - "multipleMovedToJunk": "{number,plural, =1{Eine Nachricht als Spam markiert} other{{number} Nachrichten als Spam markiert}}", - "multipleMovedToInbox": "{number,plural, =1{Eine Nachricht in Inbox geschoben} other{{number} Nachrichten in Inbox geschoben}}", - "multipleMovedToArchive": "{number,plural, =1{Eine Nachricht archiviert} other{{number} Nachrichten archiviert}}", - "multipleMovedToTrash": "{number,plural, =1{Eine Nachricht gelöscht} other{{number} Nachrichten gelöscht}}", - "multipleSelectionNeededInfo": "Wähle mindestens eine Nachricht aus.", - "multipleMoveTitle": "{number,plural, =1{Nachricht verschieben} other{{number} Nachrichten verschieben}}", - "messageActionMultipleMarkSeen": "Als gelesen markieren", - "messageActionMultipleMarkUnseen": "Als ungelesen markieren", - "messageActionMultipleMarkFlagged": "Markieren", - "messageActionMultipleMarkUnflagged": "Markierung entfernen", - "messageActionViewInSafeMode": "Ohne externe Inhalte anzeigen", - "emailSenderUnknown": "", - "dateRangeFuture": "Zukunft", - "dateRangeTomorrow": "morgen", - "dateRangeToday": "heute", - "dateRangeYesterday": "gestern", - "dateRangeCurrentWeek": "diese Woche", - "dateRangeLastWeek": "letzte Woche", - "dateRangeCurrentMonth": "diesen Monat", - "dateRangeLastMonth": "letzten Monat", - "dateRangeCurrentYear": "dieses Jahr", - "dateRangeLongAgo": "lange her", - "dateUndefined": "undefiniert", - "dateDayToday": "heute", - "dateDayYesterday": "gestern", - "dateDayLastWeekday": "letzten {day}", - "drawerEntryAbout": "Über Maily", - "drawerEntrySettings": "Einstellungen", - "drawerAccountsSectionTitle": "{number,plural, =1{1 Konto} other{{number} Konten}}", - "drawerEntryAddAccount": "Konto hinzufügen", - "unifiedAccountName": "Alle Konten", - "unifiedFolderInbox": "Alle Posteingänge", - "unifiedFolderSent": "Alle Gesendeten", - "unifiedFolderDrafts": "Alle Entwürfe", - "unifiedFolderTrash": "Alle Gelöschten", - "unifiedFolderArchive": "Alle Archivierten", - "unifiedFolderJunk": "Alle Spam Nachrichten", - "folderInbox": "Posteingang", - "folderSent": "Gesendete Nachrichten", - "folderDrafts": "Entwürfe", - "folderTrash": "Papierkorb", - "folderArchive": "Archiv", - "folderJunk": "Spam Nachrichten", - "viewContentsAction": "Inhalt anzeigen", - "viewSourceAction": "Sourcecode anzeigen", - "detailsErrorDownloadInfo": "E-Mail konnte nicht geladen werden.", - "detailsErrorDownloadRetry": "wiederholen", - "detailsHeaderFrom": "Von", - "detailsHeaderTo": "An", - "detailsHeaderCc": "CC", - "detailsHeaderBcc": "BCC", - "detailsHeaderDate": "Datum", - "subjectUndefined": "", - "detailsActionShowImages": "Bilder anzeigen", - "detailsNewsletterActionUnsubscribe": "Abbestellen", - "detailsNewsletterActionResubscribe": "Neu abbonieren", - "detailsNewsletterStatusUnsubscribed": "Abbestellt", - "detailsNewsletterUnsubscribeDialogTitle": "Abbestellen", - "detailsNewsletterUnsubscribeDialogQuestion": "Möchtest du die Mailingliste {listName} abbestellen?", - "detailsNewsletterUnsubscribeDialogAction": "Abbestellen", - "detailsNewsletterUnsubscribeSuccessTitle": "Abbestellt", - "detailsNewsletterUnsubscribeSuccessMessage": "Du hast dich von der Mailingliste {listName} erfolgreich abgemeldet.", - "detailsNewsletterUnsubscribeFailureTitle": "Nicht abbestellt", - "detailsNewsletterUnsubscribeFailureMessage": "Entschuldige, ich konnte dich nicht automatisch von {listName} abmelden.", - "detailsNewsletterResubscribeDialogTitle": "Abonnieren", - "detailsNewsletterResubscribeDialogQuestion": "Möchtest du die Mailingliste {listName} wieder abonnieren?", - "detailsNewsletterResubscribeDialogAction": "Abonnieren", - "detailsNewsletterResubscribeSuccessTitle": "Aboniert", - "detailsNewsletterResubscribeSuccessMessage": "Du hast wieder die Mailingliste {listName} abonniert.", - "detailsNewsletterResubscribeFailureTitle": "Nicht abonniert", - "detailsNewsletterResubscribeFailureMessage": "Entschuldige, ich konnte dich leider nicht automatisch bei der Mailingliste {listName} anmelden.", - "detailsSendReadReceiptAction": "Lesebestätigung senden", - "detailsReadReceiptSentStatus": "Lesebestätigung gesendet ✔", - "detailsReadReceiptSubject": "Lesebestätigung", - "attachmentActionOpen": "Öffnen", - "messageActionReply": "Antworten", - "messageActionReplyAll": "Allen antworten", - "messageActionForward": "Weiterleiten", - "messageActionForwardAsAttachment": "Als Anhang weiterleiten", - "messageActionForwardAttachments": "{number,plural, =1{Anhang weiterleiten} other{{number} Anhänge weiterleiten}}", - "messagesActionForwardAttachments": "Anhänge weiterleiten", - "messageActionDelete": "Löschen", - "messageActionMoveToInbox": "In Posteingang verschieben", - "messageActionMove": "Verschieben", - "messageStatusSeen": "Ist gelesen", - "messageStatusUnseen": "Ist ungelesen", - "messageStatusFlagged": "Ist markiert", - "messageStatusUnflagged": "Ist nicht markiert", - "messageActionMarkAsJunk": "Als Spam markieren", - "messageActionMarkAsNotJunk": "Als nicht-Spam markieren", - "messageActionArchive": "Archivieren", - "messageActionUnarchive": "In Posteingang verschieben", - "messageActionRedirect": "Umleiten", - "messageActionAddNotification": "Benachrichtigung hinzufügen", - "resultDeleted": "Gelöscht", - "resultMovedToJunk": "Als Spam markiert", - "resultMovedToInbox": "In Posteingang verschoben", - "resultArchived": "Archiviert", - "resultRedirectedSuccess": "Nachricht umgeleitet 👍", - "resultRedirectedFailure": "Nachricht konnte nicht umgeleitet werden.\n\nDer Server meldet folgende Details: \"{details}\"", - "redirectTitle": "Umleiten", - "redirectInfo": "Leite diese Nachricht an folgende Empfänger:innen um. Umleiten verändert nicht die Nachricht.", - "redirectEmailInputRequired": "Bitte gebe mindestens eine gültige E-Mail-Adresse ein.", - "searchQueryDescription": "Suche in {folder}...", - "searchQueryTitle": "Suche \"{query}\"", - "legaleseUsage": "Durch die Nutzung von Maily stimmst du unserer [PP] und unseren [TC] zu.", - "legalesePrivacyPolicy": "Datenschutzerlärung", - "legaleseTermsAndConditions": "Bedingungen", - "aboutApplicationLegalese": "Maily ist freie Software unter der GPL GNU General Public License lizensiert.", - "feedbackActionSuggestFeature": "Feature vorschlagen", - "feedbackActionReportProblem": "Problem berichten", - "feedbackActionHelpDeveloping": "Hilf Maily zu entwickeln", - "feedbackTitle": "Feedback", - "feedbackIntro": "Danke dass du Maily testest!", - "feedbackProvideInfoRequest": "Bitte teile folgende Information mit, wenn du ein Problem berichtest:", - "feedbackResultInfoCopied": "kopiert", - "accountsTitle": "Konten", - "accountsActionReorder": "Konten Reihenfolge ändern", - "settingsTitle": "Einstellungen", - "settingsSecurityBlockExternalImages": "Externe Bilder blockieren", - "settingsSecurityBlockExternalImagesDescriptionTitle": "Externe Bilder", - "settingsSecurityBlockExternalImagesDescriptionText": "E-Mails können Bilder enthalten, die entweder in der Nachricht integriert sind oder die von externen Servern bereitgestellt werden. Solche externe Bilder können Daten zu dem Absender der Nachricht freigeben, zum Beispiel dass die Nachricht geöffnet wurde. Diese Option erlaubt es solche externen Bilder zu blockieren, um solche Datenlecks zu minimieren. Beim Lesen einer E-Mail kannst für jede Nachricht individual externe Bilder nachladen.", - "settingsSecurityMessageRenderingHtml": "Gesamte Nachricht anzeigen", - "settingsSecurityMessageRenderingPlainText": "Nur den Text der Nachricht anzeigen", - "settingsActionAccounts": "Konten verwalten", - "settingsActionDesign": "Darstellung", - "settingsActionFeedback": "Feedback geben", - "settingsActionWelcome": "Willkommen anzeigen", - "settingsDevelopment": "Entwicklungs-Einstellungen", - "settingsFolders": "Ordner", - "settingsReadReceipts": "Lesebestätigungen", - "readReceiptsSettingsIntroduction": "Sollen Lesebestätigungs-Anforderungen angezeigt werden?", - "readReceiptOptionAlways": "Immer", - "readReceiptOptionNever": "Nie", - "folderNamesIntroduction": "Welche Ordner-Namen möchtest du nutzen?", - "folderNamesSettingLocalized": "Von Maily vorgegebene Namen", - "folderNamesSettingServer": "Vom Maildienst gegebene Namen", - "folderNamesSettingCustom": "Meine eigenen Namen", - "folderNamesEditAction": "Eigene Ordner Namen ändern", - "folderNamesCustomTitle": "Eigene Namen", - "folderAddAction": "Ordner erstellen", - "folderAddTitle": "Ordner erstellen", - "folderAddNameLabel": "Name", - "folderAddNameHint": "Name des Ordners", - "folderAccountLabel": "Konto", - "folderMailboxLabel": "Erstelle in", - "folderAddResultSuccess": "Ordner erstellt 😊", - "folderAddResultFailure": "Der Ordner konnte nicht erstellt werden.\n\nDer Server antwortete mit \"{details}\".", - "folderDeleteAction": "Löschen", - "folderDeleteConfirmTitle": "Bestätigen", - "folderDeleteConfirmText": "Möchtest Du den Ordner {name} wirklich löschen?", - "folderDeleteResultSuccess": "Ordner gelöscht.", - "folderDeleteResultFailure": "Der Ordner konnte nicht gelöscht werden.\n\nDer Server antwortete mit \"{details}\".", - "developerModeTitle": "Entwicklungs-Modus", - "developerModeIntroduction": "Mit einem aktivierten Entwicklungs-Modus kannst du den Sourcecode von Mails einsehen, siehst alle Fehler-Details und Text Anhänge in eine Mail Nachricht umwandeln.", - "developerModeEnable": "Entwicklungs-Modus aktivieren", - "developerShowAsEmail": "Text zu E-Mail konvertieren", - "developerShowAsEmailFailed": "Dieser Text kann nicht in einer MIME Nachricht umgewandelt werden.", - "designTitle": "Design Einstellungen", - "designSectionThemeTitle": "Modus", - "designThemeOptionLight": "Hell", - "designThemeOptionDark": "Dunkel", - "designThemeOptionSystem": "System", - "designThemeOptionCustom": "Selbst definieren", - "designSectionCustomTitle": "Der dunkle Modus wird aktiviert", - "designThemeCustomStart": "von {time}", - "designThemeCustomEnd": "bis {time}", - "designSectionColorTitle": "Farbschema", - "securitySettingsTitle": "Sicherheit", - "securitySettingsIntro": "Passe die Sicherheitseinstellungen deinen persönlichen Ansprüchen an.", - "securityUnlockWithFaceId": "Entsicher Maily mit Face ID.", - "securityUnlockWithTouchId": "Entsicher Maily mit Touch ID.", - "securityUnlockReason": "Entsicher Maily.", - "securityUnlockDisableReason": "Entsicher Maily um die Sicherung zu deaktvieren.", - "securityUnlockNotAvailable": "Dein Gerät unterstützt keine Biometrie-Absicherung. Vielleicht musst du zuerst die Displaysperre in den Geräteeinstellungen aktivieren.", - "securityUnlockLabel": "Maily Absichern", - "securityUnlockDescriptionTitle": "Maily Absichern", - "securityUnlockDescriptionText": "Du kannst Maily absichern, so dass anderen deine E-Mails auch dann nicht lesen können, wenn sie Zugang zu deinem Gerät haben.", - "securityLockImmediately": "Sofort absichern", - "securityLockAfter5Minutes": "Nach 5 Minuten absichern", - "securityLockAfter30Minutes": "Nach 30 Minuten absichern", - "settingsSecurityLaunchModeLabel": "Wie soll Maily Links öffnen?", - "settingsSecurityLaunchModeExternal": "Öffne Links extern", - "settingsSecurityLaunchModeInApp": "Öffne Links in Maily", - "lockScreenTitle": "Maily ist gesichert", - "lockScreenIntro": "Maily ist gesichert, bitte authentifiziere dich um weiter zu machen.", - "lockScreenUnlockAction": "Entsichern", - "addAccountTitle": "Konto hinzufügen", - "addAccountEmailLabel": "E-Mail", - "addAccountEmailHint": "Deine E-Mail Adresse", - "addAccountResolvingSettingsLabel": "Suche {email} Einstellungen...", - "addAccountResolvedSettingsWrongAction": "Nicht bei {provider}?", - "addAccountResolvingSettingsFailedInfo": "Ich konte die Einstellungen für {email} nicht finden. Bitte gehe zurück und ändere die E-Mail Adresse oder gebe die Einstellungen manuell an.", - "addAccountEditManuallyAction": "Manuell bearbeiten", - "addAccountPasswordLabel": "Passwort", - "addAccountPasswordHint": "Dein Passwort", - "addAccountApplicationPasswordRequiredInfo": "Dieser Anbieter verlangt ein Applikations-spezifisches Passwort.", - "addAccountApplicationPasswordRequiredButton": "App Passwort erstellen", - "addAccountApplicationPasswordRequiredAcknowledged": "Verstanden", - "addAccountVerificationStep": "Überprüfen", - "addAccountSetupAccountStep": "Konto Einrichten", - "addAccountVerifyingSettingsLabel": "Überprüfe {email}...", - "addAccountVerifyingSuccessInfo": "Erfolgreich mit {email} angemeldet.", - "addAccountVerifyingFailedInfo": "Leider konnte ich dich nicht anmelden. Überprüfe deine E-Mail {email} und dein Passwort.", - "addAccountOauthOptionsText": "Melde dich mit {provider} an oder erstelle ein Applikations-spezifisches Passwort.", - "addAccountOauthSignIn": "Mit {provider} einloggen", - "addAccountOauthSignInGoogle": "Mit Google einloggen", - "addAccountOauthSignInWithAppPassword": "Oder erstelle ein Applikations-Passwort:", - "accountAddImapAccessSetupMightBeRequired": "Vielleicht musst Du bei deinem Anbieter den Zugang für E-Mail Apps aktivieren.", - "addAccountSetupImapAccessButtonLabel": "E-Mail Zugang aktivieren", - "addAccountNameOfUserLabel": "Dein Name", - "addAccountNameOfUserHint": "Name, den Empfänger:innen sehen", - "addAccountNameOfAccountLabel": "Konto Name", - "addAccountNameOfAccountHint": "Gebe den Namen des Kontos an", - "editAccountTitle": "Bearbeite {name}", - "editAccountIncludeInUnifiedLabel": "zu \"Alle Konten\" hinzufügen", - "editAccountFailureToConnectInfo": "Maily konnte {name} nicht erreichen.", - "editAccountFailureToConnectRetryAction": "Wiederholen", - "editAccountFailureToConnectChangePasswordAction": "Passwort ändern", - "editAccountFailureToConnectFixedTitle": "Verbunden", - "editAccountFailureToConnectFixedInfo": "Das Konto ist wieder verbunden.", - "editAccountAliasLabel": "Alias E-Mail Adressen für {email}:", - "editAccountNoAliasesInfo": "Du hast noch keine bekannten Alias E-Mail Adressen für dieses Konto.", - "editAccountAliasRemoved": "{email} Alias gelöscht", - "editAccountAddAliasAction": "Alias hinzufügen", - "editAccountPlusAliasesSupported": "Unterstützt + Aliase", - "editAccountCheckPlusAliasAction": "Teste Unterstützung für + Aliase", - "editAccountBccMyself": "Setze mich auf BCC", - "editAccountBccMyselfDescriptionTitle": "Setze mich auf CC", - "editAccountBccMyselfDescriptionText": "Du kannst Dir selbst eine \"BCC\" Kopie von jeder Nachricht schicken, die du von diesem Konto verschickst. Normalerweise ist das nicht nötig und nicht gewollt, weil alle gesendeten Nachrichten im\"Gesendete Nachrichten\" Ordner gespeichert werden.", - "editAccountServerSettingsAction": "Bearbeite Server Einstellungen", - "editAccountDeleteAccountAction": "Lösche Konto", - "editAccountDeleteAccountConfirmationTitle": "Bestätige", - "editAccountDeleteAccountConfirmationQuery": "Möchtest du das Konto {name} löschen?", - "editAccountTestPlusAliasTitle": "+ Aliase für {name}", - "editAccountTestPlusAliasStepIntroductionTitle": "Einleitung", - "editAccountTestPlusAliasStepIntroductionText": "Dein Konto {accountName} könnte sogenannte + Aliase wie {example} unterstützen.\nEin + Alias hilft dir Deine Identität zu schützen und kann gegen Spam helfen.\nUm dies zu testen, wird eine Nachricht an diese generierte Adresse gesendet. Wenn sie ankommt, dann unterstützt dein Anbieter + Aliase und du kannst leicht neue generieren wenn Du eine E-Mail schreibst.", - "editAccountTestPlusAliasStepTestingTitle": "Testen", - "editAccountTestPlusAliasStepResultTitle": "Ergebnis", - "editAccountTestPlusAliasStepResultSuccess": "Dein Konto {name} unterstütz + Aliase.", - "editAccountTestPlusAliasStepResultNoSuccess": "Dein Konto {name} unterstütz leider keine + Aliase.", - "editAccountAddAliasTitle": "Alias hinzufügen", - "editAccountEditAliasTitle": "Alias bearbeiten", - "editAccountAliasAddAction": "Hinzufügen", - "editAccountAliasUpdateAction": "Ändern", - "editAccountEditAliasNameLabel": "Alias Name", - "editAccountEditAliasEmailLabel": "Alias E-Mail", - "editAccountEditAliasEmailHint": "Deine Alias E-Mail Adresse", - "editAccountEditAliasDuplicateError": "Es gibt bereits einen Alias mit {email}.", - "editAccountEnableLogging": "Log aktivieren", - "editAccountLoggingEnabled": "Log aktiviert, bitte neu starten", - "editAccountLoggingDisabled": "Log de-aktiviert, bitte neu starten", - "accountDetailsFallbackTitle": "Server Einstellungen", - "errorTitle": "Fehler", - "accountProviderStepTitle": "E-Mail Service Anbieter", - "accountProviderCustom": "Anderer E-Mail Service", - "accountDetailsErrorHostProblem": "Maily kann den angegeben Server nicht erreich. Bitte überprüfe die Einstellugen des Posteingang-Servers \"{incomingHost}\" und des Postausgang-Servers \"{outgoingHost}\".", - "accountDetailsErrorLoginProblem": "Anmeldung fehlgeschlagen. Bitte überprüfe den Login-Namen \"{userName}\" und das Passwort \"{password}\".", - "accountDetailsUserNameLabel": "Login Name", - "accountDetailsUserNameHint": "Dein Login, falls es nicht die E-Mail ist", - "accountDetailsPasswordLabel": "Login Passwort", - "accountDetailsPasswordHint": "Dein Passwort", - "accountDetailsBaseSectionTitle": "Basis Einstellungen", - "accountDetailsIncomingLabel": "Posteingangs-Server", - "accountDetailsIncomingHint": "Domäne wie imap.domain.de", - "accountDetailsOutgoingLabel": "Postausgangs-Server", - "accountDetailsOutgoingHint": "Domäne wie smtp.domain.de", - "accountDetailsAdvancedIncomingSectionTitle": "Erweiterte Posteingang Einstellungen", - "accountDetailsIncomingServerTypeLabel": "Typ des Posteingang Servers:", - "accountDetailsOptionAutomatic": "automatisch", - "accountDetailsIncomingSecurityLabel": "Posteingang Sicherheit:", - "accountDetailsSecurityOptionNone": "Plain (keine Verschlüsselung)", - "accountDetailsIncomingPortLabel": "Posteingang Port", - "accountDetailsPortHint": "Leer lassen um automatisch finden zu lassen", - "accountDetailsIncomingUserNameLabel": "Posteingang Login-Name", - "accountDetailsAlternativeUserNameHint": "Login, falls abweichend von oben", - "accountDetailsIncomingPasswordLabel": "Posteingang Passwort", - "accountDetailsAlternativePasswordHint": "Passwort, falls abweichend von oben", - "accountDetailsAdvancedOutgoingSectionTitle": "Erweiterte Postausgang Einstellungen", - "accountDetailsOutgoingServerTypeLabel": "Typ des Postausgang Servers:", - "accountDetailsOutgoingSecurityLabel": "Postausgang Sicherheit:", - "accountDetailsOutgoingPortLabel": "Postausgang Port", - "accountDetailsOutgoingUserNameLabel": "Postausgang Login-Name", - "accountDetailsOutgoingPasswordLabel": "Postausgang Passwort", - "composeTitleNew": "Neu", - "composeTitleForward": "Weiterleitung", - "composeTitleReply": "Antwort", - "composeEmptyMessage": "Leere Nachricht", - "composeWarningNoSubject": "Du hast kein Betreff geschrieben. Möchtest du die Nachricht ohne Betreff senden?", - "composeActionSentWithoutSubject": "Senden", - "composeMailSendSuccess": "Gesendet 😊", - "composeSendErrorInfo": "Leider konnte die E-Mail nicht versendet werden.\nDer Postausgang Server liefert folgende Antwort:\n{details}", - "composeRequestReadReceiptAction": "Lesebestätigung anfordern", - "composeSaveDraftAction": "Als Entwurf speichern", - "composeMessageSavedAsDraft": "Entwurf gespeichert", - "composeMessageSavedAsDraftErrorInfo": "Der Entwurf konnte nicht gespeichert werden.\nDie Fehlermeldung lautet:\n{details}", - "composeConvertToPlainTextEditorAction": "Zu Text-Nachricht konvertieren", - "composeConvertToHtmlEditorAction": "Zu HTML-Nachricht konvertieren", - "composeContinueEditingAction": "Weiter bearbeiten", - "composeCreatePlusAliasAction": "Neuen + Alias erstellen...", - "composeSenderHint": "Absender:in", - "composeRecipientHint": "E-Mails der Empfänger:innen", - "composeSubjectLabel": "Betreff", - "composeSubjectHint": "Betreff der Nachricht", - "composeAddAttachmentAction": "Hinzufügen", - "composeRemoveAttachmentAction": "{name} entfernen", - "composeLeftByMistake": "Aus Versehen verlassen?", - "attachTypeFile": "Datei", - "attachTypePhoto": "Foto", - "attachTypeVideo": "Video", - "attachTypeAudio": "Audio", - "attachTypeLocation": "Ort", - "attachTypeGif": "Animiertes Gif", - "attachTypeGifSearch": "in GIPHY suchen", - "attachTypeSticker": "Sticker", - "attachTypeStickerSearch": "in GIPHY suchen", - "attachTypeAppointment": "Termin", - "languageSettingTitle": "Sprache (Language)", - "languageSettingLabel": "Währe die Sprache für Maily:", - "languageSettingSystemOption": "Systemsprache", - "languageSettingConfirmationTitle": "Deutsch für Maily nutzen?", - "languageSettingConfirmationQuery": "Bitte bestätige, dass deutsch als Sprache verwendet werden soll.", - "languageSetInfo": "Maily ist nun auf deutsch. Bitte starte die App neu.", - "languageSystemSetInfo": "Maily wird nun die Systemsprache oder englisch nutzen, wenn die Systemprache nicht unterstützt wird. Bitte starte die App neu.", - "swipeSettingTitle": "Wischgesten", - "swipeSettingLeftToRightLabel": "Von links nach rechts wischen", - "swipeSettingRightToLeftLabel": "Von rechts nach links wischen", - "swipeSettingChangeAction": "Ändern", - "signatureSettingsTitle": "Signatur", - "signatureSettingsComposeActionsInfo": "Aktiviere die Signatur für folgende Nachrichten:", - "signatureSettingsAccountInfo": "Du kannst Signaturen für Konten in den Konten-Einstellungen festlegen.", - "signatureSettingsAddForAccount": "Signature für {account} hinzufügen", - "defaultSenderSettingsTitle": "Standard Absender", - "defaultSenderSettingsLabel": "Wähle den Absender für neue Nachrichten aus.", - "defaultSenderSettingsFirstAccount": "Erstes Konto ({email})", - "defaultSenderSettingsAliasInfo": "Du kannst Alias E-Mail Adressen in den [AS] festlegen.", - "defaultSenderSettingsAliasAccountSettings": "Konto-Einstellungen", - "replySettingsTitle": "Nachrichten Format", - "replySettingsIntro": "In welchem Format möchtest du Nachrichten schreiben?", - "replySettingsFormatHtml": "Immer HTML", - "replySettingsFormatSameAsOriginal": "Im selben Format wie die Orignal-Nachricht", - "replySettingsFormatPlainText": "Immer nur Text", - "moveTitle": "Nachricht verschieben", - "moveSuccess": "In {mailbox} verschoben.", - "editorArtInputLabel": "Deine Eingabe", - "editorArtInputHint": "Hier Text eingeben", - "editorArtWaitingForInputHint": "warte auf Eingabe", - "fontSerifBold": "Serif fett", - "fontSerifItalic": "Serif kursiv", - "fontSerifBoldItalic": "Serif fett kursiv", - "fontSans": "Sans", - "fontSansBold": "Sans fett", - "fontSansItalic": "Sans kursiv", - "fontSansBoldItalic": "Sans fett kursiv", - "fontScript": "Script", - "fontScriptBold": "Script fett", - "fontFraktur": "Fraktur", - "fontFrakturBold": "Fraktur fett", - "fontMonospace": "Monospace", - "fontFullwidth": "Fullwidth", - "fontDoublestruck": "Double struck", - "fontCapitalized": "Grossbuchstaben", - "fontCircled": "Eingekreist", - "fontParenthesized": "Geklammert", - "fontUnderlinedSingle": "Unterstrichen", - "fontUnderlinedDouble": "Doppelt unterstrichen", - "fontStrikethroughSingle": "Durchgestrichen", - "fontCrosshatch": "Crosshatch", - "accountLoadError": "Keine Verbindung mit {name} möglich. Wurde vielleicht das Passwort geändert?", - "accountLoadErrorEditAction": "Konto bearbeiten", - "extensionsTitle": "Erweiterungen", - "extensionsIntro": "Mit Erweiterungen können E-Mail-Dienstleister, Firmen und Entwickler:innen Maily mit hilfreichen Funktionen ergänzen.", - "extensionsLearnMoreAction": "Lerne mehr über Erweiterungen", - "extensionsReloadAction": "Erweiterungen neu laden", - "extensionDeactivateAllAction": "Alle Erweiterungen deaktivieren", - "extensionsManualAction": "Manuell laden", - "extensionsManualUrlLabel": "Url der Erweiterung", - "extensionsManualLoadingError": "Es kann keine Erweiterung von \"{url}\" heruntergeladen werden.", - "icalendarAcceptTentatively": "Vorbehaltlich", - "icalendarActionChangeParticipantStatus": "Ändern", - "icalendarLabelSummary": "Titel", - "icalendarNoSummaryInfo": "(kein Titel)", - "icalendarLabelDescription": "Beschreibung", - "icalendarLabelStart": "Start", - "icalendarLabelEnd": "Ende", - "icalendarLabelDuration": "Dauer", - "icalendarLabelTeamsUrl": "Link", - "icalendarLabelLocation": "Ort", - "icalendarLabelRecurrenceRule": "Wiederholung", - "icalendarLabelParticipants": "Teilnehmer", - "icalendarParticipantStatusNeedsAction": "Du wirst gebeten, diese Einladung zu beantworten.", - "icalendarParticipantStatusAccepted": "Du hast die Einladung akzeptiert.", - "icalendarParticipantStatusDeclined": "Du hast die Einladung abgelehnt.", - "icalendarParticipantStatusAcceptedTentatively": "Du hast die Einladung vorbehaltlich akzeptiert.", - "icalendarParticipantStatusDelegated": "Du hast die Teilnahme delegiert.", - "icalendarParticipantStatusInProcess": "Die Aufgabe wird bearbeitet.", - "icalendarParticipantStatusPartial": "Die Aufgabe ist teilweise erledigt.", - "icalendarParticipantStatusCompleted": "Die Aufgabe ist erledigt.", - "icalendarParticipantStatusOther": "Der Status ist unbekannt.", - "icalendarParticipantStatusChangeTitle": "Dein Status", - "icalendarParticipantStatusChangeText": "Möchtest Du an diese Einladung annehmen?", - "icalendarParticipantStatusSentFailure": "Antwort kann nicht gesendet werrden.\nDer Server hat mit den folgenden Details geantwortet:\n{details}", - "icalendarExportAction": "Exportieren", - "icalendarReplyStatusNeedsAction": "{attendee} hat diese Einladung nicht beantwortet.", - "icalendarReplyStatusAccepted": "{attendee} hat die Einladung akzeptiert.", - "icalendarReplyStatusDeclined": "{attendee} hat die Einladung abgelehnt.", - "icalendarReplyStatusAcceptedTentatively": "{attendee} hat die Einladung vorbehaltlich akzeptiert.", - "icalendarReplyStatusDelegated": "{attendee} hat die Teilnahme delegiert.", - "icalendarReplyStatusInProcess": "{attendee} hat mit der Aufgabe begonnen.", - "icalendarReplyStatusPartial": "{attendee} hat die Aufgabe teilweise erledigt.", - "icalendarReplyStatusCompleted": "{attendee} hat die Aufgabe erledigt.", - "icalendarReplyStatusOther": "{attendee} hat mit einem unbekannten Status geantwortet.", - "icalendarReplyWithoutParticipants": "Diese Antwort enthält keine Teilnehmer:innen.", - "icalendarReplyWithoutStatus": "{attendee} hat eine Antwort ohne Teilnahme-Status gesendet.", - "composeAppointmentTitle": "Einladung erstellen", - "composeAppointmentLabelDay": "Tag", - "composeAppointmentLabelTime": "Zeit", - "composeAppointmentLabelAllDayEvent": "Ganztägiger Termin", - "composeAppointmentLabelRepeat": "Wiederholen", - "composeAppointmentLabelRepeatOptionNever": "Nie", - "composeAppointmentLabelRepeatOptionDaily": "Täglich", - "composeAppointmentLabelRepeatOptionWeekly": "Wöchentlich", - "composeAppointmentLabelRepeatOptionMonthly": "Monatlich", - "composeAppointmentLabelRepeatOptionYearly": "Jährlich", - "composeAppointmentRecurrenceFrequencyLabel": "Frequenz", - "composeAppointmentRecurrenceIntervalLabel": "Intervall", - "composeAppointmentRecurrenceDaysLabel": "An Tagen", - "composeAppointmentRecurrenceUntilLabel": "Bis", - "composeAppointmentRecurrenceUntilOptionUnlimited": "Unlimitiert", - "composeAppointmentRecurrenceUntilOptionRecommended": "Empfohlen ({duration})", - "composeAppointmentRecurrenceUntilOptionSpecificDate": "Bestimmtes Datum", - "composeAppointmentRecurrenceMonthlyOnDayOfMonth": "Am {day}. Tag des Monats", - "composeAppointmentRecurrenceMonthlyOnWeekDay": "Am Wochentag des Monats", - "composeAppointmentRecurrenceFirst": "Erster", - "composeAppointmentRecurrenceSecond": "Zweiter", - "composeAppointmentRecurrenceThird": "Dritter", - "composeAppointmentRecurrenceLast": "Letzter", - "composeAppointmentRecurrenceSecondLast": "Vorletzter", - "durationYears": "{number,plural, =1{1 Jahr} other{{number} Jahre}}", - "durationMonths": "{number,plural, =1{1 Monat} other{{number} Monate}}", - "durationWeeks": "{number,plural, =1{1 Woche} other{{number} Wochen}}", - "durationDays": "{number,plural, =1{1 Tag} other{{number} Tage}}", - "durationHours": "{number,plural, =1{1 Stunde} other{{number} Stunden}}", - "durationMinutes": "{number,plural, =1{1 Minute} other{{number} Minuten}}", - "durationEmpty": "Keine Dauer" -} \ No newline at end of file diff --git a/lib/l10n/extension.dart b/lib/l10n/extension.dart deleted file mode 100644 index 5a88199..0000000 --- a/lib/l10n/extension.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -import 'app_localizations.g.dart'; -import 'app_localizations_en.g.dart'; - -extension AppLocalizationBuildContext on BuildContext { - /// Retrieves the current localizations - AppLocalizations get text => - AppLocalizations.of(this) ?? AppLocalizationsEn(); -} diff --git a/lib/lifecycle.dart b/lib/lifecycle.dart deleted file mode 100644 index 20d7488..0000000 --- a/lib/lifecycle.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:enough_mail_app/events/app_event_bus.dart'; -import 'package:flutter/material.dart'; - -class LifecycleManager extends StatefulWidget { - final Widget child; - const LifecycleManager({Key? key, required this.child}) : super(key: key); - - @override - State createState() => _LifecycleManagerState(); -} - -class _LifecycleManagerState extends State - with WidgetsBindingObserver { - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - AppEventBus.eventBus.fire(state); - } - - @override - void initState() { - WidgetsBinding.instance.addObserver(this); - super.initState(); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return widget.child; - } -} diff --git a/lib/localization/app_de.arb b/lib/localization/app_de.arb new file mode 100644 index 0000000..5c6c847 --- /dev/null +++ b/lib/localization/app_de.arb @@ -0,0 +1,2437 @@ +{ + "@@locale": "de", + "signature": "Mit Maily gesendet", + "@signature": { + "description": "Default signature text" + }, + "actionCancel": "Abbrechen", + "@actionCancel": { + "description": "Generic cancel action" + }, + "actionOk": "OK", + "@actionOk": { + "description": "Generic OK action" + }, + "actionDone": "Fertig", + "@actionDone": { + "description": "Generic done action" + }, + "actionNext": "Weiter", + "@actionNext": { + "description": "Generic next action" + }, + "actionSkip": "Überspringen", + "@actionSkip": { + "description": "Generic skip action" + }, + "actionUndo": "Rückgängig", + "@actionUndo": { + "description": "Generic undo action" + }, + "actionDelete": "Löschen", + "@actionDelete": { + "description": "Generic delete action" + }, + "actionAccept": "Akzeptieren", + "@actionAccept": { + "description": "Generic accept action" + }, + "actionDecline": "Ablehnen", + "@actionDecline": { + "description": "Generic decline action" + }, + "actionEdit": "Bearbeiten", + "@actionEdit": { + "description": "Generic edit action" + }, + "actionAddressCopy": "Kopieren", + "@actionAddressCopy": { + "description": "Copy action for email addresses" + }, + "actionAddressCompose": "Neue Nachricht", + "@actionAddressCompose": { + "description": "Compose action for email addresses" + }, + "actionAddressSearch": "Suchen", + "@actionAddressSearch": { + "description": "Search action for email addresses" + }, + "splashLoading1": "Maily startet...", + "@splashLoading1": { + "description": "Message shown on splash screen while loading" + }, + "splashLoading2": "Maily fängt an zu arbeiten...", + "@splashLoading2": { + "description": "Message shown on splash screen while loading" + }, + "splashLoading3": "Maily startet in 10, 9, 8...", + "@splashLoading3": { + "description": "Message shown on splash screen while loading" + }, + "welcomePanel1Title": "Maily", + "@welcomePanel1Title": { + "description": "Welcome panel title" + }, + "welcomePanel1Text": "Willkommen zu Maily, deinem freundlichen und schnellen E-Mail Helferlein!", + "@welcomePanel1Text": { + "description": "Welcome message shown on first panel" + }, + "welcomePanel2Title": "Konten", + "@welcomePanel2Title": { + "description": "Welcome panel title" + }, + "welcomePanel2Text": "Verwalte beliebig viele E-Mail Konten. Lese und suche Mails in allen Konten gleichzeitig", + "@welcomePanel2Text": { + "description": "Welcome message shown on second panel" + }, + "welcomePanel3Title": "Wisch und drück mich!", + "@welcomePanel3Title": { + "description": "Welcome panel title" + }, + "welcomePanel3Text": "Wische eine E-Mail um sie zu löschen oder als gelesen zu markieren. Halte eine E-Mail lange um mehrere gleichzeitig zu bearbeiten.", + "@welcomePanel3Text": { + "description": "Welcome message shown on third panel" + }, + "welcomePanel4Title": "Halte Deinen Posteingang sauber", + "@welcomePanel4Title": { + "description": "Welcome panel title" + }, + "welcomePanel4Text": "Melde Dich von Newslettern mit einem Klick ab.", + "@welcomePanel4Text": { + "description": "Welcome message shown on fourth panel" + }, + "welcomeActionSignIn": "Melde dich bei deinem E-Mail Konto an", + "@welcomeActionSignIn": { + "description": "Button showing login option" + }, + "homeSearchHint": "Deine Suche", + "@homeSearchHint": { + "description": "Hint shown in empty search field" + }, + "homeActionsShowAsStack": "Stapel Modus", + "@homeActionsShowAsStack": { + "description": "Action to show mails as stack" + }, + "homeActionsShowAsList": "Listen Modus", + "@homeActionsShowAsList": { + "description": "Action to show mails as list" + }, + "homeEmptyFolderMessage": "Alles fertig!\n\nEs gibt keine E-Mails in diesem Ordner.", + "@homeEmptyFolderMessage": { + "description": "Message shown when there are no messages in the folder" + }, + "homeEmptySearchMessage": "Keine E-Mails gefunden.", + "@homeEmptySearchMessage": { + "description": "Message shown when there are no messages found in a search query" + }, + "homeDeleteAllTitle": "Bestätigung", + "@homeDeleteAllTitle": { + "description": "Title of confirmation dialog when deleting all messages" + }, + "homeDeleteAllQuestion": "Wirklich alle E-Mails löschen?", + "@homeDeleteAllQuestion": { + "description": "Question in confirmation dialog when deleting all messages" + }, + "homeDeleteAllAction": "Alle löschen", + "@homeDeleteAllAction": { + "description": "Action to tap to delete all messages (must be short)." + }, + "homeDeleteAllScrubOption": "Endgültig löschen", + "@homeDeleteAllScrubOption": { + "description": "Option to remove deleted messages from disk." + }, + "homeDeleteAllSuccess": "Alle E-Mails gelöscht.", + "@homeDeleteAllSuccess": { + "description": "Message shown after all messages have been deleted." + }, + "homeMarkAllSeenAction": "Gelesen", + "@homeMarkAllSeenAction": { + "description": "Action to tap to mark all messages as seen / read (must be short)." + }, + "homeMarkAllUnseenAction": "Ungelesen", + "@homeMarkAllUnseenAction": { + "description": "Action to tap to mark all messages as unseen / unread (must be short)." + }, + "homeFabTooltip": "Neue E-Mail", + "@homeFabTooltip": { + "description": "Tooltip for 'compose new message' floating action button." + }, + "homeLoadingMessageSourceTitle": "Lade Daten...", + "@homeLoadingMessageSourceTitle": { + "description": "Title shown while message source itself is being loaded." + }, + "homeLoading": "Lade {name}...", + "@homeLoading": { + "description": "Message shown while loading message.", + "placeholders": { + "name": { + "type": "String", + "example": "Inbox" + } + } + }, + "swipeActionToggleRead": "Gelesen / ungelesen", + "@swipeActionToggleRead": { + "description": "Swipe action for marking a message as read / unread." + }, + "swipeActionDelete": "Löschen", + "@swipeActionDelete": { + "description": "Swipe action for deleting a message." + }, + "swipeActionMarkJunk": "Als Spam markieren", + "@swipeActionMarkJunk": { + "description": "Swipe action for moving a message to junk." + }, + "swipeActionArchive": "Archivieren", + "@swipeActionArchive": { + "description": "Swipe action for moving a message to archive." + }, + "swipeActionFlag": "Markieren", + "@swipeActionFlag": { + "description": "Swipe action for marking a message as flagged / unflagged." + }, + "multipleMovedToJunk": "{number,plural, =1{Eine Nachricht als Spam markiert} other{{number} Nachrichten als Spam markiert}}", + "@multipleMovedToJunk": { + "description": "Message shown after moving messages to junk. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleMovedToInbox": "{number,plural, =1{Eine Nachricht in Inbox geschoben} other{{number} Nachrichten in Inbox geschoben}}", + "@multipleMovedToInbox": { + "description": "Message shown after moving messages from junk, trash or archive back to the Inbox. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleMovedToArchive": "{number,plural, =1{Eine Nachricht archiviert} other{{number} Nachrichten archiviert}}", + "@multipleMovedToArchive": { + "description": "Message shown after moving messages to archive. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleMovedToTrash": "{number,plural, =1{Eine Nachricht gelöscht} other{{number} Nachrichten gelöscht}}", + "@multipleMovedToTrash": { + "description": "Message shown after moving messages to trash. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleSelectionNeededInfo": "Wähle mindestens eine Nachricht aus.", + "@multipleSelectionNeededInfo": { + "description": "Short info shown when a multiple message action is triggered without selecting at least one message first." + }, + "multipleMoveTitle": "{number,plural, =1{Nachricht verschieben} other{{number} Nachrichten verschieben}}", + "@multipleMoveTitle": { + "description": "Title of move dialog for multiple messages. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "messageActionMultipleMarkSeen": "Als gelesen markieren", + "@messageActionMultipleMarkSeen": { + "description": "Action for several messages." + }, + "messageActionMultipleMarkUnseen": "Als ungelesen markieren", + "@messageActionMultipleMarkUnseen": { + "description": "Action for several messages." + }, + "messageActionMultipleMarkFlagged": "Markieren", + "@messageActionMultipleMarkFlagged": { + "description": "Action for several messages." + }, + "messageActionMultipleMarkUnflagged": "Markierung entfernen", + "@messageActionMultipleMarkUnflagged": { + "description": "Action for several messages." + }, + "messageActionViewInSafeMode": "Ohne externe Inhalte anzeigen", + "@messageActionMultipleViewInSafeMode": { + "description": "Action for message." + }, + "emailSenderUnknown": "", + "@emailSenderUnknown": { + "description": "Shown as replacement when there is no known sender of a message." + }, + "dateRangeFuture": "Zukunft", + "@dateRangeFuture": { + "description": "Date range title." + }, + "dateRangeTomorrow": "morgen", + "@dateRangeTomorrow": { + "description": "Date range title." + }, + "dateRangeToday": "heute", + "@dateRangeToday": { + "description": "Date range title." + }, + "dateRangeYesterday": "gestern", + "@dateRangeYesterday": { + "description": "Date range title." + }, + "dateRangeCurrentWeek": "diese Woche", + "@dateRangeCurrentWeek": { + "description": "Date range title." + }, + "dateRangeLastWeek": "letzte Woche", + "@dateRangeLastWeek": { + "description": "Date range title." + }, + "dateRangeCurrentMonth": "diesen Monat", + "@dateRangeCurrentMonth": { + "description": "Date range title." + }, + "dateRangeLastMonth": "letzten Monat", + "@dateRangeLastMonth": { + "description": "Date range title." + }, + "dateRangeCurrentYear": "dieses Jahr", + "@dateRangeCurrentYear": { + "description": "Date range title." + }, + "dateRangeLongAgo": "lange her", + "@dateRangeLongAgo": { + "description": "Date range title." + }, + "dateUndefined": "undefiniert", + "@dateUndefined": { + "description": "Unknown date." + }, + "dateDayToday": "heute", + "@dateDayToday": { + "description": "Message data is today." + }, + "dateDayYesterday": "gestern", + "@dateDayYesterday": { + "description": "Message data is yesterday." + }, + "dateDayLastWeekday": "letzten {day}", + "@dateDayLastWeekday": { + "description": "Message data is a recent weekday.", + "placeholders": { + "day": { + "type": "String", + "example": "Tuesday" + } + } + }, + "drawerEntryAbout": "Über Maily", + "@drawerEntryAbout": { + "description": "Menu entry for about." + }, + "drawerEntrySettings": "Einstellungen", + "@drawerEntrySettings": { + "description": "Menu entry for settings." + }, + "drawerAccountsSectionTitle": "{number,plural, =1{1 Konto} other{{number} Konten}}", + "@drawerAccountsSectionTitle": { + "description": "Title shown for accounts drop down. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "drawerEntryAddAccount": "Konto hinzufügen", + "@drawerEntryAddAccount": { + "description": "Menu entry for adding a new account." + }, + "unifiedAccountName": "Alle Konten", + "@unifiedAccountName": { + "description": "Name of unified account." + }, + "unifiedFolderInbox": "Alle Posteingänge", + "@unifiedFolderInbox": { + "description": "Folder name of unified account." + }, + "unifiedFolderSent": "Alle Gesendeten", + "@unifiedFolderSent": { + "description": "Folder name of unified account." + }, + "unifiedFolderDrafts": "Alle Entwürfe", + "@unifiedFolderDrafts": { + "description": "Folder name of unified account." + }, + "unifiedFolderTrash": "Alle Gelöschten", + "@unifiedFolderTrash": { + "description": "Folder name of unified account." + }, + "unifiedFolderArchive": "Alle Archivierten", + "@unifiedFolderArchive": { + "description": "Folder name of unified account." + }, + "unifiedFolderJunk": "Alle Spam Nachrichten", + "@unifiedFolderJunk": { + "description": "Folder name of unified account." + }, + "folderInbox": "Posteingang", + "@folderInbox": { + "description": "Folder name." + }, + "folderSent": "Gesendete Nachrichten", + "@folderSent": { + "description": "Folder name." + }, + "folderDrafts": "Entwürfe", + "@folderDrafts": { + "description": "Folder name." + }, + "folderTrash": "Papierkorb", + "@folderTrash": { + "description": "Folder name." + }, + "folderArchive": "Archiv", + "@folderArchive": { + "description": "Folder name." + }, + "folderJunk": "Spam Nachrichten", + "@folderJunk": { + "description": "Folder name." + }, + "folderUnknown": "Unbekannt", + "@folderUnknown": { + "description": "Folder name for a message source without a name." + }, + "viewContentsAction": "Inhalt anzeigen", + "@viewContentsAction": { + "description": "Show contents of a message on a separate screen." + }, + "viewSourceAction": "Sourcecode anzeigen", + "@viewSourceAction": { + "description": "Show source code of a message." + }, + "detailsErrorDownloadInfo": "E-Mail konnte nicht geladen werden.", + "@detailsErrorDownloadInfo": { + "description": "Info shown when an email could not be downloaded." + }, + "detailsErrorDownloadRetry": "wiederholen", + "@detailsErrorDownloadRetry": { + "description": "Retry action shown when an email could not be downloaded." + }, + "detailsHeaderFrom": "Von", + "@detailsHeaderFrom": { + "description": "Label for sender(s) of email." + }, + "detailsHeaderTo": "An", + "@detailsHeaderTo": { + "description": "Label for [to] recipient(s) of email." + }, + "detailsHeaderCc": "CC", + "@detailsHeaderCc": { + "description": "Label for [CC] - carbon copy - recipient(s) of email." + }, + "detailsHeaderBcc": "BCC", + "@detailsHeaderBcc": { + "description": "Label for [BCC] - blind carbon copy - recipient(s) of email." + }, + "detailsHeaderDate": "Datum", + "@detailsHeaderDate": { + "description": "Label for date of email." + }, + "subjectUndefined": "", + "@subjectUndefined": { + "description": "Shown instead of the subject when it is undefined." + }, + "detailsActionShowImages": "Bilder anzeigen", + "@detailsActionShowImages": { + "description": "Action for showing images. Only visible when external images are blocked." + }, + "detailsNewsletterActionUnsubscribe": "Abbestellen", + "@detailsNewsletterActionUnsubscribe": { + "description": "Action shown for unsubscribable newsletter." + }, + "detailsNewsletterActionResubscribe": "Neu abbonieren", + "@detailsNewsletterActionResubscribe": { + "description": "Action shown after re-subscribable newsletter has been unsubscribed." + }, + "detailsNewsletterStatusUnsubscribed": "Abbestellt", + "@detailsNewsletterStatusUnsubscribed": { + "description": "Status shown for unsubscribed newsletter." + }, + "detailsNewsletterUnsubscribeDialogTitle": "Abbestellen", + "@detailsNewsletterUnsubscribeDialogTitle": { + "description": "Title for unsubscribe newsletter dialog." + }, + "detailsNewsletterUnsubscribeDialogQuestion": "Möchtest du die Mailingliste {listName} abbestellen?", + "@detailsNewsletterUnsubscribeDialogQuestion": { + "description": "Question for unsubscribe newsletter dialog.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterUnsubscribeDialogAction": "Abbestellen", + "@detailsNewsletterUnsubscribeDialogAction": { + "description": "Action for unsubscribe newsletter dialog." + }, + "detailsNewsletterUnsubscribeSuccessTitle": "Abbestellt", + "@detailsNewsletterUnsubscribeSuccessTitle": { + "description": "Title for dialog after unsubscribing newsletter successfully." + }, + "detailsNewsletterUnsubscribeSuccessMessage": "Du hast dich von der Mailingliste {listName} erfolgreich abgemeldet.", + "@detailsNewsletterUnsubscribeSuccessMessage": { + "description": "Text confirmation after successfully unsubscribing a newsletter.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterUnsubscribeFailureTitle": "Nicht abbestellt", + "@detailsNewsletterUnsubscribeFailureTitle": { + "description": "Title for dialog after unsubscribing newsletter failed." + }, + "detailsNewsletterUnsubscribeFailureMessage": "Entschuldige, ich konnte dich nicht automatisch von {listName} abmelden.", + "@detailsNewsletterUnsubscribeFailureMessage": { + "description": "Text confirmation after unsubscribing a newsletter failed.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterResubscribeDialogTitle": "Abonnieren", + "@detailsNewsletterResubscribeDialogTitle": { + "description": "Title for re-subscribe newsletter dialog." + }, + "detailsNewsletterResubscribeDialogQuestion": "Möchtest du die Mailingliste {listName} wieder abonnieren?", + "@detailsNewsletterResubscribeDialogQuestion": { + "description": "Question for re-subscribe newsletter dialog.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterResubscribeDialogAction": "Abonnieren", + "@detailsNewsletterResubscribeDialogAction": { + "description": "Action for re-subscribe newsletter dialog." + }, + "detailsNewsletterResubscribeSuccessTitle": "Aboniert", + "@detailsNewsletterResubscribeSuccessTitle": { + "description": "Title for dialog after re-subscribed newsletter successfully." + }, + "detailsNewsletterResubscribeSuccessMessage": "Du hast wieder die Mailingliste {listName} abonniert.", + "@detailsNewsletterResubscribeSuccessMessage": { + "description": "Text confirmation after successfully re-subscribing a newsletter.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterResubscribeFailureTitle": "Nicht abonniert", + "@detailsNewsletterResubscribeFailureTitle": { + "description": "Title for dialog after re-subscribing newsletter failed." + }, + "detailsNewsletterResubscribeFailureMessage": "Entschuldige, ich konnte dich leider nicht automatisch bei der Mailingliste {listName} anmelden.", + "@detailsNewsletterResubscribeFailureMessage": { + "description": "Text confirmation after re-subscribing a newsletter failed.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsSendReadReceiptAction": "Lesebestätigung senden", + "@detailsSendReadReceiptAction": { + "description": "Action to send the read receipt for the shown message." + }, + "detailsReadReceiptSentStatus": "Lesebestätigung gesendet ✔", + "@detailsReadReceiptSentStatus": { + "description": "Status after sending the read receipt for the shown message." + }, + "detailsReadReceiptSubject": "Lesebestätigung", + "@detailsReadReceiptSubject": { + "description": "Message subject for read receipts." + }, + "attachmentActionOpen": "Öffnen", + "@attachmentActionOpen": { + "description": "Open action for attachments without interactive viewer." + }, + "attachmentDecodeError": "Dieses Attachment ist in einem unbekannten Format.\nDetails: ${details}", + "@attachmentDecodeError": { + "description": "Text shown when downloaded attachment could not be decoded.", + "placeholders": { + "details": { + "type": "String", + "example": "FormatException" + } + } + }, + "attachmentDownloadError": "Dieses Attachment konnte nicht heruntergeladen werden.\nDetails: ${details}", + "@attachmentDownloadError": { + "description": "Text shown when attachment could not be downloaded.", + "placeholders": { + "details": { + "type": "String", + "example": "NotFound" + } + } + }, + "messageActionReply": "Antworten", + "@messageActionReply": { + "description": "Action for single message." + }, + "messageActionReplyAll": "Allen antworten", + "@messageActionReplyAll": { + "description": "Action for single message." + }, + "messageActionForward": "Weiterleiten", + "@messageActionForward": { + "description": "Action for single message." + }, + "messageActionForwardAsAttachment": "Als Anhang weiterleiten", + "@messageActionForwardAsAttachment": { + "description": "Action for single message." + }, + "messageActionForwardAttachments": "{number,plural, =1{Anhang weiterleiten} other{{number} Anhänge weiterleiten}}", + "@messageActionForwardAttachments": { + "description": "Action for single message to forward the given number of attachments.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "messagesActionForwardAttachments": "Anhänge weiterleiten", + "@messagesActionForwardAttachments": { + "description": "Action for multiple selected messages to forward all attachments of the messages." + }, + "messageActionDelete": "Löschen", + "@messageActionDelete": { + "description": "Action for single message." + }, + "messageActionMoveToInbox": "In Posteingang verschieben", + "@messageActionMoveToInbox": { + "description": "Action for single message." + }, + "messageActionMove": "Verschieben", + "@messageActionMove": { + "description": "Action for single message." + }, + "messageStatusSeen": "Ist gelesen", + "@messageStatusSeen": { + "description": "Status of single message." + }, + "messageStatusUnseen": "Ist ungelesen", + "@messageStatusUnseen": { + "description": "Status of single message." + }, + "messageStatusFlagged": "Ist markiert", + "@messageStatusFlagged": { + "description": "Status of single message." + }, + "messageStatusUnflagged": "Ist nicht markiert", + "@messageStatusUnflagged": { + "description": "Status of single message." + }, + "messageActionMarkAsJunk": "Als Spam markieren", + "@messageActionMarkAsJunk": { + "description": "Action for single message." + }, + "messageActionMarkAsNotJunk": "Als nicht-Spam markieren", + "@messageActionMarkAsNotJunk": { + "description": "Action for single message." + }, + "messageActionArchive": "Archivieren", + "@messageActionArchive": { + "description": "Action for single message." + }, + "messageActionUnarchive": "In Posteingang verschieben", + "@messageActionUnarchive": { + "description": "Action for single message." + }, + "messageActionRedirect": "Umleiten", + "@messageActionRedirect": { + "description": "Action for single message." + }, + "messageActionAddNotification": "Benachrichtigung hinzufügen", + "@messageActionAddNotification": { + "description": "Action for single message." + }, + "resultDeleted": "Gelöscht", + "@resultDeleted": { + "description": "Successful short snackbar message after deleting message(s)." + }, + "resultMovedToJunk": "Als Spam markiert", + "@resultMovedToJunk": { + "description": "Successful short snackbar message after moving message(s) to junk." + }, + "resultMovedToInbox": "In Posteingang verschoben", + "@resultMovedToInbox": { + "description": "Successful short snackbar message after moving message(s) to inbox." + }, + "resultArchived": "Archiviert", + "@resultArchived": { + "description": "Successful short snackbar message after moving message(s) to archive." + }, + "resultRedirectedSuccess": "Nachricht umgeleitet 👍", + "@resultRedirectedSuccess": { + "description": "Successful snackbar message after redirecting message to new recipient(s)." + }, + "resultRedirectedFailure": "Nachricht konnte nicht umgeleitet werden.\n\nDer Server meldet folgende Details: \"{details}\"", + "@resultRedirectedFailure": { + "description": "Failure snackbar message after failed to redirect message to new recipient(s).", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid recipient" + } + } + }, + "redirectTitle": "Umleiten", + "@redirectTitle": { + "description": "Title of redirect dialog." + }, + "redirectInfo": "Leite diese Nachricht an folgende Empfänger:innen um. Umleiten verändert nicht die Nachricht.", + "@redirectInfo": { + "description": "Short explanation of redirect action in redirect dialog." + }, + "redirectEmailInputRequired": "Bitte gebe mindestens eine gültige E-Mail-Adresse ein.", + "@redirectEmailInputRequired": { + "description": "Information when redirect is wanted but no address has been entered." + }, + "searchQueryDescription": "Suche in {folder}...", + "@searchQueryDescription": { + "description": "Description of search within the given folder.", + "placeholders": { + "folder": { + "type": "String", + "example": "Inbox" + } + } + }, + "searchQueryTitle": "Suche \"{query}\"", + "@searchQueryTitle": { + "description": "Title for a search with the given query.", + "placeholders": { + "query": { + "type": "String", + "example": "a sender name" + } + } + }, + "legaleseUsage": "Durch die Nutzung von Maily stimmst du unserer [PP] und unseren [TC] zu.", + "@legaleseUsage": { + "description": "Legal info shown on initial welcome screen and later in about. [PP] is replaced with the legalesePrivacyPolicy text and [TC] with legaleseTermsAndConditions." + }, + "legalesePrivacyPolicy": "Datenschutzerlärung", + "@legalesePrivacyPolicy": { + "description": "Translation of privacy policy" + }, + "legaleseTermsAndConditions": "Bedingungen", + "@legaleseTermsAndConditions": { + "description": "Translation of Terms & Conditions " + }, + "aboutApplicationLegalese": "Maily ist freie Software, die unter der GPL GNU General Public License veröffentlicht ist.", + "@aboutApplicationLegalese": { + "description": "Legal info shown in about dialog." + }, + "feedbackActionSuggestFeature": "Feature vorschlagen", + "@feedbackActionSuggestFeature": { + "description": "Action to suggest a feature." + }, + "feedbackActionReportProblem": "Problem berichten", + "@feedbackActionReportProblem": { + "description": "Action to report a problem." + }, + "feedbackActionHelpDeveloping": "Hilf Maily zu entwickeln", + "@feedbackActionHelpDeveloping": { + "description": "Action to help developing." + }, + "feedbackTitle": "Feedback", + "@feedbackTitle": { + "description": "Title of feedback settings screen." + }, + "feedbackIntro": "Danke, dass du Maily testest!", + "@feedbackIntro": { + "description": "Intro for feedback settings screen." + }, + "feedbackProvideInfoRequest": "Bitte teile folgende Information mit, wenn du ein Problem berichtest:", + "@feedbackProvideInfoRequest": { + "description": "Request to provide device and app information when reporting a problem." + }, + "feedbackResultInfoCopied": "kopiert", + "@feedbackResultInfoCopied": { + "description": "Info shown after copying device and app info to clipboard." + }, + "accountsTitle": "Konten", + "@accountsTitle": { + "description": "Title of accounts settings screen." + }, + "accountsActionReorder": "Konten Reihenfolge ändern", + "@accountActionReorder": { + "description": "Action to start reordering accounts." + }, + "settingsTitle": "Einstellungen", + "@settingsTitle": { + "description": "Title of base settings screen." + }, + "settingsSecurityBlockExternalImages": "Externe Bilder blockieren", + "@settingsSecurityBlockExternalImages": { + "description": "Settings option to block external options." + }, + "settingsSecurityBlockExternalImagesDescriptionTitle": "Externe Bilder", + "@settingsSecurityBlockExternalImagesDescriptionTitle": { + "description": "Title of dialog that shows additional information about the 'block external images' option." + }, + "settingsSecurityBlockExternalImagesDescriptionText": "E-Mail-Nachrichten können Bilder enthalten, die entweder auf externen Servern integriert oder gehostet werden. Die letzteren externen Bilder können dem Absender der Nachricht Informationen offen legen, z.B. um dem Absender mitzuteilen, dass Sie die Nachricht geöffnet haben. Mit dieser Option können Sie solche externen Bilder blockieren, was das Risiko verringert, sensible Informationen zu enthüllen. Wenn Sie eine Nachricht lesen, können Sie diese Bilder immer noch pro Nachricht laden.", + "@settingsSecurityBlockExternalImagesDescriptionText": { + "description": "Text of dialog that shows additional information about the 'block external images' option." + }, + "settingsSecurityMessageRenderingHtml": "Gesamte Nachricht anzeigen", + "@settingsSecurityMessageRenderingHtml": { + "description": "Option for how to render messages." + }, + "settingsSecurityMessageRenderingPlainText": "Nur den Text der Nachricht anzeigen", + "@settingsSecurityMessageRenderingPlainText": { + "description": "Option for how to render messages." + }, + "settingsSecurityLaunchModeLabel": "Wie soll Maily Links öffnen?", + "@settingsSecurityLaunchModeLabel": { + "description": "Option for how to launch URLs." + }, + "settingsSecurityLaunchModeExternal": "Öffne Links extern", + "@settingsSecurityLaunchModeExternal": { + "description": "Option for how to launch URLs." + }, + "settingsSecurityLaunchModeInApp": "Öffne Links in Maily", + "@settingsSecurityLaunchModeInApp": { + "description": "Option for how to launch URLs." + }, + "settingsActionAccounts": "Konten verwalten", + "@settingsActionAccounts": { + "description": "Settings action to manage accounts." + }, + "settingsActionDesign": "Darstellung", + "@settingsActionDesign": { + "description": "Settings action to manage the visualization of the app." + }, + "settingsActionFeedback": "Feedback geben", + "@settingsActionFeedback": { + "description": "Settings action to provide feedback about the app." + }, + "settingsActionWelcome": "Willkommen anzeigen", + "@settingsActionWelcome": { + "description": "Settings action to show welcome screen of the app again." + }, + "settingsReadReceipts": "Lesebestätigungen", + "@settingsReadReceipts": { + "description": "Settings action to customize read receipts." + }, + "readReceiptsSettingsIntroduction": "Sollen Lesebestätigungs-Anforderungen angezeigt werden?", + "@readReceiptsSettingsIntroduction": { + "description": "Introduction text for managing read receipt requests." + }, + "readReceiptOptionAlways": "Immer", + "@readReceiptOptionAlways": { + "description": "Display option for read receipt requests." + }, + "readReceiptOptionNever": "Nie", + "@readReceiptOptionNever": { + "description": "Display option for read receipt requests." + }, + "settingsFolders": "Ordner", + "@settingsFolders": { + "description": "Settings action to customize folders." + }, + "folderNamesIntroduction": "Welche Ordner-Namen möchtest du nutzen?", + "@folderNamesIntroduction": { + "description": "Introduction for folder names setting." + }, + "folderNamesSettingLocalized": "Von Maily vorgegebene Namen", + "@folderNamesSettingLocalized": { + "description": "Folder name setting option." + }, + "folderNamesSettingServer": "Vom Maildienst gegebene Namen", + "@folderNamesSettingServer": { + "description": "Folder name setting option." + }, + "folderNamesSettingCustom": "Meine eigenen Namen", + "@folderNamesSettingCustom": { + "description": "Folder name setting option." + }, + "folderNamesEditAction": "Eigene Ordner Namen ändern", + "@folderNamesEditAction": { + "description": "Action to specify custom folder names." + }, + "folderNamesCustomTitle": "Eigene Namen", + "@folderNamesCustomTitle": { + "description": "Title of dialog to specify custom folder names." + }, + "folderAddAction": "Ordner erstellen", + "@folderAddAction": { + "description": "Action to create a new folder." + }, + "folderAddTitle": "Ordner erstellen", + "@folderAddTitle": { + "description": "Dialog title when creating a new folder." + }, + "folderAddNameLabel": "Name", + "@folderAddNameLabel": { + "description": "Label for input field for the folder name." + }, + "folderAddNameHint": "Name des Ordners", + "@folderAddNameHint": { + "description": "Hint for input field for the folder name." + }, + "folderAccountLabel": "Konto", + "@folderAddAccountLabel": { + "description": "Label to select the current account." + }, + "folderMailboxLabel": "Erstelle in", + "@folderAddParentFolderLabel": { + "description": "Label to select the current folder." + }, + "folderAddResultSuccess": "Ordner erstellt 😊", + "@folderAddResultSuccess": { + "description": "Info for showing the creation success." + }, + "folderAddResultFailure": "Der Ordner konnte nicht erstellt werden.\n\nDer Server antwortete mit \"{details}\".", + "@folderAddResultFailure": { + "description": "Info for showing a folder creation error.", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid name" + } + } + }, + "folderDeleteAction": "Löschen", + "@folderDeleteAction": { + "description": "Action to delete an existing folder." + }, + "folderDeleteConfirmTitle": "Bestätigen", + "@folderDeleteConfirmTitle": { + "description": "Dialog title to confirm deleting a folder." + }, + "folderDeleteConfirmText": "Möchtest Du den Ordner {name} wirklich löschen?", + "@folderDeleteConfirmText": { + "description": "Dialog text to confirm deleting a folder.", + "placeholders": { + "name": { + "type": "String", + "example": "My Custom Folder" + } + } + }, + "folderDeleteResultSuccess": "Ordner gelöscht.", + "@folderDeleteResultSuccess": { + "description": "Info for showing the creation success." + }, + "folderDeleteResultFailure": "Der Ordner konnte nicht gelöscht werden.\n\nDer Server antwortete mit \"{details}\".", + "@folderDeleteResultFailure": { + "description": "Info for showing a folder deletion error.", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid name" + } + } + }, + "settingsDevelopment": "Entwicklungs-Einstellungen", + "@settingsDevelopment": { + "description": "Settings action to specify the development options." + }, + "developerModeTitle": "Entwicklungs-Modus", + "@developerModeTitle": { + "description": "Title of the development mode section." + }, + "developerModeIntroduction": "Mit einem aktivierten Entwicklungs-Modus kannst du den Sourcecode von Mails einsehen, siehst alle Fehler-Details und Text Anhänge in eine Mail Nachricht umwandeln.", + "@developerModeIntroduction": { + "description": "Text explaining the development mode." + }, + "developerModeEnable": "Entwicklungs-Modus aktivieren", + "@developerModeEnable": { + "description": "Text in checkbox to enable the development mode." + }, + "developerShowAsEmail": "Text zu E-Mail konvertieren", + "@developerShowAsEmail": { + "description": "Action to convert text into an email." + }, + "developerShowAsEmailFailed": "Dieser Text kann nicht in einer MIME Nachricht umgewandelt werden.", + "@developerShowAsEmailFailed": { + "description": "Text shown when text cannot be converted into an email." + }, + "designTitle": "Design Einstellungen", + "@designTitle": { + "description": "Title of design settings screen." + }, + "designSectionThemeTitle": "Modus", + "@designSectionThemeTitle": { + "description": "Title of theme section on design settings screen." + }, + "designThemeOptionLight": "Hell", + "@designThemeOptionLight": { + "description": "Theme option." + }, + "designThemeOptionDark": "Dunkel", + "@designThemeOptionDark": { + "description": "Theme option." + }, + "designThemeOptionSystem": "System", + "@designThemeOptionSystem": { + "description": "Theme option." + }, + "designThemeOptionCustom": "Selbst definieren", + "@designThemeOptionCustom": { + "description": "Theme option." + }, + "designSectionCustomTitle": "Der dunkle Modus wird aktiviert", + "@designSectionCustomTitle": { + "description": "Title of custom theme option section on design settings screen." + }, + "designThemeCustomStart": "von {time}", + "@designThemeCustomStart": { + "description": "Start time of custom theme setting.", + "placeholders": { + "time": { + "type": "String", + "example": "10 PM" + } + } + }, + "designThemeCustomEnd": "bis {time}", + "@designThemeCustomEnd": { + "description": "End time of custom theme setting.", + "placeholders": { + "time": { + "type": "String", + "example": "7 AM" + } + } + }, + "designSectionColorTitle": "Farbschema", + "@designSectionColorTitle": { + "description": "Title of color section on design settings screen." + }, + "securitySettingsTitle": "Sicherheit", + "@securitySettingsTitle": { + "description": "Title of security settings screen." + }, + "securitySettingsIntro": "Passe die Sicherheitseinstellungen deinen persönlichen Ansprüchen an.", + "@securitySettingsIntro": { + "description": "Introduction of security settings screen." + }, + "securityUnlockWithFaceId": "Entsicher Maily mit Face ID.", + "@securityUnlockWithFaceId": { + "description": "iOS-specific unlock reason." + }, + "securityUnlockWithTouchId": "Entsicher Maily mit Touch ID.", + "@securityUnlockWithTouchId": { + "description": "iOS-specific unlock reason." + }, + "securityUnlockReason": "Entsicher Maily.", + "@securityUnlockReason": { + "description": "Generic unlock reason." + }, + "securityUnlockDisableReason": "Entsicher Maily um die Sicherung zu deaktvieren.", + "@securityUnlockDisableReason": { + "description": "Generic unlock disable reason." + }, + "securityUnlockNotAvailable": "Dein Gerät unterstützt keine Biometrie-Absicherung. Vielleicht musst du zuerst die Displaysperre in den Geräteeinstellungen aktivieren.", + "@securityUnlockNotAvailable": { + "description": "Message when biometric authentication is not available." + }, + "securityUnlockLabel": "Maily Absichern", + "@securityUnlockLabel": { + "description": "Label of biometric authentication lock feature." + }, + "securityUnlockDescriptionTitle": "Maily Absichern", + "@securityUnlockDescriptionTitle": { + "description": "Title to explain lock feature via biometric authentication." + }, + "securityUnlockDescriptionText": "Du kannst Maily absichern, so dass anderen deine E-Mails auch dann nicht lesen können, wenn sie Zugang zu deinem Gerät haben.", + "@securityUnlockDescriptionText": { + "description": "Text explaining lock feature via biometric authentication." + }, + "securityLockImmediately": "Sofort absichern", + "@securityLockImmediately": { + "description": "Lock timing option." + }, + "securityLockAfter5Minutes": "Nach 5 Minuten absichern", + "@securityLockAfter5Minutes": { + "description": "Lock timing option." + }, + "securityLockAfter30Minutes": "Nach 30 Minuten absichern", + "@securityLockAfter30Minutes": { + "description": "Lock timing option." + }, + "lockScreenTitle": "Maily ist gesichert", + "@lockScreenTitle": { + "description": "Title of lock screen." + }, + "lockScreenIntro": "Maily ist gesichert, bitte authentifiziere dich um weiter zu machen.", + "@lockScreenIntro": { + "description": "Text on lock screen." + }, + "lockScreenUnlockAction": "Entsichern", + "@lockScreenUnlockAction": { + "description": "Action to unlock on lock screen." + }, + "addAccountTitle": "Konto hinzufügen", + "@addAccountTitle": { + "description": "Title of add account screen." + }, + "addAccountEmailLabel": "E-Mail", + "@addAccountEmailLabel": { + "description": "Label and section header of email address input field." + }, + "addAccountEmailHint": "Deine E-Mail Adresse", + "@addAccountEmailHint": { + "description": "Hint text of email address input field." + }, + "addAccountResolvingSettingsLabel": "Suche {email} Einstellungen...", + "@addAccountResolvingSettingsLabel": { + "description": "Label shown while resolving the settings for the specified email address.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountResolvedSettingsWrongAction": "Nicht bei {provider}?", + "@addAccountResolvedSettingsWrongAction": { + "description": "Button text shown for the user to edit server settings manually when the resolving was successful but turned out a different than expected provider name.", + "placeholders": { + "provider": { + "type": "String", + "example": "gmail" + } + } + }, + "addAccountResolvingSettingsFailedInfo": "Ich konte die Einstellungen für {email} nicht finden. Bitte gehe zurück und ändere die E-Mail Adresse oder gebe die Einstellungen manuell an.", + "@addAccountResolvingSettingsFailedInfo": { + "description": "Info shown after resolving the settings for the specified email address failed.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountEditManuallyAction": "Manuell bearbeiten", + "@addAccountEditManuallyAction": { + "description": "Action shown after account settings could not be automatically be discovered." + }, + "addAccountPasswordLabel": "Passwort", + "@addAccountPasswordLabel": { + "description": "Label and section header of password input field." + }, + "addAccountPasswordHint": "Dein Passwort", + "@addAccountPasswordHint": { + "description": "Hint text of password input field." + }, + "addAccountApplicationPasswordRequiredInfo": "Dieser Anbieter verlangt ein Applikations-spezifisches Passwort.", + "@addAccountApplicationPasswordRequiredInfo": { + "description": "Text shown when the provider of the email account requires app specific passwords to be set up." + }, + "addAccountApplicationPasswordRequiredButton": "App Passwort erstellen", + "@addAccountApplicationPasswordRequiredButton": { + "description": "Button text for setting up app specific password." + }, + "addAccountApplicationPasswordRequiredAcknowledged": "Ich habe bereits ein App Passwort", + "@addAccountApplicationPasswordRequiredAcknowledged": { + "description": "Acknowledgement to be confirmed by user to acknowledge the fact that an app specific password is required." + }, + "addAccountVerificationStep": "Überprüfen", + "@addAccountVerificationStep": { + "description": "Section header of verification/log in step." + }, + "addAccountSetupAccountStep": "Konto Einrichten", + "@addAccountSetupAccountStep": { + "description": "Section header of account setup step." + }, + "addAccountVerifyingSettingsLabel": "Überprüfe {email}...", + "@addAccountVerifyingSettingsLabel": { + "description": "Info shown while the account settings for the given email are verified.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountVerifyingSuccessInfo": "Erfolgreich mit {email} angemeldet.", + "@addAccountVerifyingSuccessInfo": { + "description": "Info shown after the account settings for the given email have been verified.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountVerifyingFailedInfo": "Leider konnte ich dich nicht anmelden. Überprüfe deine E-Mail {email} und dein Passwort.", + "@addAccountVerifyingFailedInfo": { + "description": "Info shown after the account settings for the given email could not be verified.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountOauthOptionsText": "Melde dich mit {provider} an oder erstelle ein Applikations-spezifisches Passwort.", + "@addAccountOauthOptionsText": { + "description": "Info shown oauth process fails and the user can try again or use an app-specific password.", + "placeholders": { + "provider": { + "type": "String", + "example": "Mailbox.org" + } + } + }, + "addAccountOauthSignIn": "Mit {provider} einloggen", + "@addAccountOauthSignIn": { + "description": "Label of button to sign in via oauth with the given provider.", + "placeholders": { + "provider": { + "type": "String", + "example": "Mailbox.org" + } + } + }, + "addAccountOauthSignInGoogle": "Mit Google einloggen", + "@addAccountOauthSignInGoogle": { + "description": "Label of button to sign in via oauth with the Google." + }, + "addAccountOauthSignInWithAppPassword": "Oder erstelle ein Applikations-Passwort:", + "@addAccountOauthSignInWithAppPassword": { + "description": "Info to set up app specific password." + }, + "accountAddImapAccessSetupMightBeRequired": "Vielleicht musst Du bei deinem Anbieter den Zugang für E-Mail Apps aktivieren.", + "@accountAddImapAccessSetupMightBeRequired": { + "description": "Info shown when login fails for a provider that is know to require manual activation of IMAP access." + }, + "addAccountSetupImapAccessButtonLabel": "E-Mail Zugang aktivieren", + "@addAccountSetupImapAccessButtonLabel": { + "description": "Label of button to launch website with instructions." + }, + "addAccountNameOfUserLabel": "Dein Name", + "@addAccountNameOfUserLabel": { + "description": "Label for user name input field." + }, + "addAccountNameOfUserHint": "Name, den Empfänger:innen sehen", + "@addAccountNameOfUserHint": { + "description": "Hint for user name input field." + }, + "addAccountNameOfAccountLabel": "Konto Name", + "@addAccountNameOfAccountLabel": { + "description": "Label for account name input field." + }, + "addAccountNameOfAccountHint": "Gebe den Namen des Kontos an", + "@addAccountNameOfAccountHint": { + "description": "Hint for account name input field." + }, + "editAccountTitle": "Bearbeite {name}", + "@editAccountTitle": { + "description": "Title for screen when editing the account with the given name.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountFailureToConnectInfo": "Maily konnte {name} nicht erreichen.", + "@editAccountFailureToConnectInfo": { + "description": "Info about not being able to connect to the named service. Most common causes are temporary network problems or a changed password.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountFailureToConnectRetryAction": "Wiederholen", + "@editAccountFailureToConnectRetryAction": { + "description": "Action to retry connecting to service again." + }, + "editAccountFailureToConnectChangePasswordAction": "Passwort ändern", + "@editAccountFailureToConnectChangePasswordAction": { + "description": "Action to change password for the service." + }, + "editAccountFailureToConnectFixedTitle": "Verbunden", + "@editAccountFailureToConnectFixedTitle": { + "description": "Title of dialog shown after successfully connecting a failed account again." + }, + "editAccountFailureToConnectFixedInfo": "Das Konto ist wieder verbunden.", + "@editAccountFailureToConnectFixedInfo": { + "description": "Message of dialog shown after successfully connecting a failed account again." + }, + "editAccountIncludeInUnifiedLabel": "zu \"Alle Konten\" hinzufügen", + "@editAccountIncludeInUnifiedLabel": { + "description": "Label for opting this account in/out of the unified account." + }, + "editAccountAliasLabel": "Alias E-Mail Adressen für {email}:", + "@editAccountAliasLabel": { + "description": "Label for any alias addresses that have been specified for the given email address.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "editAccountNoAliasesInfo": "Du hast noch keine bekannten Alias E-Mail Adressen für dieses Konto.", + "@editAccountNoAliasesInfo": { + "description": "Info when there have been no aliases specified for this account." + }, + "editAccountAliasRemoved": "{email} Alias gelöscht", + "@editAccountAliasRemoved": { + "description": "Info given after an alias was removed.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "editAccountAddAliasAction": "Alias hinzufügen", + "@editAccountAddAliasAction": { + "description": "Button text for adding an alias to this account." + }, + "editAccountPlusAliasesSupported": "Unterstützt + Aliase", + "@editAccountPlusAliasesSupported": { + "description": "Info shown when + aliases are supported by this account." + }, + "editAccountCheckPlusAliasAction": "Teste Unterstützung für + Aliase", + "@editAccountCheckPlusAliasAction": { + "description": "Button text for testing of + aliases are supported by this account." + }, + "editAccountBccMyself": "Setze mich auf BCC", + "@editAccountBccMyself": { + "description": "Label of checkbox to enable the BCC-MYSELF-Feature." + }, + "editAccountBccMyselfDescriptionTitle": "Setze mich auf CC", + "@editAccountBccMyselfDescriptionTitle": { + "description": "Title of alert explaining the BCC-MYSELF-Feature." + }, + "editAccountBccMyselfDescriptionText": "Du kannst Dir selbst eine \"BCC\" Kopie von jeder Nachricht schicken, die du von diesem Konto verschickst. Normalerweise ist das nicht nötig und nicht gewollt, weil alle gesendeten Nachrichten im\"Gesendete Nachrichten\" Ordner gespeichert werden.", + "@editAccountBccMyselfDescriptionText": { + "description": "Explanation of the the BCC-MYSELF-Feature." + }, + "editAccountServerSettingsAction": "Bearbeite Server Einstellungen", + "@editAccountServerSettingsAction": { + "description": "Button text for editing the server settings of this account." + }, + "editAccountDeleteAccountAction": "Lösche Konto", + "@editAccountDeleteAccountAction": { + "description": "Button text for deleting this account." + }, + "editAccountDeleteAccountConfirmationTitle": "Bestätige", + "@editAccountDeleteAccountConfirmationTitle": { + "description": "Title for confirmation dialog when deleting this account." + }, + "editAccountDeleteAccountConfirmationQuery": "Möchtest du das Konto {name} löschen?", + "@editAccountDeleteAccountConfirmationQuery": { + "description": "Request to confirm when deleting this account.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountTestPlusAliasTitle": "+ Aliase für {name}", + "@editAccountTestPlusAliasTitle": { + "description": "Title for dialog shown while testing + alias support", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountTestPlusAliasStepIntroductionTitle": "Einleitung", + "@editAccountTestPlusAliasStepIntroductionTitle": { + "description": "Title for introducing concept of + aliases." + }, + "editAccountTestPlusAliasStepIntroductionText": "Dein Konto {accountName} könnte sogenannte + Aliase wie {example} unterstützen.\nEin + Alias hilft dir Deine Identität zu schützen und kann gegen Spam helfen.\nUm dies zu testen, wird eine Nachricht an diese generierte Adresse gesendet. Wenn sie ankommt, dann unterstützt dein Anbieter + Aliase und du kannst leicht neue generieren wenn Du eine E-Mail schreibst.", + "@editAccountTestPlusAliasStepIntroductionText": { + "description": "Text for introducing concept of + aliases.", + "placeholders": { + "accountName": { + "type": "String", + "example": "domain.com" + }, + "example": { + "type": "String", + "example": "someone+example@domain.com" + } + } + }, + "editAccountTestPlusAliasStepTestingTitle": "Testen", + "@editAccountTestPlusAliasStepTestingTitle": { + "description": "Title while testing concept of + aliases." + }, + "editAccountTestPlusAliasStepResultTitle": "Ergebnis", + "@editAccountTestPlusAliasStepResultTitle": { + "description": "Title after testing concept of + aliases." + }, + "editAccountTestPlusAliasStepResultSuccess": "Dein Konto {name} unterstütz + Aliase.", + "@editAccountTestPlusAliasStepResultSuccess": { + "description": "Result when account supports + aliases", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountTestPlusAliasStepResultNoSuccess": "Dein Konto {name} unterstütz leider keine + Aliase.", + "@editAccountTestPlusAliasStepResultNoSuccess": { + "description": "Result when account does not supports + aliases", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountAddAliasTitle": "Alias hinzufügen", + "@editAccountAddAliasTitle": { + "description": "Title when adding new alias." + }, + "editAccountEditAliasTitle": "Alias bearbeiten", + "@editAccountEditAliasTitle": { + "description": "Title when editing alias." + }, + "editAccountAliasAddAction": "Hinzufügen", + "@editAccountAliasAddAction": { + "description": "Action when adding new alias." + }, + "editAccountAliasUpdateAction": "Ändern", + "@editAccountAliasUpdateAction": { + "description": "Action when editing alias." + }, + "editAccountEditAliasNameLabel": "Alias Name", + "@editAccountEditAliasNameLabel": { + "description": "Label for alias name input field." + }, + "editAccountEditAliasEmailLabel": "Alias E-Mail", + "@editAccountEditAliasEmailLabel": { + "description": "Label for alias email input field." + }, + "editAccountEditAliasEmailHint": "Deine Alias E-Mail Adresse", + "@editAccountEditAliasEmailHint": { + "description": "Hint for alias email input field." + }, + "editAccountEditAliasDuplicateError": "Es gibt bereits einen Alias mit {email}.", + "@editAccountEditAliasDuplicateError": { + "description": "Error when the alias email is already known", + "placeholders": { + "email": { + "type": "String", + "example": "you@domain.com" + } + } + }, + "editAccountEnableLogging": "Log aktivieren", + "@editAccountEnableLogging": { + "description": "Label developer mode option to enable logging." + }, + "editAccountLoggingEnabled": "Log aktiviert, bitte neu starten", + "@editAccountLoggingEnabled": { + "description": "Short message shown after the log has been enabled." + }, + "editAccountLoggingDisabled": "Log de-aktiviert, bitte neu starten", + "@editAccountLoggingDisabled": { + "description": "Short message shown after the log has been disabled." + }, + "accountDetailsFallbackTitle": "Server Einstellungen", + "@accountDetailsFallbackTitle": { + "description": "Title shown when account name is not set." + }, + "errorTitle": "Fehler", + "@errorTitle": { + "description": "Title for error dialogs." + }, + "accountProviderStepTitle": "E-Mail Service Anbieter", + "@accountProviderStepTitle": { + "description": "Step to select a provider." + }, + "accountProviderCustom": "Anderer E-Mail Service", + "@accountProviderCustom": { + "description": "When no standard provider is chosen." + }, + "accountDetailsErrorHostProblem": "Maily kann den angegeben Server nicht erreich. Bitte überprüfe die Einstellugen des Posteingang-Servers \"{incomingHost}\" und des Postausgang-Servers \"{outgoingHost}\".", + "@accountDetailsErrorHostProblem": { + "description": "Error details when no connection to server could be established at all", + "placeholders": { + "incomingHost": { + "type": "String", + "example": "imap@domain.com" + }, + "outgoingHost": { + "type": "String", + "example": "smtp@domain.com" + } + } + }, + "accountDetailsErrorLoginProblem": "Anmeldung fehlgeschlagen. Bitte überprüfe den Login-Namen \"{userName}\" und das Passwort \"{password}\".", + "@accountDetailsErrorLoginProblem": { + "description": "Error details when login fails", + "placeholders": { + "userName": { + "type": "String", + "example": "email@domain.com" + }, + "password": { + "type": "String", + "example": "secret" + } + } + }, + "accountDetailsUserNameLabel": "Login Name", + "@accountDetailsUserNameLabel": { + "description": "Label for user name input field." + }, + "accountDetailsUserNameHint": "Dein Login, falls es nicht die E-Mail ist", + "@accountDetailsUserNameHint": { + "description": "Hint for user name input field." + }, + "accountDetailsPasswordLabel": "Login Passwort", + "@accountDetailsPasswordLabel": { + "description": "Label for password input field." + }, + "accountDetailsPasswordHint": "Dein Passwort", + "@accountDetailsPasswordHint": { + "description": "Hint for user password input field." + }, + "accountDetailsBaseSectionTitle": "Basis Einstellungen", + "@accountDetailsBaseSectionTitle": { + "description": "Title of base settings section." + }, + "accountDetailsIncomingLabel": "Posteingangs-Server", + "@accountDetailsIncomingLabel": { + "description": "Label for incoming server domain field." + }, + "accountDetailsIncomingHint": "Domäne wie imap.domain.de", + "@accountDetailsIncomingHint": { + "description": "Hint for incoming server domain field." + }, + "accountDetailsOutgoingLabel": "Postausgangs-Server", + "@accountDetailsOutgoingLabel": { + "description": "Label for outgoing server domain field." + }, + "accountDetailsOutgoingHint": "Domäne wie smtp.domain.de", + "@accountDetailsOutgoingHint": { + "description": "Hint for outgoing server domain field." + }, + "accountDetailsAdvancedIncomingSectionTitle": "Erweiterte Posteingang Einstellungen", + "@accountDetailsAdvancedIncomingSectionTitle": { + "description": "Title of incoming settings section." + }, + "accountDetailsIncomingServerTypeLabel": "Typ des Posteingang Servers:", + "@accountDetailsIncomingServerTypeLabel": { + "description": "Label for server type dropdown." + }, + "accountDetailsOptionAutomatic": "automatisch", + "@accountDetailsOptionAutomatic": { + "description": "Option when the server type/security should be discovered automatically." + }, + "accountDetailsIncomingSecurityLabel": "Posteingang Sicherheit:", + "@accountDetailsIncomingSecurityLabel": { + "description": "Label for server security dropdown." + }, + "accountDetailsSecurityOptionNone": "Plain (keine Verschlüsselung)", + "@accountDetailsSecurityOptionNone": { + "description": "Label for security dropdown option without encryption." + }, + "accountDetailsIncomingPortLabel": "Posteingang Port", + "@accountDetailsIncomingPortLabel": { + "description": "Label for incoming port input field." + }, + "accountDetailsPortHint": "Leer lassen um automatisch finden zu lassen", + "@accountDetailsPortHint": { + "description": "Hint for port input fields." + }, + "accountDetailsIncomingUserNameLabel": "Posteingang Login-Name", + "@accountDetailsIncomingUserNameLabel": { + "description": "Label for incoming user name input field." + }, + "accountDetailsAlternativeUserNameHint": "Login, falls abweichend von oben", + "@accountDetailsAlternativeUserNameHint": { + "description": "Label for alternative user name input fields." + }, + "accountDetailsIncomingPasswordLabel": "Posteingang Passwort", + "@accountDetailsIncomingPasswordLabel": { + "description": "Label for incoming password input field." + }, + "accountDetailsAlternativePasswordHint": "Passwort, falls abweichend von oben", + "@accountDetailsAlternativePasswordHint": { + "description": "Label for alternative user name input fields." + }, + "accountDetailsAdvancedOutgoingSectionTitle": "Erweiterte Postausgang Einstellungen", + "@accountDetailsAdvancedOutgoingSectionTitle": { + "description": "Title of incoming settings section." + }, + "accountDetailsOutgoingServerTypeLabel": "Typ des Postausgang Servers:", + "@accountDetailsOutgoingServerTypeLabel": { + "description": "Label for server type dropdown." + }, + "accountDetailsOutgoingSecurityLabel": "Postausgang Sicherheit:", + "@accountDetailsOutgoingSecurityLabel": { + "description": "Label for server security dropdown." + }, + "accountDetailsOutgoingPortLabel": "Postausgang Port", + "@accountDetailsOutgoingPortLabel": { + "description": "Label for outgoing port input field." + }, + "accountDetailsOutgoingUserNameLabel": "Postausgang Login-Name", + "@accountDetailsOutgoingUserNameLabel": { + "description": "Label for outgoing user name input field." + }, + "accountDetailsOutgoingPasswordLabel": "Postausgang Passwort", + "@accountDetailsOutgoingPasswordLabel": { + "description": "Label for outgoing password input field." + }, + "composeTitleNew": "Neu", + "@composeTitleNew": { + "description": "Title for compose screen when a new message is created." + }, + "composeTitleForward": "Weiterleitung", + "@composeTitleForward": { + "description": "Title for compose screen when a message is forwarded." + }, + "composeTitleReply": "Antwort", + "@composeTitleReply": { + "description": "Title for compose screen when a message is replied." + }, + "composeEmptyMessage": "Leere Nachricht", + "@composeEmptyMessage": { + "description": "Message text for message without text." + }, + "composeWarningNoSubject": "Du hast kein Betreff geschrieben. Möchtest du die Nachricht ohne Betreff senden?", + "@composeWarningNoSubject": { + "description": "Warning shown when trying to send a message without subject." + }, + "composeActionSentWithoutSubject": "Senden", + "@composeActionSentWithoutSubject": { + "description": "Action to send message without subject." + }, + "composeMailSendSuccess": "Gesendet 😊", + "@composeMailSendSuccess": { + "description": "Notification shown after a mail was sent successfully." + }, + "composeSendErrorInfo": "Leider konnte die E-Mail nicht versendet werden.\nDer Postausgang Server liefert folgende Antwort:\n{details}", + "@composeSendErrorInfo": { + "description": "Error shown when email could not be send", + "placeholders": { + "details": { + "type": "String", + "example": "554-Reject due to policy restrictions." + } + } + }, + "composeRequestReadReceiptAction": "Lesebestätigung anfordern", + "@composeRequestReadReceiptAction": { + "description": "Action to request a read receipt for this message" + }, + "composeSaveDraftAction": "Als Entwurf speichern", + "@composeSaveDraftAction": { + "description": "Action to save a message as draft" + }, + "composeMessageSavedAsDraft": "Entwurf gespeichert", + "@composeMessageSavedAsDraft": { + "description": "Info shown when message was saved as draft successfully" + }, + "composeMessageSavedAsDraftErrorInfo": "Der Entwurf konnte nicht gespeichert werden.\nDie Fehlermeldung lautet:\n{details}", + "@composeMessageSavedAsDraftErrorInfo": { + "description": "Info shown when message could not be saved as a draft", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid header: XX" + } + } + }, + "composeConvertToPlainTextEditorAction": "Zu Text-Nachricht konvertieren", + "@composeConvertToPlainTextEditorAction": { + "description": "Action to write a plain text message instead of an html message" + }, + "composeConvertToHtmlEditorAction": "Zu HTML-Nachricht konvertieren", + "@composeConvertToHtmlEditorAction": { + "description": "Action to write a HTML message instead of a text message" + }, + "composeContinueEditingAction": "Weiter bearbeiten", + "@composeContinueEditingAction": { + "description": "Action to return to compose screen when draft cannot be saved" + }, + "composeCreatePlusAliasAction": "Neuen + Alias erstellen...", + "@composeCreatePlusAliasAction": { + "description": "Action to create a new + alias as a sender" + }, + "composeSenderHint": "Absender:in", + "@composeSenderHint": { + "description": "Hint for From input field" + }, + "composeRecipientHint": "E-Mails der Empfänger:innen", + "@composeRecipientHint": { + "description": "Hint for To input field" + }, + "composeSubjectLabel": "Betreff", + "@composeSubjectLabel": { + "description": "Label for Subject input field" + }, + "composeSubjectHint": "Betreff der Nachricht", + "@composeSubjectHint": { + "description": "Hint for Subject input field" + }, + "composeAddAttachmentAction": "Hinzufügen", + "@composeAddAttachmentAction": { + "description": "Action to add an attachment - should be short!" + }, + "composeRemoveAttachmentAction": "{name} entfernen", + "@composeRemoveAttachmentAction": { + "description": "Action to remove an attachment", + "placeholders": { + "name": { + "type": "String", + "example": "funny.png" + } + } + }, + "composeLeftByMistake": "Aus Versehen verlassen?", + "@composeLeftByMistake": { + "description": "Info shown after leaving compose screen to allow an easy return" + }, + "attachTypeFile": "Datei", + "@attachTypeFile": { + "description": "Attachment type to add" + }, + "attachTypePhoto": "Foto", + "@attachTypePhoto": { + "description": "Attachment type to add" + }, + "attachTypeVideo": "Video", + "@attachTypeVideo": { + "description": "Attachment type to add" + }, + "attachTypeAudio": "Audio", + "@attachTypeAudio": { + "description": "Attachment type to add" + }, + "attachTypeLocation": "Ort", + "@attachTypeLocation": { + "description": "Attachment type to add" + }, + "attachTypeGif": "Animiertes Gif", + "@attachTypeGif": { + "description": "Attachment type to add" + }, + "attachTypeGifSearch": "in GIPHY suchen", + "@attachTypeGifSearch": { + "description": "Text for searching in GIPHY service for GIF" + }, + "attachTypeSticker": "Sticker", + "@attachTypeSticker": { + "description": "Attachment type to add" + }, + "attachTypeStickerSearch": "in GIPHY suchen", + "@attachTypeStickerSearch": { + "description": "Text for searching in GIPHY service for sticker" + }, + "attachTypeAppointment": "Termin", + "@attachTypeAppointment": { + "description": "Attachment type to add" + }, + "languageSettingTitle": "Sprache (Language)", + "@languageSettingTitle": { + "description": "Title of language setting screen" + }, + "languageSettingLabel": "Währe die Sprache für Maily:", + "@languageSettingLabel": { + "description": "Label for language setting dropdown screen" + }, + "languageSettingSystemOption": "Systemsprache", + "@languageSettingSystemOption": { + "description": "Option to use the system's settings" + }, + "languageSettingConfirmationTitle": "Deutsch für Maily nutzen?", + "@languageSettingConfirmationTitle": { + "description": "Title of dialog to confirm when switching the language" + }, + "languageSettingConfirmationQuery": "Bitte bestätige, dass deutsch als Sprache verwendet werden soll.", + "@languageSettingConfirmationQuery": { + "description": "Query to be confirmed by user when switching the language" + }, + "languageSetInfo": "Maily ist nun auf deutsch. Bitte starte die App neu.", + "@languageSetInfo": { + "description": "Info text after having specified the language." + }, + "languageSystemSetInfo": "Maily wird nun die Systemsprache oder englisch nutzen, wenn die Systemprache nicht unterstützt wird. Bitte starte die App neu.", + "@languageSystemSetInfo": { + "description": "Info text after choosing the system's language for Maily." + }, + "swipeSettingTitle": "Wischgesten", + "@swipeSettingTitle": { + "description": "Title of swipe setting screen" + }, + "swipeSettingLeftToRightLabel": "Von links nach rechts wischen", + "@swipeSettingLeftToRightLabel": { + "description": "Label for swipe gesture" + }, + "swipeSettingRightToLeftLabel": "Von rechts nach links wischen", + "@swipeSettingRightToLeftLabel": { + "description": "Label for swipe gesture" + }, + "swipeSettingChangeAction": "Ändern", + "@swipeSettingChangeAction": { + "description": "Action for changing a swipe gesture" + }, + "signatureSettingsTitle": "Signatur", + "@signatureSettingsTitle": { + "description": "Title of signature setting screen" + }, + "signatureSettingsComposeActionsInfo": "Aktiviere die Signatur für folgende Nachrichten:", + "@signatureSettingsComposeActionsInfo": { + "description": "Text before selecting message types for a signature (new, forward, reply)." + }, + "signatureSettingsAccountInfo": "Du kannst Signaturen für Konten in den Konten-Einstellungen festlegen.", + "@signatureSettingsAccountInfo": { + "description": "Informational text before showing link to account settings." + }, + "signatureSettingsAddForAccount": "Signature für {account} hinzufügen", + "@signatureSettingsAddForAccount": { + "description": "Action to add account specific signature.", + "placeholders": { + "account": { + "type": "String", + "example": "domain.com" + } + } + }, + "defaultSenderSettingsTitle": "Standard Absender", + "@defaultSenderSettingsTitle": { + "description": "Title of default sender setting screen" + }, + "defaultSenderSettingsLabel": "Wähle den Absender für neue Nachrichten aus.", + "@defaultSenderSettingsLabel": { + "description": "Description of default sender setting screen" + }, + "defaultSenderSettingsFirstAccount": "Erstes Konto ({email})", + "@defaultSenderSettingsFirstAccount": { + "description": "The default sender is the one from the first account", + "placeholders": { + "email": { + "type": "String", + "example": "email@domain.com" + } + } + }, + "defaultSenderSettingsAliasInfo": "Du kannst Alias E-Mail Adressen in den [AS] festlegen.", + "@defaultSenderSettingsAliasInfo": { + "description": "Info about that email aliases can be set up in the account settings. [AS] is the place, where defaultSenderSettingsAliasAccountSettings is included as a link." + }, + "defaultSenderSettingsAliasAccountSettings": "Konto-Einstellungen", + "@defaultSenderSettingsAliasAccountSettings": { + "description": "Text of the account settings link." + }, + "replySettingsTitle": "Nachrichten Format", + "@replySettingsTitle": { + "description": "Reply settings title." + }, + "replySettingsIntro": "In welchem Format möchtest du Nachrichten schreiben?", + "@replySettingsIntro": { + "description": "Reply settings introduction text." + }, + "replySettingsFormatHtml": "Immer HTML", + "@replySettingsFormatHtml": { + "description": "Reply settings option." + }, + "replySettingsFormatSameAsOriginal": "Im selben Format wie die Orignal-Nachricht", + "@replySettingsFormatSameAsOriginal": { + "description": "Reply settings option." + }, + "replySettingsFormatPlainText": "Immer nur Text", + "@replySettingsFormatPlainText": { + "description": "Reply settings option." + }, + "moveTitle": "Nachricht verschieben", + "@moveTitle": { + "description": "Title of move to mailbox dialog." + }, + "moveSuccess": "In {mailbox} verschoben.", + "@moveSuccess": { + "description": "Message after moving message successfully.", + "placeholders": { + "mailbox": { + "type": "String", + "example": "Inbox" + } + } + }, + "editorArtInputLabel": "Deine Eingabe", + "@editorArtInputLabel": { + "description": "Label of input field when inserting a formatted text" + }, + "editorArtInputHint": "Hier Text eingeben", + "@editorArtInputHint": { + "description": "Hint of input field when inserting a formatted text" + }, + "editorArtWaitingForInputHint": "warte auf Eingabe...", + "@editorArtWaitingForInputHint": { + "description": "Text shown while waiting for input" + }, + "fontSerifBold": "Serif fett", + "@fontSerifBold": { + "description": "Font name" + }, + "fontSerifItalic": "Serif kursiv", + "@fontSerifItalic": { + "description": "Font name" + }, + "fontSerifBoldItalic": "Serif fett kursiv", + "@fontSerifBoldItalic": { + "description": "Font name" + }, + "fontSans": "Sans", + "@fontSans": { + "description": "Font name" + }, + "fontSansBold": "Sans fett", + "@fontSansBold": { + "description": "Font name" + }, + "fontSansItalic": "Sans kursiv", + "@fontSansItalic": { + "description": "Font name" + }, + "fontSansBoldItalic": "Sans fett kursiv", + "@fontSansBoldItalic": { + "description": "Font name" + }, + "fontScript": "Skript", + "@fontScript": { + "description": "Font name" + }, + "fontScriptBold": "Script fett", + "@fontScriptBold": { + "description": "Font name" + }, + "fontFraktur": "Fraktur", + "@fontFraktur": { + "description": "Font name" + }, + "fontFrakturBold": "Fraktur fett", + "@fontFrakturBold": { + "description": "Font name" + }, + "fontMonospace": "Monospace", + "@fontMonospace": { + "description": "Font name" + }, + "fontFullwidth": "Fullwidth", + "@fontFullwidth": { + "description": "Font name" + }, + "fontDoublestruck": "Doppelt gestrichen", + "@fontDoublestruck": { + "description": "Font name" + }, + "fontCapitalized": "Grossbuchstaben", + "@fontCapitalized": { + "description": "Font name" + }, + "fontCircled": "Eingekreist", + "@fontCircled": { + "description": "Font name" + }, + "fontParenthesized": "Geklammert", + "@fontParenthesized": { + "description": "Font name" + }, + "fontUnderlinedSingle": "Unterstrichen", + "@fontUnderlinedSingle": { + "description": "Font name" + }, + "fontUnderlinedDouble": "Doppelt unterstrichen", + "@fontUnderlinedDouble": { + "description": "Font name" + }, + "fontStrikethroughSingle": "Durchgestrichen", + "@fontStrikethroughSingle": { + "description": "Font name" + }, + "fontCrosshatch": "Crosshatch", + "@fontCrosshatch": { + "description": "Font name" + }, + "accountLoadError": "Keine Verbindung mit {name} möglich. Wurde vielleicht das Passwort geändert?", + "@accountLoadError": { + "description": "Message shown when single account could not be loaded.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "accountLoadErrorEditAction": "Konto bearbeiten", + "@accountLoadErrorEditAction": { + "description": "Action to edit account" + }, + "extensionsTitle": "Erweiterungen", + "@extensionsTitle": { + "description": "Title of extension section within the development settings" + }, + "extensionsIntro": "Mit Erweiterungen können E-Mail-Dienstleister, Firmen und Entwickler:innen Maily mit hilfreichen Funktionen ergänzen.", + "@extensionsIntro": { + "description": "Explanation of extensions" + }, + "extensionsLearnMoreAction": "Lerne mehr über Erweiterungen", + "@extensionsLearnMoreAction": { + "description": "Label for launching a website with more information" + }, + "extensionsReloadAction": "Erweiterungen neu laden", + "@extensionsReloadAction": { + "description": "Action to refresh extensions" + }, + "extensionDeactivateAllAction": "Alle Erweiterungen deaktivieren", + "@extensionDeactivateAllAction": { + "description": "Action to deactivate / unload any extension" + }, + "extensionsManualAction": "Manuell laden", + "@extensionsManualAction": { + "description": "Action to load extension manually" + }, + "extensionsManualUrlLabel": "Url der Erweiterung", + "@extensionsManualUrlLabel": { + "description": "Label for URL input field" + }, + "extensionsManualLoadingError": "Es kann keine Erweiterung von \"{url}\" heruntergeladen werden.", + "@extensionsManualLoadingError": { + "description": "Message shown when extensions could not be loaded.", + "placeholders": { + "url": { + "type": "String", + "example": "https://domain.com/.maily.json" + } + } + }, + "icalendarAcceptTentatively": "Vorbehaltlich", + "@icalendarAcceptTentatively": { + "description": "Action to accept an icalendar invitation tentatively" + }, + "icalendarActionChangeParticipantStatus": "Ändern", + "@icalendarActionChangeParticipantStatus": { + "description": "Action to change an icalendar invitation participant status" + }, + "icalendarLabelSummary": "Titel", + "@icalendarLabelSummary": { + "description": "Label of the summary info of an icalendar object" + }, + "icalendarNoSummaryInfo": "(kein Titel)", + "@icalendarNoSummaryInfo": { + "description": "Info shown when the icalendar object has no summary (no title)" + }, + "icalendarLabelDescription": "Beschreibung", + "@icalendarLabelDescription": { + "description": "Label of the description info of an icalendar object" + }, + "icalendarLabelStart": "Start", + "@icalendarLabelStart": { + "description": "Label of the start datetime info of an icalendar object" + }, + "icalendarLabelEnd": "Ende", + "@icalendarLabelEnd": { + "description": "Label of the end datetime info of an icalendar object" + }, + "icalendarLabelDuration": "Dauer", + "@icalendarLabelDuration": { + "description": "Label of the duration info of an icalendar object" + }, + "icalendarLabelLocation": "Ort", + "@icalendarLabelLocation": { + "description": "Label of the location info of an icalendar object" + }, + "icalendarLabelTeamsUrl": "Link", + "@icalendarLabelTeamsUrl": { + "description": "Label of the ms teams url info of an icalendar object" + }, + "icalendarLabelRecurrenceRule": "Wiederholung", + "@icalendarLabelRecurrenceRule": { + "description": "Label of the recurrence info of an icalendar object" + }, + "icalendarLabelParticipants": "Teilnehmer", + "@icalendarLabelParticipants": { + "description": "Label of the participants info of an icalendar object" + }, + "icalendarParticipantStatusNeedsAction": "Du wirst gebeten, diese Einladung zu beantworten.", + "@icalendarParticipantStatusNeedsAction": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusAccepted": "Du hast die Einladung akzeptiert.", + "@icalendarParticipantStatusAccepted": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusDeclined": "Du hast die Einladung abgelehnt.", + "@icalendarParticipantStatusDeclined": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusAcceptedTentatively": "Du hast die Einladung vorbehaltlich akzeptiert.", + "@icalendarParticipantStatusAcceptedTentatively": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusDelegated": "Du hast die Teilnahme delegiert.", + "@icalendarParticipantStatusDelegated": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusInProcess": "Die Aufgabe wird bearbeitet.", + "@icalendarParticipantStatusInProcess": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusPartial": "Die Aufgabe ist teilweise erledigt.", + "@icalendarParticipantStatusPartial": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusCompleted": "Die Aufgabe ist erledigt.", + "@icalendarParticipantStatusCompleted": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusOther": "Der Status ist unbekannt.", + "@icalendarParticipantStatusOther": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusChangeTitle": "Dein Status", + "@icalendarParticipantStatusChangeTitle": { + "description": "Title for dialog to change participant status" + }, + "icalendarParticipantStatusChangeText": "Möchtest Du an diese Einladung annehmen?", + "@icalendarParticipantStatusChangeText": { + "description": "Text of dialog to change participant status" + }, + "icalendarParticipantStatusSentFailure": "Antwort kann nicht gesendet werrden.\nDer Server hat mit den folgenden Details geantwortet:\n{details}", + "@icalendarParticipantStatusSentFailure": { + "description": "Failure message for a status change reply", + "placeholders": { + "details": { + "type": "String", + "example": "No internet connection" + } + } + }, + "icalendarExportAction": "Exportieren", + "@icalendarExportAction": { + "description": "Action to export the invite to the native calendar" + }, + "icalendarReplyStatusNeedsAction": "{attendee} hat diese Einladung nicht beantwortet.", + "@icalendarReplyStatusNeedsAction": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusAccepted": "{attendee} hat die Einladung akzeptiert.", + "@icalendarReplyStatusAccepted": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusDeclined": "{attendee} hat die Einladung abgelehnt.", + "@icalendarReplyStatusDeclined": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusAcceptedTentatively": "{attendee} hat die Einladung vorbehaltlich akzeptiert.", + "@icalendarReplyStatusAcceptedTentatively": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusDelegated": "{attendee} hat die Teilnahme delegiert.", + "@icalendarReplyStatusDelegated": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusInProcess": "{attendee} hat mit der Aufgabe begonnen.", + "@icalendarReplyStatusInProcess": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusPartial": "{attendee} hat die Aufgabe teilweise erledigt.", + "@icalendarReplyStatusPartial": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusCompleted": "{attendee} hat die Aufgabe erledigt.", + "@icalendarReplyStatusCompleted": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusOther": "{attendee} hat mit einem unbekannten Status geantwortet.", + "@icalendarReplyStatusOther": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyWithoutParticipants": "Diese Antwort enthält keine Teilnehmer:innen.", + "@icalendarReplyWithoutParticipants": { + "description": "Calendar reply without any participant" + }, + "icalendarReplyWithoutStatus": "{attendee} hat eine Antwort ohne Teilnahme-Status gesendet.", + "@icalendarReplyWithoutStatus": { + "description": "Calendar reply without any participant status", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "composeAppointmentTitle": "Einladung erstellen", + "@composeAppointmentTitle": { + "description": "Title for adding a new appointment screen" + }, + "composeAppointmentLabelDay": "Tag", + "@composeAppointmentLabelDay": { + "description": "Label for select day button" + }, + "composeAppointmentLabelTime": "Zeit", + "@composeAppointmentLabelTime": { + "description": "Label for select time button" + }, + "composeAppointmentLabelAllDayEvent": "Ganztägiger Termin", + "@composeAppointmentLabelAllDayEvent": { + "description": "Label for is all day toggle" + }, + "composeAppointmentLabelRepeat": "Wiederholen", + "@composeAppointmentLabelRepeat": { + "description": "Label for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionNever": "Nie", + "@composeAppointmentLabelRepeatOptionNever": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionDaily": "Täglich", + "@composeAppointmentLabelRepeatOptionDaily": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionWeekly": "Wöchentlich", + "@composeAppointmentLabelRepeatOptionWeekly": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionMonthly": "Monatlich", + "@composeAppointmentLabelRepeatOptionMonthly": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionYearly": "Jährlich", + "@composeAppointmentLabelRepeatOptionYearly": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentRecurrenceFrequencyLabel": "Frequenz", + "@composeAppointmentRecurrenceFrequencyLabel": { + "description": "Label for frequency drop-down" + }, + "composeAppointmentRecurrenceIntervalLabel": "Intervall", + "@composeAppointmentRecurrenceIntervalLabel": { + "description": "Label for interval drop-down" + }, + "composeAppointmentRecurrenceDaysLabel": "An Tagen", + "@composeAppointmentRecurrenceDaysLabel": { + "description": "Label for days selection area in a weekly or monthly recurrence" + }, + "composeAppointmentRecurrenceUntilLabel": "Bis", + "@composeAppointmentRecurrenceUntilLabel": { + "description": "Label for choosing end date of recurrence" + }, + "composeAppointmentRecurrenceUntilOptionUnlimited": "Unlimitiert", + "@composeAppointmentRecurrenceUntilOptionUnlimited": { + "description": "Option for no end date of recurrence" + }, + "composeAppointmentRecurrenceUntilOptionRecommended": "Empfohlen ({duration})", + "@composeAppointmentRecurrenceUntilOptionRecommended": { + "description": "Option for standard end date of recurrence with the given duration", + "placeholders": { + "duration": { + "type": "String", + "example": "3 months" + } + } + }, + "composeAppointmentRecurrenceUntilOptionSpecificDate": "Bestimmtes Datum", + "@composeAppointmentRecurrenceUntilOptionSpecificDate": { + "description": "Option for specific end date of recurrence" + }, + "composeAppointmentRecurrenceMonthlyOnDayOfMonth": "Am {day}. Tag des Monats", + "@composeAppointmentRecurrenceMonthlyOnDayOfMonth": { + "description": "Option for repeating an event always on that day of the month", + "placeholders": { + "day": { + "type": "int", + "example": "3", + "format": "compactLong" + } + } + }, + "composeAppointmentRecurrenceMonthlyOnWeekDay": "Am Wochentag des Monats", + "@composeAppointmentRecurrenceMonthlyOnWeekDay": { + "description": "Monthly repeat on weekday of a chosen week" + }, + "composeAppointmentRecurrenceFirst": "Erster", + "@composeAppointmentRecurrenceFirst": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceSecond": "Zweiter", + "@composeAppointmentRecurrenceSecond": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceThird": "Dritter", + "@composeAppointmentRecurrenceThird": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceLast": "Letzter", + "@composeAppointmentRecurrenceLast": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceSecondLast": "Vorletzter", + "@composeAppointmentRecurrenceSecondLast": { + "description": "Monthly day option" + }, + "durationYears": "{number,plural, =1{1 Jahr} other{{number} Jahre}}", + "@durationYears": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationMonths": "{number,plural, =1{1 Monat} other{{number} Monate}}", + "@durationMonths": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationWeeks": "{number,plural, =1{1 Woche} other{{number} Wochen}}", + "@durationWeeks": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationDays": "{number,plural, =1{1 Tag} other{{number} Tage}}", + "@durationDays": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationHours": "{number,plural, =1{1 Stunde} other{{number} Stunden}}", + "@durationHours": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationMinutes": "{number,plural, =1{1 Minute} other{{number} Minuten}}", + "@durationMinutes": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationEmpty": "Keine Dauer", + "@durationEmpty": { + "description": "Text shown when the duration is 0" + } +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/localization/app_en.arb similarity index 98% rename from lib/l10n/app_en.arb rename to lib/localization/app_en.arb index 5d60fe2..1c476c7 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" @@ -237,6 +238,16 @@ "@multipleSelectionNeededInfo": { "description": "Short info shown when a multiple message action is triggered without selecting at least one message first." }, + "multipleSelectionActionFailed": "Unable to perform action\nDetails: {details}", + "@multipleSelectionActionFailed": { + "description": "Error message when the selection action failed.", + "placeholders": { + "details": { + "type": "String", + "example": "Mailbox not found" + } + } + }, "multipleMoveTitle": "{number,plural, =1{Move message} other{Move {number} messages}}", "@multipleMoveTitle": { "description": "Title of move dialog for multiple messages. Message formatted using the plural JSON scheme.", @@ -408,6 +419,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." @@ -572,6 +587,26 @@ "@attachmentActionOpen": { "description": "Open action for attachments without interactive viewer." }, + "attachmentDecodeError": "This attachment has an unsupported format or encoding.\nDetails: ${details}", + "@attachmentDecodeError": { + "description": "Text shown when downloaded attachment could not be decoded.", + "placeholders": { + "details": { + "type": "String", + "example": "FormatException" + } + } + }, + "attachmentDownloadError": "Unable to download this attachment.\nDetails: ${details}", + "@attachmentDownloadError": { + "description": "Text shown when attachment could not be downloaded.", + "placeholders": { + "details": { + "type": "String", + "example": "NotFound" + } + } + }, "messageActionReply": "Reply", "@messageActionReply": { "description": "Action for single message." @@ -1137,7 +1172,7 @@ "@addAccountApplicationPasswordRequiredButton": { "description": "Button text for setting up app specific password." }, - "addAccountApplicationPasswordRequiredAcknowledged": "Understood", + "addAccountApplicationPasswordRequiredAcknowledged": "I already have an app password", "@addAccountApplicationPasswordRequiredAcknowledged": { "description": "Acknowledgement to be confirmed by user to acknowledge the fact that an app specific password is required." }, @@ -1766,11 +1801,11 @@ "@languageSettingConfirmationQuery": { "description": "Query to be confirmed by user when switching the language" }, - "languageSetInfo": "Maily is now shown in English. Please restart the app to take effect.", + "languageSetInfo": "Maily is now shown in English.", "@languageSetInfo": { "description": "Info text after having specified the language." }, - "languageSystemSetInfo": "Maily will now use the system's language or English if the system's language is not supported. Please restart the app to take effect.", + "languageSystemSetInfo": "Maily will now use the system's language or English if the system's language is not supported.", "@languageSystemSetInfo": { "description": "Info text after choosing the system's language for Maily." }, diff --git a/lib/localization/app_es.arb b/lib/localization/app_es.arb new file mode 100644 index 0000000..ad4f876 --- /dev/null +++ b/lib/localization/app_es.arb @@ -0,0 +1,2437 @@ +{ + "@@locale": "es", + "signature": "Enviado con Maily", + "@signature": { + "description": "Default signature text" + }, + "actionCancel": "Cancelar", + "@actionCancel": { + "description": "Generic cancel action" + }, + "actionOk": "Ok", + "@actionOk": { + "description": "Generic OK action" + }, + "actionDone": "Hecho", + "@actionDone": { + "description": "Generic done action" + }, + "actionNext": "Siguiente", + "@actionNext": { + "description": "Generic next action" + }, + "actionSkip": "Saltar", + "@actionSkip": { + "description": "Generic skip action" + }, + "actionUndo": "Deshacer", + "@actionUndo": { + "description": "Generic undo action" + }, + "actionDelete": "Eliminar", + "@actionDelete": { + "description": "Generic delete action" + }, + "actionAccept": "Aceptar", + "@actionAccept": { + "description": "Generic accept action" + }, + "actionDecline": "Rechazar", + "@actionDecline": { + "description": "Generic decline action" + }, + "actionEdit": "Editar", + "@actionEdit": { + "description": "Generic edit action" + }, + "actionAddressCopy": "Copiar", + "@actionAddressCopy": { + "description": "Copy action for email addresses" + }, + "actionAddressCompose": "Nuevo mensaje", + "@actionAddressCompose": { + "description": "Compose action for email addresses" + }, + "actionAddressSearch": "Buscar", + "@actionAddressSearch": { + "description": "Search action for email addresses" + }, + "splashLoading1": "Iniciando...", + "@splashLoading1": { + "description": "Message shown on splash screen while loading" + }, + "splashLoading2": "Preparando tu Motor de Maily...", + "@splashLoading2": { + "description": "Message shown on splash screen while loading" + }, + "splashLoading3": "Lanzando en el 10, 9, 8...", + "@splashLoading3": { + "description": "Message shown on splash screen while loading" + }, + "welcomePanel1Title": "Maily", + "@welcomePanel1Title": { + "description": "Welcome panel title" + }, + "welcomePanel1Text": "Bienvenido a Maily, tu ayudante de correo electrónico rápido y amistoso!", + "@welcomePanel1Text": { + "description": "Welcome message shown on first panel" + }, + "welcomePanel2Title": "Cuentas", + "@welcomePanel2Title": { + "description": "Welcome panel title" + }, + "welcomePanel2Text": "Administra cuentas de correo electrónico ilimitadas. Lee y busca correos en todas tus cuentas a la vez.", + "@welcomePanel2Text": { + "description": "Welcome message shown on second panel" + }, + "welcomePanel3Title": "Deslizar y pulsar largo", + "@welcomePanel3Title": { + "description": "Welcome panel title" + }, + "welcomePanel3Text": "Desliza el dedo por tus mensajes para borrarlos o marcarlos como leídos. Mantén pulsado un mensaje para seleccionarlo y gestionar varios.", + "@welcomePanel3Text": { + "description": "Welcome message shown on third panel" + }, + "welcomePanel4Title": "Mantén tu bandeja de entrada limpia", + "@welcomePanel4Title": { + "description": "Welcome panel title" + }, + "welcomePanel4Text": "Darse de baja de los boletines con un solo toque.", + "@welcomePanel4Text": { + "description": "Welcome message shown on fourth panel" + }, + "welcomeActionSignIn": "Inicia sesión en tu cuenta de correo", + "@welcomeActionSignIn": { + "description": "Button showing login option" + }, + "homeSearchHint": "Tu búsqueda", + "@homeSearchHint": { + "description": "Hint shown in empty search field" + }, + "homeActionsShowAsStack": "Mostrar como pila", + "@homeActionsShowAsStack": { + "description": "Action to show mails as stack" + }, + "homeActionsShowAsList": "Mostrar como lista", + "@homeActionsShowAsList": { + "description": "Action to show mails as list" + }, + "homeEmptyFolderMessage": "¡Todo listo!\n\nNo hay mensajes en esta carpeta.", + "@homeEmptyFolderMessage": { + "description": "Message shown when there are no messages in the folder" + }, + "homeEmptySearchMessage": "No se encontraron mensajes.", + "@homeEmptySearchMessage": { + "description": "Message shown when there are no messages found in a search query" + }, + "homeDeleteAllTitle": "Confirmar", + "@homeDeleteAllTitle": { + "description": "Title of confirmation dialog when deleting all messages" + }, + "homeDeleteAllQuestion": "¿Realmente eliminar todos los mensajes?", + "@homeDeleteAllQuestion": { + "description": "Question in confirmation dialog when deleting all messages" + }, + "homeDeleteAllAction": "Borrar todo", + "@homeDeleteAllAction": { + "description": "Action to tap to delete all messages (must be short)." + }, + "homeDeleteAllScrubOption": "Limpiar mensajes", + "@homeDeleteAllScrubOption": { + "description": "Option to remove deleted messages from disk." + }, + "homeDeleteAllSuccess": "Todos los mensajes eliminados.", + "@homeDeleteAllSuccess": { + "description": "Message shown after all messages have been deleted." + }, + "homeMarkAllSeenAction": "Todos leídos", + "@homeMarkAllSeenAction": { + "description": "Action to tap to mark all messages as seen / read (must be short)." + }, + "homeMarkAllUnseenAction": "Todos no leídos", + "@homeMarkAllUnseenAction": { + "description": "Action to tap to mark all messages as unseen / unread (must be short)." + }, + "homeFabTooltip": "Nuevo mensaje", + "@homeFabTooltip": { + "description": "Tooltip for 'compose new message' floating action button." + }, + "homeLoadingMessageSourceTitle": "Cargando...", + "@homeLoadingMessageSourceTitle": { + "description": "Title shown while message source itself is being loaded." + }, + "homeLoading": "cargando {name}...", + "@homeLoading": { + "description": "Message shown while loading message.", + "placeholders": { + "name": { + "type": "String", + "example": "Inbox" + } + } + }, + "swipeActionToggleRead": "Marcar como leído/no leídos", + "@swipeActionToggleRead": { + "description": "Swipe action for marking a message as read / unread." + }, + "swipeActionDelete": "Eliminar", + "@swipeActionDelete": { + "description": "Swipe action for deleting a message." + }, + "swipeActionMarkJunk": "Marcar como basura", + "@swipeActionMarkJunk": { + "description": "Swipe action for moving a message to junk." + }, + "swipeActionArchive": "Archivar", + "@swipeActionArchive": { + "description": "Swipe action for moving a message to archive." + }, + "swipeActionFlag": "Cambiar bandera", + "@swipeActionFlag": { + "description": "Swipe action for marking a message as flagged / unflagged." + }, + "multipleMovedToJunk": "{number,plural, =1{Un mensaje marcado como basura} other{Marcado {number} mensajes como basura}}", + "@multipleMovedToJunk": { + "description": "Message shown after moving messages to junk. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleMovedToInbox": "{number,plural, =1{¡Se ha movido un mensaje a la bandeja de entrada} other{¡Se ha movido {number} ¡Los mensajes a la bandeja de entrada}}", + "@multipleMovedToInbox": { + "description": "Message shown after moving messages from junk, trash or archive back to the Inbox. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleMovedToArchive": "{number,plural, =1{¡Archivado un mensaje} other{¡Archivado {number} ¡Mensajes}}", + "@multipleMovedToArchive": { + "description": "Message shown after moving messages to archive. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleMovedToTrash": "{number,plural, =1{¡Eliminado un mensaje} other{¡Eliminado {number} ¡Mensajes}}", + "@multipleMovedToTrash": { + "description": "Message shown after moving messages to trash. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "multipleSelectionNeededInfo": "Por favor, seleccione mensajes primero.", + "@multipleSelectionNeededInfo": { + "description": "Short info shown when a multiple message action is triggered without selecting at least one message first." + }, + "multipleMoveTitle": "{number,plural, =1{Mover mensaje} other{Mover {number} mensajes}}", + "@multipleMoveTitle": { + "description": "Title of move dialog for multiple messages. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "messageActionMultipleMarkSeen": "Marcar como leído", + "@messageActionMultipleMarkSeen": { + "description": "Action for several messages." + }, + "messageActionMultipleMarkUnseen": "Marcar como no leído", + "@messageActionMultipleMarkUnseen": { + "description": "Action for several messages." + }, + "messageActionMultipleMarkFlagged": "Marcar mensajes", + "@messageActionMultipleMarkFlagged": { + "description": "Action for several messages." + }, + "messageActionMultipleMarkUnflagged": "Desmarcar mensajes", + "@messageActionMultipleMarkUnflagged": { + "description": "Action for several messages." + }, + "messageActionViewInSafeMode": "Ver sin contenido externo", + "@messageActionMultipleViewInSafeMode": { + "description": "Action for message." + }, + "emailSenderUnknown": "", + "@emailSenderUnknown": { + "description": "Shown as replacement when there is no known sender of a message." + }, + "dateRangeFuture": "futuro", + "@dateRangeFuture": { + "description": "Date range title." + }, + "dateRangeTomorrow": "mañana", + "@dateRangeTomorrow": { + "description": "Date range title." + }, + "dateRangeToday": "hoy", + "@dateRangeToday": { + "description": "Date range title." + }, + "dateRangeYesterday": "ayer", + "@dateRangeYesterday": { + "description": "Date range title." + }, + "dateRangeCurrentWeek": "esta semana", + "@dateRangeCurrentWeek": { + "description": "Date range title." + }, + "dateRangeLastWeek": "semana pasada", + "@dateRangeLastWeek": { + "description": "Date range title." + }, + "dateRangeCurrentMonth": "este mes", + "@dateRangeCurrentMonth": { + "description": "Date range title." + }, + "dateRangeLastMonth": "mes pasado", + "@dateRangeLastMonth": { + "description": "Date range title." + }, + "dateRangeCurrentYear": "este año", + "@dateRangeCurrentYear": { + "description": "Date range title." + }, + "dateRangeLongAgo": "hace mucho tiempo", + "@dateRangeLongAgo": { + "description": "Date range title." + }, + "dateUndefined": "indefinido", + "@dateUndefined": { + "description": "Unknown date." + }, + "dateDayToday": "hoy", + "@dateDayToday": { + "description": "Message data is today." + }, + "dateDayYesterday": "ayer", + "@dateDayYesterday": { + "description": "Message data is yesterday." + }, + "dateDayLastWeekday": "último {day}", + "@dateDayLastWeekday": { + "description": "Message data is a recent weekday.", + "placeholders": { + "day": { + "type": "String", + "example": "Tuesday" + } + } + }, + "drawerEntryAbout": "Sobre Maily", + "@drawerEntryAbout": { + "description": "Menu entry for about." + }, + "drawerEntrySettings": "Ajustes", + "@drawerEntrySettings": { + "description": "Menu entry for settings." + }, + "drawerAccountsSectionTitle": "{number,plural, =1{Una cuenta} other{{number} cuentas}}", + "@drawerAccountsSectionTitle": { + "description": "Title shown for accounts drop down. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "drawerEntryAddAccount": "Añadir cuenta", + "@drawerEntryAddAccount": { + "description": "Menu entry for adding a new account." + }, + "unifiedAccountName": "Cuenta unificada", + "@unifiedAccountName": { + "description": "Name of unified account." + }, + "unifiedFolderInbox": "Entrada unificada", + "@unifiedFolderInbox": { + "description": "Folder name of unified account." + }, + "unifiedFolderSent": "Enviado unificado", + "@unifiedFolderSent": { + "description": "Folder name of unified account." + }, + "unifiedFolderDrafts": "Borradores unificados", + "@unifiedFolderDrafts": { + "description": "Folder name of unified account." + }, + "unifiedFolderTrash": "Basura unificada", + "@unifiedFolderTrash": { + "description": "Folder name of unified account." + }, + "unifiedFolderArchive": "Archivo unificado", + "@unifiedFolderArchive": { + "description": "Folder name of unified account." + }, + "unifiedFolderJunk": "Chatarra unificada", + "@unifiedFolderJunk": { + "description": "Folder name of unified account." + }, + "folderInbox": "Entrada", + "@folderInbox": { + "description": "Folder name." + }, + "folderSent": "Enviado", + "@folderSent": { + "description": "Folder name." + }, + "folderDrafts": "Borradores", + "@folderDrafts": { + "description": "Folder name." + }, + "folderTrash": "Basura", + "@folderTrash": { + "description": "Folder name." + }, + "folderArchive": "Archivar", + "@folderArchive": { + "description": "Folder name." + }, + "folderJunk": "Chatarra", + "@folderJunk": { + "description": "Folder name." + }, + "folderUnknown": "Desconocido", + "@folderUnknown": { + "description": "Folder name for a message source without a name." + }, + "viewContentsAction": "Ver contenido", + "@viewContentsAction": { + "description": "Show contents of a message on a separate screen." + }, + "viewSourceAction": "Ver fuente", + "@viewSourceAction": { + "description": "Show source code of a message." + }, + "detailsErrorDownloadInfo": "No se pudo descargar el mensaje.", + "@detailsErrorDownloadInfo": { + "description": "Info shown when an email could not be downloaded." + }, + "detailsErrorDownloadRetry": "Reintentar", + "@detailsErrorDownloadRetry": { + "description": "Retry action shown when an email could not be downloaded." + }, + "detailsHeaderFrom": "De", + "@detailsHeaderFrom": { + "description": "Label for sender(s) of email." + }, + "detailsHeaderTo": "A", + "@detailsHeaderTo": { + "description": "Label for [to] recipient(s) of email." + }, + "detailsHeaderCc": "CC", + "@detailsHeaderCc": { + "description": "Label for [CC] - carbon copy - recipient(s) of email." + }, + "detailsHeaderBcc": "BCC", + "@detailsHeaderBcc": { + "description": "Label for [BCC] - blind carbon copy - recipient(s) of email." + }, + "detailsHeaderDate": "Fecha", + "@detailsHeaderDate": { + "description": "Label for date of email." + }, + "subjectUndefined": "", + "@subjectUndefined": { + "description": "Shown instead of the subject when it is undefined." + }, + "detailsActionShowImages": "Mostrar imágenes", + "@detailsActionShowImages": { + "description": "Action for showing images. Only visible when external images are blocked." + }, + "detailsNewsletterActionUnsubscribe": "Desuscribirse", + "@detailsNewsletterActionUnsubscribe": { + "description": "Action shown for unsubscribable newsletter." + }, + "detailsNewsletterActionResubscribe": "Volver a suscribirse", + "@detailsNewsletterActionResubscribe": { + "description": "Action shown after re-subscribable newsletter has been unsubscribed." + }, + "detailsNewsletterStatusUnsubscribed": "No suscrito", + "@detailsNewsletterStatusUnsubscribed": { + "description": "Status shown for unsubscribed newsletter." + }, + "detailsNewsletterUnsubscribeDialogTitle": "Cancelar suscripción", + "@detailsNewsletterUnsubscribeDialogTitle": { + "description": "Title for unsubscribe newsletter dialog." + }, + "detailsNewsletterUnsubscribeDialogQuestion": "¿Quieres darte de baja de la lista de correo {listName}?", + "@detailsNewsletterUnsubscribeDialogQuestion": { + "description": "Question for unsubscribe newsletter dialog.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterUnsubscribeDialogAction": "Cancelar suscripción", + "@detailsNewsletterUnsubscribeDialogAction": { + "description": "Action for unsubscribe newsletter dialog." + }, + "detailsNewsletterUnsubscribeSuccessTitle": "No suscrito", + "@detailsNewsletterUnsubscribeSuccessTitle": { + "description": "Title for dialog after unsubscribing newsletter successfully." + }, + "detailsNewsletterUnsubscribeSuccessMessage": "Te has dado de baja de la lista de correo {listName}.", + "@detailsNewsletterUnsubscribeSuccessMessage": { + "description": "Text confirmation after successfully unsubscribing a newsletter.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterUnsubscribeFailureTitle": "No desuscrito", + "@detailsNewsletterUnsubscribeFailureTitle": { + "description": "Title for dialog after unsubscribing newsletter failed." + }, + "detailsNewsletterUnsubscribeFailureMessage": "Lo siento, pero no he podido darte de baja de {listName} automáticamente.", + "@detailsNewsletterUnsubscribeFailureMessage": { + "description": "Text confirmation after unsubscribing a newsletter failed.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterResubscribeDialogTitle": "Volver a suscribirse", + "@detailsNewsletterResubscribeDialogTitle": { + "description": "Title for re-subscribe newsletter dialog." + }, + "detailsNewsletterResubscribeDialogQuestion": "¿Quieres suscribirte de nuevo a esta lista de correo {listName}?", + "@detailsNewsletterResubscribeDialogQuestion": { + "description": "Question for re-subscribe newsletter dialog.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterResubscribeDialogAction": "Suscribirse", + "@detailsNewsletterResubscribeDialogAction": { + "description": "Action for re-subscribe newsletter dialog." + }, + "detailsNewsletterResubscribeSuccessTitle": "Suscrito", + "@detailsNewsletterResubscribeSuccessTitle": { + "description": "Title for dialog after re-subscribed newsletter successfully." + }, + "detailsNewsletterResubscribeSuccessMessage": "Ahora estás suscrito a la lista de correo {listName} de nuevo.", + "@detailsNewsletterResubscribeSuccessMessage": { + "description": "Text confirmation after successfully re-subscribing a newsletter.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsNewsletterResubscribeFailureTitle": "No suscrito", + "@detailsNewsletterResubscribeFailureTitle": { + "description": "Title for dialog after re-subscribing newsletter failed." + }, + "detailsNewsletterResubscribeFailureMessage": "Lo sentimos, pero la solicitud de suscripción ha fallado para la lista de correo {listName}.", + "@detailsNewsletterResubscribeFailureMessage": { + "description": "Text confirmation after re-subscribing a newsletter failed.", + "placeholders": { + "listName": { + "type": "String", + "example": "List-Name" + } + } + }, + "detailsSendReadReceiptAction": "Enviar recibo de lectura", + "@detailsSendReadReceiptAction": { + "description": "Action to send the read receipt for the shown message." + }, + "detailsReadReceiptSentStatus": "Leer el recibo enviado ✔️", + "@detailsReadReceiptSentStatus": { + "description": "Status after sending the read receipt for the shown message." + }, + "detailsReadReceiptSubject": "Leer recibo", + "@detailsReadReceiptSubject": { + "description": "Message subject for read receipts." + }, + "attachmentActionOpen": "Abrir", + "@attachmentActionOpen": { + "description": "Open action for attachments without interactive viewer." + }, + "attachmentDecodeError": "Este archivo adjunto tiene un formato o codificación no compatibles.\nDetalles: ${details}", + "@attachmentDecodeError": { + "description": "Text shown when downloaded attachment could not be decoded.", + "placeholders": { + "details": { + "type": "String", + "example": "FormatException" + } + } + }, + "attachmentDownloadError": "No se puede descargar este adjunto.\nDetalles: ${details}", + "@attachmentDownloadError": { + "description": "Text shown when attachment could not be downloaded.", + "placeholders": { + "details": { + "type": "String", + "example": "NotFound" + } + } + }, + "messageActionReply": "Responder", + "@messageActionReply": { + "description": "Action for single message." + }, + "messageActionReplyAll": "Responder a todos", + "@messageActionReplyAll": { + "description": "Action for single message." + }, + "messageActionForward": "Reenviar", + "@messageActionForward": { + "description": "Action for single message." + }, + "messageActionForwardAsAttachment": "Reenviar como archivo adjunto", + "@messageActionForwardAsAttachment": { + "description": "Action for single message." + }, + "messageActionForwardAttachments": "{number,plural, =1{¡Adelante el adjunto} other{Reenviar {number} archivos adjuntos}}", + "@messageActionForwardAttachments": { + "description": "Action for single message to forward the given number of attachments.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "messagesActionForwardAttachments": "Reenviar archivos adjuntos", + "@messagesActionForwardAttachments": { + "description": "Action for multiple selected messages to forward all attachments of the messages." + }, + "messageActionDelete": "Eliminar", + "@messageActionDelete": { + "description": "Action for single message." + }, + "messageActionMoveToInbox": "Mover a bandeja de entrada", + "@messageActionMoveToInbox": { + "description": "Action for single message." + }, + "messageActionMove": "Mover", + "@messageActionMove": { + "description": "Action for single message." + }, + "messageStatusSeen": "Es leído", + "@messageStatusSeen": { + "description": "Status of single message." + }, + "messageStatusUnseen": "No es leído", + "@messageStatusUnseen": { + "description": "Status of single message." + }, + "messageStatusFlagged": "Está marcado", + "@messageStatusFlagged": { + "description": "Status of single message." + }, + "messageStatusUnflagged": "No está marcado", + "@messageStatusUnflagged": { + "description": "Status of single message." + }, + "messageActionMarkAsJunk": "Marcar como basura", + "@messageActionMarkAsJunk": { + "description": "Action for single message." + }, + "messageActionMarkAsNotJunk": "Marcar como no basura", + "@messageActionMarkAsNotJunk": { + "description": "Action for single message." + }, + "messageActionArchive": "Archivar", + "@messageActionArchive": { + "description": "Action for single message." + }, + "messageActionUnarchive": "Mover a bandeja de entrada", + "@messageActionUnarchive": { + "description": "Action for single message." + }, + "messageActionRedirect": "Redireccionar", + "@messageActionRedirect": { + "description": "Action for single message." + }, + "messageActionAddNotification": "Añadir notificación", + "@messageActionAddNotification": { + "description": "Action for single message." + }, + "resultDeleted": "Eliminado", + "@resultDeleted": { + "description": "Successful short snackbar message after deleting message(s)." + }, + "resultMovedToJunk": "Marcado como basura", + "@resultMovedToJunk": { + "description": "Successful short snackbar message after moving message(s) to junk." + }, + "resultMovedToInbox": "Movido a la bandeja de entrada", + "@resultMovedToInbox": { + "description": "Successful short snackbar message after moving message(s) to inbox." + }, + "resultArchived": "Archivado", + "@resultArchived": { + "description": "Successful short snackbar message after moving message(s) to archive." + }, + "resultRedirectedSuccess": "Mensaje redireccionado 👍", + "@resultRedirectedSuccess": { + "description": "Successful snackbar message after redirecting message to new recipient(s)." + }, + "resultRedirectedFailure": "No se puede redirigir el mensaje.\n\nEl servidor respondió con los siguientes detalles: \"{details}\"", + "@resultRedirectedFailure": { + "description": "Failure snackbar message after failed to redirect message to new recipient(s).", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid recipient" + } + } + }, + "redirectTitle": "Redireccionar", + "@redirectTitle": { + "description": "Title of redirect dialog." + }, + "redirectInfo": "Redirigir este mensaje a los siguientes destinatarios. Redirigir no altera el mensaje.", + "@redirectInfo": { + "description": "Short explanation of redirect action in redirect dialog." + }, + "redirectEmailInputRequired": "Necesitas añadir al menos una dirección de correo electrónico válida.", + "@redirectEmailInputRequired": { + "description": "Information when redirect is wanted but no address has been entered." + }, + "searchQueryDescription": "Buscar en {folder}...", + "@searchQueryDescription": { + "description": "Description of search within the given folder.", + "placeholders": { + "folder": { + "type": "String", + "example": "Inbox" + } + } + }, + "searchQueryTitle": "Buscar \"{query}\"", + "@searchQueryTitle": { + "description": "Title for a search with the given query.", + "placeholders": { + "query": { + "type": "String", + "example": "a sender name" + } + } + }, + "legaleseUsage": "Al utilizar Maily aceptas nuestras [PP] y nuestras [TC].", + "@legaleseUsage": { + "description": "Legal info shown on initial welcome screen and later in about. [PP] is replaced with the legalesePrivacyPolicy text and [TC] with legaleseTermsAndConditions." + }, + "legalesePrivacyPolicy": "Política de Privacidad", + "@legalesePrivacyPolicy": { + "description": "Translation of privacy policy" + }, + "legaleseTermsAndConditions": "Términos y Condiciones", + "@legaleseTermsAndConditions": { + "description": "Translation of Terms & Conditions " + }, + "aboutApplicationLegalese": "Maily es un software libre publicado bajo la Licencia Pública General GNU.", + "@aboutApplicationLegalese": { + "description": "Legal info shown in about dialog." + }, + "feedbackActionSuggestFeature": "Sugerir una característica", + "@feedbackActionSuggestFeature": { + "description": "Action to suggest a feature." + }, + "feedbackActionReportProblem": "Reportar un problema", + "@feedbackActionReportProblem": { + "description": "Action to report a problem." + }, + "feedbackActionHelpDeveloping": "Ayuda al desarrollo de Maily", + "@feedbackActionHelpDeveloping": { + "description": "Action to help developing." + }, + "feedbackTitle": "Comentarios", + "@feedbackTitle": { + "description": "Title of feedback settings screen." + }, + "feedbackIntro": "¡Gracias por probar Maily!", + "@feedbackIntro": { + "description": "Intro for feedback settings screen." + }, + "feedbackProvideInfoRequest": "Por favor, proporcione esta información cuando reporte un problema:", + "@feedbackProvideInfoRequest": { + "description": "Request to provide device and app information when reporting a problem." + }, + "feedbackResultInfoCopied": "Copiado al portapapeles", + "@feedbackResultInfoCopied": { + "description": "Info shown after copying device and app info to clipboard." + }, + "accountsTitle": "Cuentas", + "@accountsTitle": { + "description": "Title of accounts settings screen." + }, + "accountsActionReorder": "Reordenar cuentas", + "@accountActionReorder": { + "description": "Action to start reordering accounts." + }, + "settingsTitle": "Ajustes", + "@settingsTitle": { + "description": "Title of base settings screen." + }, + "settingsSecurityBlockExternalImages": "Bloquear imágenes externas", + "@settingsSecurityBlockExternalImages": { + "description": "Settings option to block external options." + }, + "settingsSecurityBlockExternalImagesDescriptionTitle": "Imágenes externas", + "@settingsSecurityBlockExternalImagesDescriptionTitle": { + "description": "Title of dialog that shows additional information about the 'block external images' option." + }, + "settingsSecurityBlockExternalImagesDescriptionText": "Los mensajes de correo electrónico pueden contener imágenes que están integradas o alojadas en servidores externos. Este último, imágenes externas pueden exponer información al remitente del mensaje, por ejemplo, para que el remitente sepa que ha abierto el mensaje. Esta opción le permite bloquear dichas imágenes externas, lo que reduce el riesgo de exponer información confidencial. Todavía puede optar por cargar dichas imágenes por mensaje cuando lea un mensaje.", + "@settingsSecurityBlockExternalImagesDescriptionText": { + "description": "Text of dialog that shows additional information about the 'block external images' option." + }, + "settingsSecurityMessageRenderingHtml": "Mostrar contenido completo del mensaje", + "@settingsSecurityMessageRenderingHtml": { + "description": "Option for how to render messages." + }, + "settingsSecurityMessageRenderingPlainText": "Mostrar sólo el texto de los mensajes", + "@settingsSecurityMessageRenderingPlainText": { + "description": "Option for how to render messages." + }, + "settingsSecurityLaunchModeLabel": "¿Cómo debe abrir enlaces Maily?", + "@settingsSecurityLaunchModeLabel": { + "description": "Option for how to launch URLs." + }, + "settingsSecurityLaunchModeExternal": "Abrir enlaces externamente", + "@settingsSecurityLaunchModeExternal": { + "description": "Option for how to launch URLs." + }, + "settingsSecurityLaunchModeInApp": "Abrir enlaces en Maily", + "@settingsSecurityLaunchModeInApp": { + "description": "Option for how to launch URLs." + }, + "settingsActionAccounts": "Administrar cuentas", + "@settingsActionAccounts": { + "description": "Settings action to manage accounts." + }, + "settingsActionDesign": "Apariencia", + "@settingsActionDesign": { + "description": "Settings action to manage the visualization of the app." + }, + "settingsActionFeedback": "Proporcionar comentarios", + "@settingsActionFeedback": { + "description": "Settings action to provide feedback about the app." + }, + "settingsActionWelcome": "Mostrar bienvenida", + "@settingsActionWelcome": { + "description": "Settings action to show welcome screen of the app again." + }, + "settingsReadReceipts": "Leer recibos", + "@settingsReadReceipts": { + "description": "Settings action to customize read receipts." + }, + "readReceiptsSettingsIntroduction": "¿Quieres mostrar las solicitudes de recibos de lectura?", + "@readReceiptsSettingsIntroduction": { + "description": "Introduction text for managing read receipt requests." + }, + "readReceiptOptionAlways": "Siempre", + "@readReceiptOptionAlways": { + "description": "Display option for read receipt requests." + }, + "readReceiptOptionNever": "Nunca", + "@readReceiptOptionNever": { + "description": "Display option for read receipt requests." + }, + "settingsFolders": "Carpetas", + "@settingsFolders": { + "description": "Settings action to customize folders." + }, + "folderNamesIntroduction": "¿Qué nombres prefiere para sus carpetas?", + "@folderNamesIntroduction": { + "description": "Introduction for folder names setting." + }, + "folderNamesSettingLocalized": "Nombres dados por Maily", + "@folderNamesSettingLocalized": { + "description": "Folder name setting option." + }, + "folderNamesSettingServer": "Nombres dados por el servicio", + "@folderNamesSettingServer": { + "description": "Folder name setting option." + }, + "folderNamesSettingCustom": "Mis nombres personalizados", + "@folderNamesSettingCustom": { + "description": "Folder name setting option." + }, + "folderNamesEditAction": "Editar nombres personalizados", + "@folderNamesEditAction": { + "description": "Action to specify custom folder names." + }, + "folderNamesCustomTitle": "Nombres personalizados", + "@folderNamesCustomTitle": { + "description": "Title of dialog to specify custom folder names." + }, + "folderAddAction": "Crear carpeta", + "@folderAddAction": { + "description": "Action to create a new folder." + }, + "folderAddTitle": "Crear carpeta", + "@folderAddTitle": { + "description": "Dialog title when creating a new folder." + }, + "folderAddNameLabel": "Nombre", + "@folderAddNameLabel": { + "description": "Label for input field for the folder name." + }, + "folderAddNameHint": "Nombre de la nueva carpeta", + "@folderAddNameHint": { + "description": "Hint for input field for the folder name." + }, + "folderAccountLabel": "Cuenta", + "@folderAddAccountLabel": { + "description": "Label to select the current account." + }, + "folderMailboxLabel": "Carpeta", + "@folderAddParentFolderLabel": { + "description": "Label to select the current folder." + }, + "folderAddResultSuccess": "Carpeta creada 😊", + "@folderAddResultSuccess": { + "description": "Info for showing the creation success." + }, + "folderAddResultFailure": "No se pudo crear la carpeta.\n\nEl servidor respondió con {details}", + "@folderAddResultFailure": { + "description": "Info for showing a folder creation error.", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid name" + } + } + }, + "folderDeleteAction": "Eliminar", + "@folderDeleteAction": { + "description": "Action to delete an existing folder." + }, + "folderDeleteConfirmTitle": "Confirmar", + "@folderDeleteConfirmTitle": { + "description": "Dialog title to confirm deleting a folder." + }, + "folderDeleteConfirmText": "¿Realmente desea eliminar la carpeta {name}?", + "@folderDeleteConfirmText": { + "description": "Dialog text to confirm deleting a folder.", + "placeholders": { + "name": { + "type": "String", + "example": "My Custom Folder" + } + } + }, + "folderDeleteResultSuccess": "Carpeta eliminada.", + "@folderDeleteResultSuccess": { + "description": "Info for showing the creation success." + }, + "folderDeleteResultFailure": "No se ha podido eliminar la carpeta.\n\nEl servidor ha respondido con {details}", + "@folderDeleteResultFailure": { + "description": "Info for showing a folder deletion error.", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid name" + } + } + }, + "settingsDevelopment": "Configuración de desarrollo", + "@settingsDevelopment": { + "description": "Settings action to specify the development options." + }, + "developerModeTitle": "Modo de desarrollo", + "@developerModeTitle": { + "description": "Title of the development mode section." + }, + "developerModeIntroduction": "Si activas el modo de desarrollo podrás ver el código fuente de los mensajes y convertir los archivos adjuntos de texto a mensajes.", + "@developerModeIntroduction": { + "description": "Text explaining the development mode." + }, + "developerModeEnable": "Activar modo de desarrollo", + "@developerModeEnable": { + "description": "Text in checkbox to enable the development mode." + }, + "developerShowAsEmail": "Convertir texto a email", + "@developerShowAsEmail": { + "description": "Action to convert text into an email." + }, + "developerShowAsEmailFailed": "Este texto no se puede convertir en un mensaje MIME.", + "@developerShowAsEmailFailed": { + "description": "Text shown when text cannot be converted into an email." + }, + "designTitle": "Ajustes de diseño", + "@designTitle": { + "description": "Title of design settings screen." + }, + "designSectionThemeTitle": "Tema", + "@designSectionThemeTitle": { + "description": "Title of theme section on design settings screen." + }, + "designThemeOptionLight": "Luz", + "@designThemeOptionLight": { + "description": "Theme option." + }, + "designThemeOptionDark": "Oscuro", + "@designThemeOptionDark": { + "description": "Theme option." + }, + "designThemeOptionSystem": "Sistema", + "@designThemeOptionSystem": { + "description": "Theme option." + }, + "designThemeOptionCustom": "Personalizado", + "@designThemeOptionCustom": { + "description": "Theme option." + }, + "designSectionCustomTitle": "Activar tema oscuro", + "@designSectionCustomTitle": { + "description": "Title of custom theme option section on design settings screen." + }, + "designThemeCustomStart": "de {time}", + "@designThemeCustomStart": { + "description": "Start time of custom theme setting.", + "placeholders": { + "time": { + "type": "String", + "example": "10 PM" + } + } + }, + "designThemeCustomEnd": "hasta {time}", + "@designThemeCustomEnd": { + "description": "End time of custom theme setting.", + "placeholders": { + "time": { + "type": "String", + "example": "7 AM" + } + } + }, + "designSectionColorTitle": "Esquema de color", + "@designSectionColorTitle": { + "description": "Title of color section on design settings screen." + }, + "securitySettingsTitle": "Seguridad", + "@securitySettingsTitle": { + "description": "Title of security settings screen." + }, + "securitySettingsIntro": "Adapte la configuración de seguridad a sus necesidades personales.", + "@securitySettingsIntro": { + "description": "Introduction of security settings screen." + }, + "securityUnlockWithFaceId": "Desbloquea Maily con Face ID.", + "@securityUnlockWithFaceId": { + "description": "iOS-specific unlock reason." + }, + "securityUnlockWithTouchId": "Desbloquea Maily con Touch ID.", + "@securityUnlockWithTouchId": { + "description": "iOS-specific unlock reason." + }, + "securityUnlockReason": "Desbloquea Maily.", + "@securityUnlockReason": { + "description": "Generic unlock reason." + }, + "securityUnlockDisableReason": "Desbloquear Maily para desactivar el bloqueo.", + "@securityUnlockDisableReason": { + "description": "Generic unlock disable reason." + }, + "securityUnlockNotAvailable": "Su dispositivo no soporta biométricos, posiblemente necesite configurar las opciones de desbloqueo primero.", + "@securityUnlockNotAvailable": { + "description": "Message when biometric authentication is not available." + }, + "securityUnlockLabel": "Bloquear Maily", + "@securityUnlockLabel": { + "description": "Label of biometric authentication lock feature." + }, + "securityUnlockDescriptionTitle": "Bloquear Maily", + "@securityUnlockDescriptionTitle": { + "description": "Title to explain lock feature via biometric authentication." + }, + "securityUnlockDescriptionText": "Puedes elegir bloquear el acceso a Maily, para que otros no puedan leer tu correo electrónico incluso cuando tengan acceso a tu dispositivo.", + "@securityUnlockDescriptionText": { + "description": "Text explaining lock feature via biometric authentication." + }, + "securityLockImmediately": "Bloquear inmediatamente", + "@securityLockImmediately": { + "description": "Lock timing option." + }, + "securityLockAfter5Minutes": "Bloquear después de 5 minutos", + "@securityLockAfter5Minutes": { + "description": "Lock timing option." + }, + "securityLockAfter30Minutes": "Bloquear después de 30 minutos", + "@securityLockAfter30Minutes": { + "description": "Lock timing option." + }, + "lockScreenTitle": "Maily está bloqueado", + "@lockScreenTitle": { + "description": "Title of lock screen." + }, + "lockScreenIntro": "Maily está bloqueado, por favor autentifíquese para continuar.", + "@lockScreenIntro": { + "description": "Text on lock screen." + }, + "lockScreenUnlockAction": "Desbloquear", + "@lockScreenUnlockAction": { + "description": "Action to unlock on lock screen." + }, + "addAccountTitle": "Añadir cuenta", + "@addAccountTitle": { + "description": "Title of add account screen." + }, + "addAccountEmailLabel": "E-mail", + "@addAccountEmailLabel": { + "description": "Label and section header of email address input field." + }, + "addAccountEmailHint": "Introduzca su dirección de correo electrónico", + "@addAccountEmailHint": { + "description": "Hint text of email address input field." + }, + "addAccountResolvingSettingsLabel": "Resolviendo {email}...", + "@addAccountResolvingSettingsLabel": { + "description": "Label shown while resolving the settings for the specified email address.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountResolvedSettingsWrongAction": "¿No está en {provider}?", + "@addAccountResolvedSettingsWrongAction": { + "description": "Button text shown for the user to edit server settings manually when the resolving was successful but turned out a different than expected provider name.", + "placeholders": { + "provider": { + "type": "String", + "example": "gmail" + } + } + }, + "addAccountResolvingSettingsFailedInfo": "No se puede resolver {email}. Por favor, vuelve a cambiarlo o configura la cuenta manualmente.", + "@addAccountResolvingSettingsFailedInfo": { + "description": "Info shown after resolving the settings for the specified email address failed.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountEditManuallyAction": "Editar manualmente", + "@addAccountEditManuallyAction": { + "description": "Action shown after account settings could not be automatically be discovered." + }, + "addAccountPasswordLabel": "Contraseña", + "@addAccountPasswordLabel": { + "description": "Label and section header of password input field." + }, + "addAccountPasswordHint": "Por favor, introduce tu contraseña", + "@addAccountPasswordHint": { + "description": "Hint text of password input field." + }, + "addAccountApplicationPasswordRequiredInfo": "Este proveedor requiere que establezcas una contraseña específica para la aplicación.", + "@addAccountApplicationPasswordRequiredInfo": { + "description": "Text shown when the provider of the email account requires app specific passwords to be set up." + }, + "addAccountApplicationPasswordRequiredButton": "Crear contraseña específica de la aplicación", + "@addAccountApplicationPasswordRequiredButton": { + "description": "Button text for setting up app specific password." + }, + "addAccountApplicationPasswordRequiredAcknowledged": "Ya tengo una contraseña de la aplicación", + "@addAccountApplicationPasswordRequiredAcknowledged": { + "description": "Acknowledgement to be confirmed by user to acknowledge the fact that an app specific password is required." + }, + "addAccountVerificationStep": "Verificación", + "@addAccountVerificationStep": { + "description": "Section header of verification/log in step." + }, + "addAccountSetupAccountStep": "Configuracion de Cuenta", + "@addAccountSetupAccountStep": { + "description": "Section header of account setup step." + }, + "addAccountVerifyingSettingsLabel": "Verificando {email}...", + "@addAccountVerifyingSettingsLabel": { + "description": "Info shown while the account settings for the given email are verified.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountVerifyingSuccessInfo": "Has iniciado sesión con éxito en {email}.", + "@addAccountVerifyingSuccessInfo": { + "description": "Info shown after the account settings for the given email have been verified.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountVerifyingFailedInfo": "Lo sentimos, pero ha habido un problema. Por favor, comprueba tu correo electrónico {email} y contraseña.", + "@addAccountVerifyingFailedInfo": { + "description": "Info shown after the account settings for the given email could not be verified.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "addAccountOauthOptionsText": "Inicie sesión con {provider} o cree una contraseña específica de la aplicación.", + "@addAccountOauthOptionsText": { + "description": "Info shown oauth process fails and the user can try again or use an app-specific password.", + "placeholders": { + "provider": { + "type": "String", + "example": "Mailbox.org" + } + } + }, + "addAccountOauthSignIn": "Iniciar sesión con {provider}", + "@addAccountOauthSignIn": { + "description": "Label of button to sign in via oauth with the given provider.", + "placeholders": { + "provider": { + "type": "String", + "example": "Mailbox.org" + } + } + }, + "addAccountOauthSignInGoogle": "Iniciar sesión con Google", + "@addAccountOauthSignInGoogle": { + "description": "Label of button to sign in via oauth with the Google." + }, + "addAccountOauthSignInWithAppPassword": "Alternativamente, cree una contraseña de la aplicación para iniciar sesión.", + "@addAccountOauthSignInWithAppPassword": { + "description": "Info to set up app specific password." + }, + "accountAddImapAccessSetupMightBeRequired": "Su proveedor puede requerir que configure el acceso para aplicaciones de correo electrónico manualmente.", + "@accountAddImapAccessSetupMightBeRequired": { + "description": "Info shown when login fails for a provider that is know to require manual activation of IMAP access." + }, + "addAccountSetupImapAccessButtonLabel": "Configurar acceso a email", + "@addAccountSetupImapAccessButtonLabel": { + "description": "Label of button to launch website with instructions." + }, + "addAccountNameOfUserLabel": "Tu nombre", + "@addAccountNameOfUserLabel": { + "description": "Label for user name input field." + }, + "addAccountNameOfUserHint": "El nombre que los destinatarios ven", + "@addAccountNameOfUserHint": { + "description": "Hint for user name input field." + }, + "addAccountNameOfAccountLabel": "Nombre de cuenta", + "@addAccountNameOfAccountLabel": { + "description": "Label for account name input field." + }, + "addAccountNameOfAccountHint": "Introduce el nombre de tu cuenta", + "@addAccountNameOfAccountHint": { + "description": "Hint for account name input field." + }, + "editAccountTitle": "Editar {name}", + "@editAccountTitle": { + "description": "Title for screen when editing the account with the given name.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountFailureToConnectInfo": "Maily no pudo conectar {name}.", + "@editAccountFailureToConnectInfo": { + "description": "Info about not being able to connect to the named service. Most common causes are temporary network problems or a changed password.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountFailureToConnectRetryAction": "Reintentar", + "@editAccountFailureToConnectRetryAction": { + "description": "Action to retry connecting to service again." + }, + "editAccountFailureToConnectChangePasswordAction": "Cambiar contraseña", + "@editAccountFailureToConnectChangePasswordAction": { + "description": "Action to change password for the service." + }, + "editAccountFailureToConnectFixedTitle": "Conectado", + "@editAccountFailureToConnectFixedTitle": { + "description": "Title of dialog shown after successfully connecting a failed account again." + }, + "editAccountFailureToConnectFixedInfo": "La cuenta está conectada de nuevo.", + "@editAccountFailureToConnectFixedInfo": { + "description": "Message of dialog shown after successfully connecting a failed account again." + }, + "editAccountIncludeInUnifiedLabel": "Incluye en cuenta unificada", + "@editAccountIncludeInUnifiedLabel": { + "description": "Label for opting this account in/out of the unified account." + }, + "editAccountAliasLabel": "Direcciones de correo electrónico de {email}:", + "@editAccountAliasLabel": { + "description": "Label for any alias addresses that have been specified for the given email address.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "editAccountNoAliasesInfo": "Aún no tienes alias conocidos para esta cuenta.", + "@editAccountNoAliasesInfo": { + "description": "Info when there have been no aliases specified for this account." + }, + "editAccountAliasRemoved": "Alias {email} eliminado", + "@editAccountAliasRemoved": { + "description": "Info given after an alias was removed.", + "placeholders": { + "email": { + "type": "String", + "example": "someone@domain.com" + } + } + }, + "editAccountAddAliasAction": "Añadir alias", + "@editAccountAddAliasAction": { + "description": "Button text for adding an alias to this account." + }, + "editAccountPlusAliasesSupported": "Soporta + alias", + "@editAccountPlusAliasesSupported": { + "description": "Info shown when + aliases are supported by this account." + }, + "editAccountCheckPlusAliasAction": "Prueba de soporte para + alias", + "@editAccountCheckPlusAliasAction": { + "description": "Button text for testing of + aliases are supported by this account." + }, + "editAccountBccMyself": "BCC mismo", + "@editAccountBccMyself": { + "description": "Label of checkbox to enable the BCC-MYSELF-Feature." + }, + "editAccountBccMyselfDescriptionTitle": "BCC mismo", + "@editAccountBccMyselfDescriptionTitle": { + "description": "Title of alert explaining the BCC-MYSELF-Feature." + }, + "editAccountBccMyselfDescriptionText": "Puedes enviar automáticamente mensajes a ti mismo para cada mensaje que envíes desde esta cuenta con la función \"BCC yo\". Normalmente esto no es necesario y deseado, ya que todos los mensajes salientes se almacenan en la carpeta \"Enviado\" de todos modos.", + "@editAccountBccMyselfDescriptionText": { + "description": "Explanation of the the BCC-MYSELF-Feature." + }, + "editAccountServerSettingsAction": "Editar configuración del servidor", + "@editAccountServerSettingsAction": { + "description": "Button text for editing the server settings of this account." + }, + "editAccountDeleteAccountAction": "Eliminar cuenta", + "@editAccountDeleteAccountAction": { + "description": "Button text for deleting this account." + }, + "editAccountDeleteAccountConfirmationTitle": "Confirmar", + "@editAccountDeleteAccountConfirmationTitle": { + "description": "Title for confirmation dialog when deleting this account." + }, + "editAccountDeleteAccountConfirmationQuery": "¿Quieres eliminar la cuenta {name}?", + "@editAccountDeleteAccountConfirmationQuery": { + "description": "Request to confirm when deleting this account.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountTestPlusAliasTitle": "+ Alias para {name}", + "@editAccountTestPlusAliasTitle": { + "description": "Title for dialog shown while testing + alias support", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountTestPlusAliasStepIntroductionTitle": "Introducción", + "@editAccountTestPlusAliasStepIntroductionTitle": { + "description": "Title for introducing concept of + aliases." + }, + "editAccountTestPlusAliasStepIntroductionText": "Tu cuenta {accountName} podría ser compatible con los alias + llamados como {example}.\nUn alias A + te ayuda a proteger tu identidad y te ayuda contra el spam.\nPara probarlo, se enviará un mensaje de prueba a esta dirección generada. Si llega, su proveedor soporta + alias y puede generarlos fácilmente a petición al escribir un nuevo mensaje de correo.", + "@editAccountTestPlusAliasStepIntroductionText": { + "description": "Text for introducing concept of + aliases.", + "placeholders": { + "accountName": { + "type": "String", + "example": "domain.com" + }, + "example": { + "type": "String", + "example": "someone+example@domain.com" + } + } + }, + "editAccountTestPlusAliasStepTestingTitle": "Pruebas", + "@editAccountTestPlusAliasStepTestingTitle": { + "description": "Title while testing concept of + aliases." + }, + "editAccountTestPlusAliasStepResultTitle": "Resultado", + "@editAccountTestPlusAliasStepResultTitle": { + "description": "Title after testing concept of + aliases." + }, + "editAccountTestPlusAliasStepResultSuccess": "Tu cuenta {name} soporta + alias.", + "@editAccountTestPlusAliasStepResultSuccess": { + "description": "Result when account supports + aliases", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountTestPlusAliasStepResultNoSuccess": "Tu cuenta {name} no soporta + alias.", + "@editAccountTestPlusAliasStepResultNoSuccess": { + "description": "Result when account does not supports + aliases", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "editAccountAddAliasTitle": "Añadir alias", + "@editAccountAddAliasTitle": { + "description": "Title when adding new alias." + }, + "editAccountEditAliasTitle": "Editar alias", + "@editAccountEditAliasTitle": { + "description": "Title when editing alias." + }, + "editAccountAliasAddAction": "Añadir", + "@editAccountAliasAddAction": { + "description": "Action when adding new alias." + }, + "editAccountAliasUpdateAction": "Actualizar", + "@editAccountAliasUpdateAction": { + "description": "Action when editing alias." + }, + "editAccountEditAliasNameLabel": "Nombre del alias", + "@editAccountEditAliasNameLabel": { + "description": "Label for alias name input field." + }, + "editAccountEditAliasEmailLabel": "Alias email", + "@editAccountEditAliasEmailLabel": { + "description": "Label for alias email input field." + }, + "editAccountEditAliasEmailHint": "Tu dirección de email de alias", + "@editAccountEditAliasEmailHint": { + "description": "Hint for alias email input field." + }, + "editAccountEditAliasDuplicateError": "Ya hay un alias con {email}.", + "@editAccountEditAliasDuplicateError": { + "description": "Error when the alias email is already known", + "placeholders": { + "email": { + "type": "String", + "example": "you@domain.com" + } + } + }, + "editAccountEnableLogging": "Activar registro", + "@editAccountEnableLogging": { + "description": "Label developer mode option to enable logging." + }, + "editAccountLoggingEnabled": "Registro habilitado, por favor reinicie", + "@editAccountLoggingEnabled": { + "description": "Short message shown after the log has been enabled." + }, + "editAccountLoggingDisabled": "Registro desactivado, por favor reinicie", + "@editAccountLoggingDisabled": { + "description": "Short message shown after the log has been disabled." + }, + "accountDetailsFallbackTitle": "Ajustes del servidor", + "@accountDetailsFallbackTitle": { + "description": "Title shown when account name is not set." + }, + "errorTitle": "Error", + "@errorTitle": { + "description": "Title for error dialogs." + }, + "accountProviderStepTitle": "Proveedor de Servicio de Email", + "@accountProviderStepTitle": { + "description": "Step to select a provider." + }, + "accountProviderCustom": "Otro servicio de email", + "@accountProviderCustom": { + "description": "When no standard provider is chosen." + }, + "accountDetailsErrorHostProblem": "Maily no puede llegar al servidor de correo especificado. Por favor, compruebe la configuración del servidor de entrada \"{incomingHost}\" y la configuración del servidor de salida \"{outgoingHost}\".", + "@accountDetailsErrorHostProblem": { + "description": "Error details when no connection to server could be established at all", + "placeholders": { + "incomingHost": { + "type": "String", + "example": "imap@domain.com" + }, + "outgoingHost": { + "type": "String", + "example": "smtp@domain.com" + } + } + }, + "accountDetailsErrorLoginProblem": "No se puede iniciar sesión. Por favor, comprueba tu nombre de usuario \"{userName}\" y tu contraseña \"{password}\".", + "@accountDetailsErrorLoginProblem": { + "description": "Error details when login fails", + "placeholders": { + "userName": { + "type": "String", + "example": "email@domain.com" + }, + "password": { + "type": "String", + "example": "secret" + } + } + }, + "accountDetailsUserNameLabel": "Nombre de usuario", + "@accountDetailsUserNameLabel": { + "description": "Label for user name input field." + }, + "accountDetailsUserNameHint": "Su nombre de usuario, si es diferente del correo electrónico", + "@accountDetailsUserNameHint": { + "description": "Hint for user name input field." + }, + "accountDetailsPasswordLabel": "Contraseña de acceso", + "@accountDetailsPasswordLabel": { + "description": "Label for password input field." + }, + "accountDetailsPasswordHint": "Su contraseña", + "@accountDetailsPasswordHint": { + "description": "Hint for user password input field." + }, + "accountDetailsBaseSectionTitle": "Ajustes de base", + "@accountDetailsBaseSectionTitle": { + "description": "Title of base settings section." + }, + "accountDetailsIncomingLabel": "Servidor entrante", + "@accountDetailsIncomingLabel": { + "description": "Label for incoming server domain field." + }, + "accountDetailsIncomingHint": "Dominio como imap.domain.com", + "@accountDetailsIncomingHint": { + "description": "Hint for incoming server domain field." + }, + "accountDetailsOutgoingLabel": "Servidor saliente", + "@accountDetailsOutgoingLabel": { + "description": "Label for outgoing server domain field." + }, + "accountDetailsOutgoingHint": "Dominio como smtp.domain.com", + "@accountDetailsOutgoingHint": { + "description": "Hint for outgoing server domain field." + }, + "accountDetailsAdvancedIncomingSectionTitle": "Configuración avanzada de entrada", + "@accountDetailsAdvancedIncomingSectionTitle": { + "description": "Title of incoming settings section." + }, + "accountDetailsIncomingServerTypeLabel": "Tipo de entrada:", + "@accountDetailsIncomingServerTypeLabel": { + "description": "Label for server type dropdown." + }, + "accountDetailsOptionAutomatic": "automático", + "@accountDetailsOptionAutomatic": { + "description": "Option when the server type/security should be discovered automatically." + }, + "accountDetailsIncomingSecurityLabel": "Seguridad entrante:", + "@accountDetailsIncomingSecurityLabel": { + "description": "Label for server security dropdown." + }, + "accountDetailsSecurityOptionNone": "Plain (sin cifrado)", + "@accountDetailsSecurityOptionNone": { + "description": "Label for security dropdown option without encryption." + }, + "accountDetailsIncomingPortLabel": "Puerto entrante", + "@accountDetailsIncomingPortLabel": { + "description": "Label for incoming port input field." + }, + "accountDetailsPortHint": "Dejar en blanco para determinar automáticamente", + "@accountDetailsPortHint": { + "description": "Hint for port input fields." + }, + "accountDetailsIncomingUserNameLabel": "Nombre de usuario entrante", + "@accountDetailsIncomingUserNameLabel": { + "description": "Label for incoming user name input field." + }, + "accountDetailsAlternativeUserNameHint": "Tu nombre de usuario, si es diferente de arriba", + "@accountDetailsAlternativeUserNameHint": { + "description": "Label for alternative user name input fields." + }, + "accountDetailsIncomingPasswordLabel": "Contraseña entrante", + "@accountDetailsIncomingPasswordLabel": { + "description": "Label for incoming password input field." + }, + "accountDetailsAlternativePasswordHint": "Su contraseña, si es diferente de la anterior", + "@accountDetailsAlternativePasswordHint": { + "description": "Label for alternative user name input fields." + }, + "accountDetailsAdvancedOutgoingSectionTitle": "Ajustes avanzados de salida", + "@accountDetailsAdvancedOutgoingSectionTitle": { + "description": "Title of incoming settings section." + }, + "accountDetailsOutgoingServerTypeLabel": "Tipo saliente:", + "@accountDetailsOutgoingServerTypeLabel": { + "description": "Label for server type dropdown." + }, + "accountDetailsOutgoingSecurityLabel": "Seguridad saliente:", + "@accountDetailsOutgoingSecurityLabel": { + "description": "Label for server security dropdown." + }, + "accountDetailsOutgoingPortLabel": "Puerto saliente", + "@accountDetailsOutgoingPortLabel": { + "description": "Label for outgoing port input field." + }, + "accountDetailsOutgoingUserNameLabel": "Nombre de usuario saliente", + "@accountDetailsOutgoingUserNameLabel": { + "description": "Label for outgoing user name input field." + }, + "accountDetailsOutgoingPasswordLabel": "Contraseña saliente", + "@accountDetailsOutgoingPasswordLabel": { + "description": "Label for outgoing password input field." + }, + "composeTitleNew": "Nuevo mensaje", + "@composeTitleNew": { + "description": "Title for compose screen when a new message is created." + }, + "composeTitleForward": "Reenviar", + "@composeTitleForward": { + "description": "Title for compose screen when a message is forwarded." + }, + "composeTitleReply": "Responder", + "@composeTitleReply": { + "description": "Title for compose screen when a message is replied." + }, + "composeEmptyMessage": "mensaje vacío", + "@composeEmptyMessage": { + "description": "Message text for message without text." + }, + "composeWarningNoSubject": "No ha especificado un asunto. ¿Desea enviar el mensaje sin un asunto?", + "@composeWarningNoSubject": { + "description": "Warning shown when trying to send a message without subject." + }, + "composeActionSentWithoutSubject": "Enviar", + "@composeActionSentWithoutSubject": { + "description": "Action to send message without subject." + }, + "composeMailSendSuccess": "Email enviado 😊", + "@composeMailSendSuccess": { + "description": "Notification shown after a mail was sent successfully." + }, + "composeSendErrorInfo": "Lo sentimos, no se ha podido enviar tu correo. Hemos recibido el siguiente error:\n{details}.", + "@composeSendErrorInfo": { + "description": "Error shown when email could not be send", + "placeholders": { + "details": { + "type": "String", + "example": "554-Reject due to policy restrictions." + } + } + }, + "composeRequestReadReceiptAction": "Solicitar recibo de lectura", + "@composeRequestReadReceiptAction": { + "description": "Action to request a read receipt for this message" + }, + "composeSaveDraftAction": "Guardar como borrador", + "@composeSaveDraftAction": { + "description": "Action to save a message as draft" + }, + "composeMessageSavedAsDraft": "Borrador guardado", + "@composeMessageSavedAsDraft": { + "description": "Info shown when message was saved as draft successfully" + }, + "composeMessageSavedAsDraftErrorInfo": "No se ha podido guardar tu borrador con el siguiente error:\n{details}", + "@composeMessageSavedAsDraftErrorInfo": { + "description": "Info shown when message could not be saved as a draft", + "placeholders": { + "details": { + "type": "String", + "example": "Invalid header: XX" + } + } + }, + "composeConvertToPlainTextEditorAction": "Convertir a texto plano", + "@composeConvertToPlainTextEditorAction": { + "description": "Action to write a plain text message instead of an html message" + }, + "composeConvertToHtmlEditorAction": "Convertir a mensaje enriquecido (HTML)", + "@composeConvertToHtmlEditorAction": { + "description": "Action to write a HTML message instead of a text message" + }, + "composeContinueEditingAction": "Continuar editando", + "@composeContinueEditingAction": { + "description": "Action to return to compose screen when draft cannot be saved" + }, + "composeCreatePlusAliasAction": "Crear nuevos + alias...", + "@composeCreatePlusAliasAction": { + "description": "Action to create a new + alias as a sender" + }, + "composeSenderHint": "Remitente", + "@composeSenderHint": { + "description": "Hint for From input field" + }, + "composeRecipientHint": "Email del destinatario", + "@composeRecipientHint": { + "description": "Hint for To input field" + }, + "composeSubjectLabel": "Sujeto", + "@composeSubjectLabel": { + "description": "Label for Subject input field" + }, + "composeSubjectHint": "Asunto del mensaje", + "@composeSubjectHint": { + "description": "Hint for Subject input field" + }, + "composeAddAttachmentAction": "Añadir", + "@composeAddAttachmentAction": { + "description": "Action to add an attachment - should be short!" + }, + "composeRemoveAttachmentAction": "Eliminar {name}", + "@composeRemoveAttachmentAction": { + "description": "Action to remove an attachment", + "placeholders": { + "name": { + "type": "String", + "example": "funny.png" + } + } + }, + "composeLeftByMistake": "¿Dejado por error?", + "@composeLeftByMistake": { + "description": "Info shown after leaving compose screen to allow an easy return" + }, + "attachTypeFile": "Fichero", + "@attachTypeFile": { + "description": "Attachment type to add" + }, + "attachTypePhoto": "Foto", + "@attachTypePhoto": { + "description": "Attachment type to add" + }, + "attachTypeVideo": "Vídeo", + "@attachTypeVideo": { + "description": "Attachment type to add" + }, + "attachTypeAudio": "Audio", + "@attachTypeAudio": { + "description": "Attachment type to add" + }, + "attachTypeLocation": "Ubicación", + "@attachTypeLocation": { + "description": "Attachment type to add" + }, + "attachTypeGif": "Gif animado", + "@attachTypeGif": { + "description": "Attachment type to add" + }, + "attachTypeGifSearch": "buscar GIPHY", + "@attachTypeGifSearch": { + "description": "Text for searching in GIPHY service for GIF" + }, + "attachTypeSticker": "Pegatina", + "@attachTypeSticker": { + "description": "Attachment type to add" + }, + "attachTypeStickerSearch": "buscar GIPHY", + "@attachTypeStickerSearch": { + "description": "Text for searching in GIPHY service for sticker" + }, + "attachTypeAppointment": "Cita", + "@attachTypeAppointment": { + "description": "Attachment type to add" + }, + "languageSettingTitle": "Idioma", + "@languageSettingTitle": { + "description": "Title of language setting screen" + }, + "languageSettingLabel": "Elige el idioma para Maily:", + "@languageSettingLabel": { + "description": "Label for language setting dropdown screen" + }, + "languageSettingSystemOption": "Idioma del sistema", + "@languageSettingSystemOption": { + "description": "Option to use the system's settings" + }, + "languageSettingConfirmationTitle": "¿Usar Inglés para Maily?", + "@languageSettingConfirmationTitle": { + "description": "Title of dialog to confirm when switching the language" + }, + "languageSettingConfirmationQuery": "Por favor confirme el uso del inglés como idioma elegido.", + "@languageSettingConfirmationQuery": { + "description": "Query to be confirmed by user when switching the language" + }, + "languageSetInfo": "Ahora se muestra en inglés. Por favor, reinicia la aplicación para que surta efecto.", + "@languageSetInfo": { + "description": "Info text after having specified the language." + }, + "languageSystemSetInfo": "Maily ahora utilizará el idioma del sistema o Inglés si el idioma del sistema no es compatible.", + "@languageSystemSetInfo": { + "description": "Info text after choosing the system's language for Maily." + }, + "swipeSettingTitle": "Deslizar gestos", + "@swipeSettingTitle": { + "description": "Title of swipe setting screen" + }, + "swipeSettingLeftToRightLabel": "Deslizar de izquierda a derecha", + "@swipeSettingLeftToRightLabel": { + "description": "Label for swipe gesture" + }, + "swipeSettingRightToLeftLabel": "Deslizar derecha a izquierda", + "@swipeSettingRightToLeftLabel": { + "description": "Label for swipe gesture" + }, + "swipeSettingChangeAction": "Cambiar", + "@swipeSettingChangeAction": { + "description": "Action for changing a swipe gesture" + }, + "signatureSettingsTitle": "Firma", + "@signatureSettingsTitle": { + "description": "Title of signature setting screen" + }, + "signatureSettingsComposeActionsInfo": "Activar la firma para los siguientes mensajes:", + "@signatureSettingsComposeActionsInfo": { + "description": "Text before selecting message types for a signature (new, forward, reply)." + }, + "signatureSettingsAccountInfo": "Puede especificar firmas específicas de la cuenta en la configuración de la cuenta.", + "@signatureSettingsAccountInfo": { + "description": "Informational text before showing link to account settings." + }, + "signatureSettingsAddForAccount": "Añadir firma para {account}", + "@signatureSettingsAddForAccount": { + "description": "Action to add account specific signature.", + "placeholders": { + "account": { + "type": "String", + "example": "domain.com" + } + } + }, + "defaultSenderSettingsTitle": "Remitente por defecto", + "@defaultSenderSettingsTitle": { + "description": "Title of default sender setting screen" + }, + "defaultSenderSettingsLabel": "Seleccione el remitente para nuevos mensajes.", + "@defaultSenderSettingsLabel": { + "description": "Description of default sender setting screen" + }, + "defaultSenderSettingsFirstAccount": "Primera cuenta ({email})", + "@defaultSenderSettingsFirstAccount": { + "description": "The default sender is the one from the first account", + "placeholders": { + "email": { + "type": "String", + "example": "email@domain.com" + } + } + }, + "defaultSenderSettingsAliasInfo": "Puede configurar direcciones de alias de correo electrónico en la [AS].", + "@defaultSenderSettingsAliasInfo": { + "description": "Info about that email aliases can be set up in the account settings. [AS] is the place, where defaultSenderSettingsAliasAccountSettings is included as a link." + }, + "defaultSenderSettingsAliasAccountSettings": "configuración de cuenta", + "@defaultSenderSettingsAliasAccountSettings": { + "description": "Text of the account settings link." + }, + "replySettingsTitle": "Formato de mensaje", + "@replySettingsTitle": { + "description": "Reply settings title." + }, + "replySettingsIntro": "¿En qué formato desea responder o reenviar el correo electrónico por defecto?", + "@replySettingsIntro": { + "description": "Reply settings introduction text." + }, + "replySettingsFormatHtml": "Formato siempre rico (HTML)", + "@replySettingsFormatHtml": { + "description": "Reply settings option." + }, + "replySettingsFormatSameAsOriginal": "Usar el mismo formato que el correo original", + "@replySettingsFormatSameAsOriginal": { + "description": "Reply settings option." + }, + "replySettingsFormatPlainText": "Siempre sólo texto", + "@replySettingsFormatPlainText": { + "description": "Reply settings option." + }, + "moveTitle": "Mover mensaje", + "@moveTitle": { + "description": "Title of move to mailbox dialog." + }, + "moveSuccess": "Mensajes movidos a {mailbox}.", + "@moveSuccess": { + "description": "Message after moving message successfully.", + "placeholders": { + "mailbox": { + "type": "String", + "example": "Inbox" + } + } + }, + "editorArtInputLabel": "Tu entrada", + "@editorArtInputLabel": { + "description": "Label of input field when inserting a formatted text" + }, + "editorArtInputHint": "Introduce el texto aquí", + "@editorArtInputHint": { + "description": "Hint of input field when inserting a formatted text" + }, + "editorArtWaitingForInputHint": "esperando por entrada...", + "@editorArtWaitingForInputHint": { + "description": "Text shown while waiting for input" + }, + "fontSerifBold": "Serif bold", + "@fontSerifBold": { + "description": "Font name" + }, + "fontSerifItalic": "Serif italic", + "@fontSerifItalic": { + "description": "Font name" + }, + "fontSerifBoldItalic": "Serif bold italic", + "@fontSerifBoldItalic": { + "description": "Font name" + }, + "fontSans": "Sans", + "@fontSans": { + "description": "Font name" + }, + "fontSansBold": "Sans bold", + "@fontSansBold": { + "description": "Font name" + }, + "fontSansItalic": "Sans italic", + "@fontSansItalic": { + "description": "Font name" + }, + "fontSansBoldItalic": "Sans bold italic", + "@fontSansBoldItalic": { + "description": "Font name" + }, + "fontScript": "Escribir", + "@fontScript": { + "description": "Font name" + }, + "fontScriptBold": "Escribir negrita", + "@fontScriptBold": { + "description": "Font name" + }, + "fontFraktur": "Fraktur", + "@fontFraktur": { + "description": "Font name" + }, + "fontFrakturBold": "Fraktur bold", + "@fontFrakturBold": { + "description": "Font name" + }, + "fontMonospace": "Monoespaciado", + "@fontMonospace": { + "description": "Font name" + }, + "fontFullwidth": "Ancho completo", + "@fontFullwidth": { + "description": "Font name" + }, + "fontDoublestruck": "Doble golpeado", + "@fontDoublestruck": { + "description": "Font name" + }, + "fontCapitalized": "Capitalizado", + "@fontCapitalized": { + "description": "Font name" + }, + "fontCircled": "Circlado", + "@fontCircled": { + "description": "Font name" + }, + "fontParenthesized": "Parentesizado", + "@fontParenthesized": { + "description": "Font name" + }, + "fontUnderlinedSingle": "Subrayado", + "@fontUnderlinedSingle": { + "description": "Font name" + }, + "fontUnderlinedDouble": "Doble subrayado", + "@fontUnderlinedDouble": { + "description": "Font name" + }, + "fontStrikethroughSingle": "Golpear a través", + "@fontStrikethroughSingle": { + "description": "Font name" + }, + "fontCrosshatch": "Crosshatch", + "@fontCrosshatch": { + "description": "Font name" + }, + "accountLoadError": "No se puede conectar a su cuenta {name}. ¿Ha cambiado la contraseña?", + "@accountLoadError": { + "description": "Message shown when single account could not be loaded.", + "placeholders": { + "name": { + "type": "String", + "example": "domain.com" + } + } + }, + "accountLoadErrorEditAction": "Editar cuenta", + "@accountLoadErrorEditAction": { + "description": "Action to edit account" + }, + "extensionsTitle": "Extensiones", + "@extensionsTitle": { + "description": "Title of extension section within the development settings" + }, + "extensionsIntro": "Con los proveedores de servicios de correo electrónico de extensiones, las empresas y los desarrolladores pueden adaptarse a las funcionalidades más útiles.", + "@extensionsIntro": { + "description": "Explanation of extensions" + }, + "extensionsLearnMoreAction": "Más información sobre extensiones", + "@extensionsLearnMoreAction": { + "description": "Label for launching a website with more information" + }, + "extensionsReloadAction": "Recargar extensiones", + "@extensionsReloadAction": { + "description": "Action to refresh extensions" + }, + "extensionDeactivateAllAction": "Desactivar todas las extensiones", + "@extensionDeactivateAllAction": { + "description": "Action to deactivate / unload any extension" + }, + "extensionsManualAction": "Cargar manualmente", + "@extensionsManualAction": { + "description": "Action to load extension manually" + }, + "extensionsManualUrlLabel": "Url de la extensión", + "@extensionsManualUrlLabel": { + "description": "Label for URL input field" + }, + "extensionsManualLoadingError": "No se ha podido descargar la extensión de \"{url}\".", + "@extensionsManualLoadingError": { + "description": "Message shown when extensions could not be loaded.", + "placeholders": { + "url": { + "type": "String", + "example": "https://domain.com/.maily.json" + } + } + }, + "icalendarAcceptTentatively": "Tentativamente", + "@icalendarAcceptTentatively": { + "description": "Action to accept an icalendar invitation tentatively" + }, + "icalendarActionChangeParticipantStatus": "Cambiar", + "@icalendarActionChangeParticipantStatus": { + "description": "Action to change an icalendar invitation participant status" + }, + "icalendarLabelSummary": "Título", + "@icalendarLabelSummary": { + "description": "Label of the summary info of an icalendar object" + }, + "icalendarNoSummaryInfo": "(sin título)", + "@icalendarNoSummaryInfo": { + "description": "Info shown when the icalendar object has no summary (no title)" + }, + "icalendarLabelDescription": "Descripción", + "@icalendarLabelDescription": { + "description": "Label of the description info of an icalendar object" + }, + "icalendarLabelStart": "Comenzar", + "@icalendarLabelStart": { + "description": "Label of the start datetime info of an icalendar object" + }, + "icalendarLabelEnd": "Fin", + "@icalendarLabelEnd": { + "description": "Label of the end datetime info of an icalendar object" + }, + "icalendarLabelDuration": "Duración", + "@icalendarLabelDuration": { + "description": "Label of the duration info of an icalendar object" + }, + "icalendarLabelLocation": "Ubicación", + "@icalendarLabelLocation": { + "description": "Label of the location info of an icalendar object" + }, + "icalendarLabelTeamsUrl": "Enlace", + "@icalendarLabelTeamsUrl": { + "description": "Label of the ms teams url info of an icalendar object" + }, + "icalendarLabelRecurrenceRule": "Repetir", + "@icalendarLabelRecurrenceRule": { + "description": "Label of the recurrence info of an icalendar object" + }, + "icalendarLabelParticipants": "Participantes", + "@icalendarLabelParticipants": { + "description": "Label of the participants info of an icalendar object" + }, + "icalendarParticipantStatusNeedsAction": "Se le pide que responda a esta invitación.", + "@icalendarParticipantStatusNeedsAction": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusAccepted": "Has aceptado esta invitación.", + "@icalendarParticipantStatusAccepted": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusDeclined": "Has rechazado esta invitación.", + "@icalendarParticipantStatusDeclined": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusAcceptedTentatively": "Has aceptado esta invitación de forma tentativa.", + "@icalendarParticipantStatusAcceptedTentatively": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusDelegated": "Usted ha delegado esta invitación.", + "@icalendarParticipantStatusDelegated": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusInProcess": "La tarea está en curso.", + "@icalendarParticipantStatusInProcess": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusPartial": "La tarea está parcialmente hecha.", + "@icalendarParticipantStatusPartial": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusCompleted": "La tarea está hecha.", + "@icalendarParticipantStatusCompleted": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusOther": "Su estado es desconocido.", + "@icalendarParticipantStatusOther": { + "description": "Participant status of the current user for a icalendar object" + }, + "icalendarParticipantStatusChangeTitle": "Tu estado", + "@icalendarParticipantStatusChangeTitle": { + "description": "Title for dialog to change participant status" + }, + "icalendarParticipantStatusChangeText": "¿Quieres aceptar esta invitación?", + "@icalendarParticipantStatusChangeText": { + "description": "Text of dialog to change participant status" + }, + "icalendarParticipantStatusSentFailure": "No se puede enviar la respuesta.\nEl servidor respondió con los siguientes detalles:\n{details}", + "@icalendarParticipantStatusSentFailure": { + "description": "Failure message for a status change reply", + "placeholders": { + "details": { + "type": "String", + "example": "No internet connection" + } + } + }, + "icalendarExportAction": "Exportar", + "@icalendarExportAction": { + "description": "Action to export the invite to the native calendar" + }, + "icalendarReplyStatusNeedsAction": "{attendee} no ha respondido a esta invitación.", + "@icalendarReplyStatusNeedsAction": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusAccepted": "{attendee} ha aceptado la cita.", + "@icalendarReplyStatusAccepted": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusDeclined": "{attendee} ha rechazado esta invitación.", + "@icalendarReplyStatusDeclined": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusAcceptedTentatively": "{attendee} ha aceptado tentativamente esta invitación.", + "@icalendarReplyStatusAcceptedTentatively": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusDelegated": "{attendee} ha delegado esta invitación.", + "@icalendarReplyStatusDelegated": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusInProcess": "{attendee} ha iniciado esta tarea.", + "@icalendarReplyStatusInProcess": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusPartial": "{attendee} ha realizado parcialmente esta tarea.", + "@icalendarReplyStatusPartial": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusCompleted": "{attendee} ha finalizado esta tarea.", + "@icalendarReplyStatusCompleted": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyStatusOther": "{attendee} ha respondido con un estado desconocido.", + "@icalendarReplyStatusOther": { + "description": "Valid Calendar reply", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "icalendarReplyWithoutParticipants": "Esta respuesta de calendario no contiene participantes.", + "@icalendarReplyWithoutParticipants": { + "description": "Calendar reply without any participant" + }, + "icalendarReplyWithoutStatus": "{attendee} respondió sin un estado de participación.", + "@icalendarReplyWithoutStatus": { + "description": "Calendar reply without any participant status", + "placeholders": { + "attendee": { + "type": "String", + "example": "User Name " + } + } + }, + "composeAppointmentTitle": "Crear cita", + "@composeAppointmentTitle": { + "description": "Title for adding a new appointment screen" + }, + "composeAppointmentLabelDay": "día", + "@composeAppointmentLabelDay": { + "description": "Label for select day button" + }, + "composeAppointmentLabelTime": "tiempo", + "@composeAppointmentLabelTime": { + "description": "Label for select time button" + }, + "composeAppointmentLabelAllDayEvent": "Todo el día", + "@composeAppointmentLabelAllDayEvent": { + "description": "Label for is all day toggle" + }, + "composeAppointmentLabelRepeat": "Repetir", + "@composeAppointmentLabelRepeat": { + "description": "Label for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionNever": "Nunca", + "@composeAppointmentLabelRepeatOptionNever": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionDaily": "Diario", + "@composeAppointmentLabelRepeatOptionDaily": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionWeekly": "Semanal", + "@composeAppointmentLabelRepeatOptionWeekly": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionMonthly": "Mensual", + "@composeAppointmentLabelRepeatOptionMonthly": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentLabelRepeatOptionYearly": "Anualmente", + "@composeAppointmentLabelRepeatOptionYearly": { + "description": "Option for repeat drop-down" + }, + "composeAppointmentRecurrenceFrequencyLabel": "Frecuencia", + "@composeAppointmentRecurrenceFrequencyLabel": { + "description": "Label for frequency drop-down" + }, + "composeAppointmentRecurrenceIntervalLabel": "Intervalo", + "@composeAppointmentRecurrenceIntervalLabel": { + "description": "Label for interval drop-down" + }, + "composeAppointmentRecurrenceDaysLabel": "En días", + "@composeAppointmentRecurrenceDaysLabel": { + "description": "Label for days selection area in a weekly or monthly recurrence" + }, + "composeAppointmentRecurrenceUntilLabel": "Hasta", + "@composeAppointmentRecurrenceUntilLabel": { + "description": "Label for choosing end date of recurrence" + }, + "composeAppointmentRecurrenceUntilOptionUnlimited": "Sin límite", + "@composeAppointmentRecurrenceUntilOptionUnlimited": { + "description": "Option for no end date of recurrence" + }, + "composeAppointmentRecurrenceUntilOptionRecommended": "Recomendado ({duration})", + "@composeAppointmentRecurrenceUntilOptionRecommended": { + "description": "Option for standard end date of recurrence with the given duration", + "placeholders": { + "duration": { + "type": "String", + "example": "3 months" + } + } + }, + "composeAppointmentRecurrenceUntilOptionSpecificDate": "Hasta la fecha elegida", + "@composeAppointmentRecurrenceUntilOptionSpecificDate": { + "description": "Option for specific end date of recurrence" + }, + "composeAppointmentRecurrenceMonthlyOnDayOfMonth": "El {day}. día del mes", + "@composeAppointmentRecurrenceMonthlyOnDayOfMonth": { + "description": "Option for repeating an event always on that day of the month", + "placeholders": { + "day": { + "type": "int", + "example": "3", + "format": "compactLong" + } + } + }, + "composeAppointmentRecurrenceMonthlyOnWeekDay": "Día de la semana en mes", + "@composeAppointmentRecurrenceMonthlyOnWeekDay": { + "description": "Monthly repeat on weekday of a chosen week" + }, + "composeAppointmentRecurrenceFirst": "Primero", + "@composeAppointmentRecurrenceFirst": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceSecond": "Segundo", + "@composeAppointmentRecurrenceSecond": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceThird": "Tercer", + "@composeAppointmentRecurrenceThird": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceLast": "Último", + "@composeAppointmentRecurrenceLast": { + "description": "Monthly day option" + }, + "composeAppointmentRecurrenceSecondLast": "Segundo-último", + "@composeAppointmentRecurrenceSecondLast": { + "description": "Monthly day option" + }, + "durationYears": "{number,plural, =1{1 año} other{{number} años}}", + "@durationYears": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationMonths": "{number,plural, =1{1 mes} other{{number} meses}}", + "@durationMonths": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationWeeks": "{number,plural, =1{1 semana} other{{number} semanas}}", + "@durationWeeks": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationDays": "{number,plural, =1{1 día} other{{number} días}}", + "@durationDays": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationHours": "{number,plural, =1{1 hora} other{{number} horas}}", + "@durationHours": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationMinutes": "{number,plural, =1{1 minuto} other{{number} minutos}}", + "@durationMinutes": { + "description": "Duration. Message formatted using the plural JSON scheme.", + "placeholders": { + "number": { + "type": "int", + "example": "2", + "format": "compactLong" + } + } + }, + "durationEmpty": "Sin duración", + "@durationEmpty": { + "description": "Text shown when the duration is 0" + } +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.g.dart b/lib/localization/app_localizations.g.dart similarity index 98% rename from lib/l10n/app_localizations.g.dart rename to lib/localization/app_localizations.g.dart index e194044..f863f61 100644 --- a/lib/l10n/app_localizations.g.dart +++ b/lib/localization/app_localizations.g.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart' as intl; import 'app_localizations_de.g.dart'; import 'app_localizations_en.g.dart'; +import 'app_localizations_es.g.dart'; /// Callers can lookup localized strings with an instance of AppLocalizations /// returned by `AppLocalizations.of(context)`. @@ -16,7 +17,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, @@ -90,7 +91,8 @@ abstract class AppLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [ Locale('en'), - Locale('de') + Locale('de'), + Locale('es') ]; /// Default signature text @@ -399,6 +401,12 @@ abstract class AppLocalizations { /// **'Please select messages first.'** String get multipleSelectionNeededInfo; + /// Error message when the selection action failed. + /// + /// In en, this message translates to: + /// **'Unable to perform action\nDetails: {details}'** + String multipleSelectionActionFailed(String details); + /// Title of move dialog for multiple messages. Message formatted using the plural JSON scheme. /// /// In en, this message translates to: @@ -627,6 +635,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: @@ -819,6 +833,18 @@ abstract class AppLocalizations { /// **'Open'** String get attachmentActionOpen; + /// Text shown when downloaded attachment could not be decoded. + /// + /// In en, this message translates to: + /// **'This attachment has an unsupported format or encoding.\nDetails: \${details}'** + String attachmentDecodeError(String details); + + /// Text shown when attachment could not be downloaded. + /// + /// In en, this message translates to: + /// **'Unable to download this attachment.\nDetails: \${details}'** + String attachmentDownloadError(String details); + /// Action for single message. /// /// In en, this message translates to: @@ -1560,7 +1586,7 @@ abstract class AppLocalizations { /// Acknowledgement to be confirmed by user to acknowledge the fact that an app specific password is required. /// /// In en, this message translates to: - /// **'Understood'** + /// **'I already have an app password'** String get addAccountApplicationPasswordRequiredAcknowledged; /// Section header of verification/log in step. @@ -2304,13 +2330,13 @@ abstract class AppLocalizations { /// Info text after having specified the language. /// /// In en, this message translates to: - /// **'Maily is now shown in English. Please restart the app to take effect.'** + /// **'Maily is now shown in English.'** String get languageSetInfo; /// Info text after choosing the system's language for Maily. /// /// In en, this message translates to: - /// **'Maily will now use the system\'s language or English if the system\'s language is not supported. Please restart the app to take effect.'** + /// **'Maily will now use the system\'s language or English if the system\'s language is not supported.'** String get languageSystemSetInfo; /// Title of swipe setting screen @@ -3049,7 +3075,7 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate } @override - bool isSupported(Locale locale) => ['de', 'en'].contains(locale.languageCode); + bool isSupported(Locale locale) => ['de', 'en', 'es'].contains(locale.languageCode); @override bool shouldReload(_AppLocalizationsDelegate old) => false; @@ -3062,6 +3088,7 @@ AppLocalizations lookupAppLocalizations(Locale locale) { switch (locale.languageCode) { case 'de': return AppLocalizationsDe(); case 'en': return AppLocalizationsEn(); + case 'es': return AppLocalizationsEs(); } throw FlutterError( diff --git a/lib/l10n/app_localizations_de.g.dart b/lib/localization/app_localizations_de.g.dart similarity index 97% rename from lib/l10n/app_localizations_de.g.dart rename to lib/localization/app_localizations_de.g.dart index c74863b..a1c2839 100644 --- a/lib/l10n/app_localizations_de.g.dart +++ b/lib/localization/app_localizations_de.g.dart @@ -217,6 +217,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get multipleSelectionNeededInfo => 'Wähle mindestens eine Nachricht aus.'; + @override + String multipleSelectionActionFailed(String details) { + return 'Unable to perform action\nDetails: $details'; + } + @override String multipleMoveTitle(int number) { final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( @@ -361,6 +366,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get folderJunk => 'Spam Nachrichten'; + @override + String get folderUnknown => 'Unbekannt'; + @override String get viewContentsAction => 'Inhalt anzeigen'; @@ -469,6 +477,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get attachmentActionOpen => 'Öffnen'; + @override + String attachmentDecodeError(String details) { + return 'Dieses Attachment ist in einem unbekannten Format.\nDetails: \$$details'; + } + + @override + String attachmentDownloadError(String details) { + return 'Dieses Attachment konnte nicht heruntergeladen werden.\nDetails: \$$details'; + } + @override String get messageActionReply => 'Antworten'; @@ -589,7 +607,7 @@ class AppLocalizationsDe extends AppLocalizations { String get legaleseTermsAndConditions => 'Bedingungen'; @override - String get aboutApplicationLegalese => 'Maily ist freie Software unter der GPL GNU General Public License lizensiert.'; + String get aboutApplicationLegalese => 'Maily ist freie Software, die unter der GPL GNU General Public License veröffentlicht ist.'; @override String get feedbackActionSuggestFeature => 'Feature vorschlagen'; @@ -604,7 +622,7 @@ class AppLocalizationsDe extends AppLocalizations { String get feedbackTitle => 'Feedback'; @override - String get feedbackIntro => 'Danke dass du Maily testest!'; + String get feedbackIntro => 'Danke, dass du Maily testest!'; @override String get feedbackProvideInfoRequest => 'Bitte teile folgende Information mit, wenn du ein Problem berichtest:'; @@ -628,7 +646,7 @@ class AppLocalizationsDe extends AppLocalizations { String get settingsSecurityBlockExternalImagesDescriptionTitle => 'Externe Bilder'; @override - String get settingsSecurityBlockExternalImagesDescriptionText => 'E-Mails können Bilder enthalten, die entweder in der Nachricht integriert sind oder die von externen Servern bereitgestellt werden. Solche externe Bilder können Daten zu dem Absender der Nachricht freigeben, zum Beispiel dass die Nachricht geöffnet wurde. Diese Option erlaubt es solche externen Bilder zu blockieren, um solche Datenlecks zu minimieren. Beim Lesen einer E-Mail kannst für jede Nachricht individual externe Bilder nachladen.'; + String get settingsSecurityBlockExternalImagesDescriptionText => 'E-Mail-Nachrichten können Bilder enthalten, die entweder auf externen Servern integriert oder gehostet werden. Die letzteren externen Bilder können dem Absender der Nachricht Informationen offen legen, z.B. um dem Absender mitzuteilen, dass Sie die Nachricht geöffnet haben. Mit dieser Option können Sie solche externen Bilder blockieren, was das Risiko verringert, sensible Informationen zu enthüllen. Wenn Sie eine Nachricht lesen, können Sie diese Bilder immer noch pro Nachricht laden.'; @override String get settingsSecurityMessageRenderingHtml => 'Gesamte Nachricht anzeigen'; @@ -875,7 +893,7 @@ class AppLocalizationsDe extends AppLocalizations { String get addAccountApplicationPasswordRequiredButton => 'App Passwort erstellen'; @override - String get addAccountApplicationPasswordRequiredAcknowledged => 'Verstanden'; + String get addAccountApplicationPasswordRequiredAcknowledged => 'Ich habe bereits ein App Passwort'; @override String get addAccountVerificationStep => 'Überprüfen'; @@ -1365,7 +1383,7 @@ class AppLocalizationsDe extends AppLocalizations { String get editorArtInputHint => 'Hier Text eingeben'; @override - String get editorArtWaitingForInputHint => 'warte auf Eingabe'; + String get editorArtWaitingForInputHint => 'warte auf Eingabe...'; @override String get fontSerifBold => 'Serif fett'; @@ -1389,7 +1407,7 @@ class AppLocalizationsDe extends AppLocalizations { String get fontSansBoldItalic => 'Sans fett kursiv'; @override - String get fontScript => 'Script'; + String get fontScript => 'Skript'; @override String get fontScriptBold => 'Script fett'; @@ -1407,7 +1425,7 @@ class AppLocalizationsDe extends AppLocalizations { String get fontFullwidth => 'Fullwidth'; @override - String get fontDoublestruck => 'Double struck'; + String get fontDoublestruck => 'Doppelt gestrichen'; @override String get fontCapitalized => 'Grossbuchstaben'; diff --git a/lib/l10n/app_localizations_en.g.dart b/lib/localization/app_localizations_en.g.dart similarity index 98% rename from lib/l10n/app_localizations_en.g.dart rename to lib/localization/app_localizations_en.g.dart index 2101c66..039736e 100644 --- a/lib/l10n/app_localizations_en.g.dart +++ b/lib/localization/app_localizations_en.g.dart @@ -217,6 +217,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get multipleSelectionNeededInfo => 'Please select messages first.'; + @override + String multipleSelectionActionFailed(String details) { + return 'Unable to perform action\nDetails: $details'; + } + @override String multipleMoveTitle(int number) { final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( @@ -361,6 +366,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get folderJunk => 'Junk'; + @override + String get folderUnknown => 'Unknown'; + @override String get viewContentsAction => 'View contents'; @@ -469,6 +477,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get attachmentActionOpen => 'Open'; + @override + String attachmentDecodeError(String details) { + return 'This attachment has an unsupported format or encoding.\nDetails: \$$details'; + } + + @override + String attachmentDownloadError(String details) { + return 'Unable to download this attachment.\nDetails: \$$details'; + } + @override String get messageActionReply => 'Reply'; @@ -875,7 +893,7 @@ class AppLocalizationsEn extends AppLocalizations { String get addAccountApplicationPasswordRequiredButton => 'Create app specific password'; @override - String get addAccountApplicationPasswordRequiredAcknowledged => 'Understood'; + String get addAccountApplicationPasswordRequiredAcknowledged => 'I already have an app password'; @override String get addAccountVerificationStep => 'Verification'; @@ -1287,10 +1305,10 @@ class AppLocalizationsEn extends AppLocalizations { String get languageSettingConfirmationQuery => 'Please confirm to use English as your chosen language.'; @override - String get languageSetInfo => 'Maily is now shown in English. Please restart the app to take effect.'; + String get languageSetInfo => 'Maily is now shown in English.'; @override - String get languageSystemSetInfo => 'Maily will now use the system\'s language or English if the system\'s language is not supported. Please restart the app to take effect.'; + String get languageSystemSetInfo => 'Maily will now use the system\'s language or English if the system\'s language is not supported.'; @override String get swipeSettingTitle => 'Swipe gestures'; diff --git a/lib/localization/app_localizations_es.g.dart b/lib/localization/app_localizations_es.g.dart new file mode 100644 index 0000000..03542dc --- /dev/null +++ b/lib/localization/app_localizations_es.g.dart @@ -0,0 +1,1801 @@ +import 'package:intl/intl.dart' as intl; + +import 'app_localizations.g.dart'; + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get signature => 'Enviado con Maily'; + + @override + String get actionCancel => 'Cancelar'; + + @override + String get actionOk => 'Ok'; + + @override + String get actionDone => 'Hecho'; + + @override + String get actionNext => 'Siguiente'; + + @override + String get actionSkip => 'Saltar'; + + @override + String get actionUndo => 'Deshacer'; + + @override + String get actionDelete => 'Eliminar'; + + @override + String get actionAccept => 'Aceptar'; + + @override + String get actionDecline => 'Rechazar'; + + @override + String get actionEdit => 'Editar'; + + @override + String get actionAddressCopy => 'Copiar'; + + @override + String get actionAddressCompose => 'Nuevo mensaje'; + + @override + String get actionAddressSearch => 'Buscar'; + + @override + String get splashLoading1 => 'Iniciando...'; + + @override + String get splashLoading2 => 'Preparando tu Motor de Maily...'; + + @override + String get splashLoading3 => 'Lanzando en el 10, 9, 8...'; + + @override + String get welcomePanel1Title => 'Maily'; + + @override + String get welcomePanel1Text => 'Bienvenido a Maily, tu ayudante de correo electrónico rápido y amistoso!'; + + @override + String get welcomePanel2Title => 'Cuentas'; + + @override + String get welcomePanel2Text => 'Administra cuentas de correo electrónico ilimitadas. Lee y busca correos en todas tus cuentas a la vez.'; + + @override + String get welcomePanel3Title => 'Deslizar y pulsar largo'; + + @override + String get welcomePanel3Text => 'Desliza el dedo por tus mensajes para borrarlos o marcarlos como leídos. Mantén pulsado un mensaje para seleccionarlo y gestionar varios.'; + + @override + String get welcomePanel4Title => 'Mantén tu bandeja de entrada limpia'; + + @override + String get welcomePanel4Text => 'Darse de baja de los boletines con un solo toque.'; + + @override + String get welcomeActionSignIn => 'Inicia sesión en tu cuenta de correo'; + + @override + String get homeSearchHint => 'Tu búsqueda'; + + @override + String get homeActionsShowAsStack => 'Mostrar como pila'; + + @override + String get homeActionsShowAsList => 'Mostrar como lista'; + + @override + String get homeEmptyFolderMessage => '¡Todo listo!\n\nNo hay mensajes en esta carpeta.'; + + @override + String get homeEmptySearchMessage => 'No se encontraron mensajes.'; + + @override + String get homeDeleteAllTitle => 'Confirmar'; + + @override + String get homeDeleteAllQuestion => '¿Realmente eliminar todos los mensajes?'; + + @override + String get homeDeleteAllAction => 'Borrar todo'; + + @override + String get homeDeleteAllScrubOption => 'Limpiar mensajes'; + + @override + String get homeDeleteAllSuccess => 'Todos los mensajes eliminados.'; + + @override + String get homeMarkAllSeenAction => 'Todos leídos'; + + @override + String get homeMarkAllUnseenAction => 'Todos no leídos'; + + @override + String get homeFabTooltip => 'Nuevo mensaje'; + + @override + String get homeLoadingMessageSourceTitle => 'Cargando...'; + + @override + String homeLoading(String name) { + return 'cargando $name...'; + } + + @override + String get swipeActionToggleRead => 'Marcar como leído/no leídos'; + + @override + String get swipeActionDelete => 'Eliminar'; + + @override + String get swipeActionMarkJunk => 'Marcar como basura'; + + @override + String get swipeActionArchive => 'Archivar'; + + @override + String get swipeActionFlag => 'Cambiar bandera'; + + @override + String multipleMovedToJunk(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: 'Marcado $numberString mensajes como basura', + one: 'Un mensaje marcado como basura', + ); + return '$_temp0'; + } + + @override + String multipleMovedToInbox(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '¡Se ha movido $numberString ¡Los mensajes a la bandeja de entrada', + one: '¡Se ha movido un mensaje a la bandeja de entrada', + ); + return '$_temp0'; + } + + @override + String multipleMovedToArchive(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '¡Archivado $numberString ¡Mensajes', + one: '¡Archivado un mensaje', + ); + return '$_temp0'; + } + + @override + String multipleMovedToTrash(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '¡Eliminado $numberString ¡Mensajes', + one: '¡Eliminado un mensaje', + ); + return '$_temp0'; + } + + @override + String get multipleSelectionNeededInfo => 'Por favor, seleccione mensajes primero.'; + + @override + String multipleSelectionActionFailed(String details) { + return 'Unable to perform action\nDetails: $details'; + } + + @override + String multipleMoveTitle(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: 'Mover $numberString mensajes', + one: 'Mover mensaje', + ); + return '$_temp0'; + } + + @override + String get messageActionMultipleMarkSeen => 'Marcar como leído'; + + @override + String get messageActionMultipleMarkUnseen => 'Marcar como no leído'; + + @override + String get messageActionMultipleMarkFlagged => 'Marcar mensajes'; + + @override + String get messageActionMultipleMarkUnflagged => 'Desmarcar mensajes'; + + @override + String get messageActionViewInSafeMode => 'Ver sin contenido externo'; + + @override + String get emailSenderUnknown => ''; + + @override + String get dateRangeFuture => 'futuro'; + + @override + String get dateRangeTomorrow => 'mañana'; + + @override + String get dateRangeToday => 'hoy'; + + @override + String get dateRangeYesterday => 'ayer'; + + @override + String get dateRangeCurrentWeek => 'esta semana'; + + @override + String get dateRangeLastWeek => 'semana pasada'; + + @override + String get dateRangeCurrentMonth => 'este mes'; + + @override + String get dateRangeLastMonth => 'mes pasado'; + + @override + String get dateRangeCurrentYear => 'este año'; + + @override + String get dateRangeLongAgo => 'hace mucho tiempo'; + + @override + String get dateUndefined => 'indefinido'; + + @override + String get dateDayToday => 'hoy'; + + @override + String get dateDayYesterday => 'ayer'; + + @override + String dateDayLastWeekday(String day) { + return 'último $day'; + } + + @override + String get drawerEntryAbout => 'Sobre Maily'; + + @override + String get drawerEntrySettings => 'Ajustes'; + + @override + String drawerAccountsSectionTitle(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '$numberString cuentas', + one: 'Una cuenta', + ); + return '$_temp0'; + } + + @override + String get drawerEntryAddAccount => 'Añadir cuenta'; + + @override + String get unifiedAccountName => 'Cuenta unificada'; + + @override + String get unifiedFolderInbox => 'Entrada unificada'; + + @override + String get unifiedFolderSent => 'Enviado unificado'; + + @override + String get unifiedFolderDrafts => 'Borradores unificados'; + + @override + String get unifiedFolderTrash => 'Basura unificada'; + + @override + String get unifiedFolderArchive => 'Archivo unificado'; + + @override + String get unifiedFolderJunk => 'Chatarra unificada'; + + @override + String get folderInbox => 'Entrada'; + + @override + String get folderSent => 'Enviado'; + + @override + String get folderDrafts => 'Borradores'; + + @override + String get folderTrash => 'Basura'; + + @override + String get folderArchive => 'Archivar'; + + @override + String get folderJunk => 'Chatarra'; + + @override + String get folderUnknown => 'Desconocido'; + + @override + String get viewContentsAction => 'Ver contenido'; + + @override + String get viewSourceAction => 'Ver fuente'; + + @override + String get detailsErrorDownloadInfo => 'No se pudo descargar el mensaje.'; + + @override + String get detailsErrorDownloadRetry => 'Reintentar'; + + @override + String get detailsHeaderFrom => 'De'; + + @override + String get detailsHeaderTo => 'A'; + + @override + String get detailsHeaderCc => 'CC'; + + @override + String get detailsHeaderBcc => 'BCC'; + + @override + String get detailsHeaderDate => 'Fecha'; + + @override + String get subjectUndefined => ''; + + @override + String get detailsActionShowImages => 'Mostrar imágenes'; + + @override + String get detailsNewsletterActionUnsubscribe => 'Desuscribirse'; + + @override + String get detailsNewsletterActionResubscribe => 'Volver a suscribirse'; + + @override + String get detailsNewsletterStatusUnsubscribed => 'No suscrito'; + + @override + String get detailsNewsletterUnsubscribeDialogTitle => 'Cancelar suscripción'; + + @override + String detailsNewsletterUnsubscribeDialogQuestion(String listName) { + return '¿Quieres darte de baja de la lista de correo $listName?'; + } + + @override + String get detailsNewsletterUnsubscribeDialogAction => 'Cancelar suscripción'; + + @override + String get detailsNewsletterUnsubscribeSuccessTitle => 'No suscrito'; + + @override + String detailsNewsletterUnsubscribeSuccessMessage(String listName) { + return 'Te has dado de baja de la lista de correo $listName.'; + } + + @override + String get detailsNewsletterUnsubscribeFailureTitle => 'No desuscrito'; + + @override + String detailsNewsletterUnsubscribeFailureMessage(String listName) { + return 'Lo siento, pero no he podido darte de baja de $listName automáticamente.'; + } + + @override + String get detailsNewsletterResubscribeDialogTitle => 'Volver a suscribirse'; + + @override + String detailsNewsletterResubscribeDialogQuestion(String listName) { + return '¿Quieres suscribirte de nuevo a esta lista de correo $listName?'; + } + + @override + String get detailsNewsletterResubscribeDialogAction => 'Suscribirse'; + + @override + String get detailsNewsletterResubscribeSuccessTitle => 'Suscrito'; + + @override + String detailsNewsletterResubscribeSuccessMessage(String listName) { + return 'Ahora estás suscrito a la lista de correo $listName de nuevo.'; + } + + @override + String get detailsNewsletterResubscribeFailureTitle => 'No suscrito'; + + @override + String detailsNewsletterResubscribeFailureMessage(String listName) { + return 'Lo sentimos, pero la solicitud de suscripción ha fallado para la lista de correo $listName.'; + } + + @override + String get detailsSendReadReceiptAction => 'Enviar recibo de lectura'; + + @override + String get detailsReadReceiptSentStatus => 'Leer el recibo enviado ✔️'; + + @override + String get detailsReadReceiptSubject => 'Leer recibo'; + + @override + String get attachmentActionOpen => 'Abrir'; + + @override + String attachmentDecodeError(String details) { + return 'Este archivo adjunto tiene un formato o codificación no compatibles.\nDetalles: \$$details'; + } + + @override + String attachmentDownloadError(String details) { + return 'No se puede descargar este adjunto.\nDetalles: \$$details'; + } + + @override + String get messageActionReply => 'Responder'; + + @override + String get messageActionReplyAll => 'Responder a todos'; + + @override + String get messageActionForward => 'Reenviar'; + + @override + String get messageActionForwardAsAttachment => 'Reenviar como archivo adjunto'; + + @override + String messageActionForwardAttachments(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: 'Reenviar $numberString archivos adjuntos', + one: '¡Adelante el adjunto', + ); + return '$_temp0'; + } + + @override + String get messagesActionForwardAttachments => 'Reenviar archivos adjuntos'; + + @override + String get messageActionDelete => 'Eliminar'; + + @override + String get messageActionMoveToInbox => 'Mover a bandeja de entrada'; + + @override + String get messageActionMove => 'Mover'; + + @override + String get messageStatusSeen => 'Es leído'; + + @override + String get messageStatusUnseen => 'No es leído'; + + @override + String get messageStatusFlagged => 'Está marcado'; + + @override + String get messageStatusUnflagged => 'No está marcado'; + + @override + String get messageActionMarkAsJunk => 'Marcar como basura'; + + @override + String get messageActionMarkAsNotJunk => 'Marcar como no basura'; + + @override + String get messageActionArchive => 'Archivar'; + + @override + String get messageActionUnarchive => 'Mover a bandeja de entrada'; + + @override + String get messageActionRedirect => 'Redireccionar'; + + @override + String get messageActionAddNotification => 'Añadir notificación'; + + @override + String get resultDeleted => 'Eliminado'; + + @override + String get resultMovedToJunk => 'Marcado como basura'; + + @override + String get resultMovedToInbox => 'Movido a la bandeja de entrada'; + + @override + String get resultArchived => 'Archivado'; + + @override + String get resultRedirectedSuccess => 'Mensaje redireccionado 👍'; + + @override + String resultRedirectedFailure(String details) { + return 'No se puede redirigir el mensaje.\n\nEl servidor respondió con los siguientes detalles: \"$details\"'; + } + + @override + String get redirectTitle => 'Redireccionar'; + + @override + String get redirectInfo => 'Redirigir este mensaje a los siguientes destinatarios. Redirigir no altera el mensaje.'; + + @override + String get redirectEmailInputRequired => 'Necesitas añadir al menos una dirección de correo electrónico válida.'; + + @override + String searchQueryDescription(String folder) { + return 'Buscar en $folder...'; + } + + @override + String searchQueryTitle(String query) { + return 'Buscar \"$query\"'; + } + + @override + String get legaleseUsage => 'Al utilizar Maily aceptas nuestras [PP] y nuestras [TC].'; + + @override + String get legalesePrivacyPolicy => 'Política de Privacidad'; + + @override + String get legaleseTermsAndConditions => 'Términos y Condiciones'; + + @override + String get aboutApplicationLegalese => 'Maily es un software libre publicado bajo la Licencia Pública General GNU.'; + + @override + String get feedbackActionSuggestFeature => 'Sugerir una característica'; + + @override + String get feedbackActionReportProblem => 'Reportar un problema'; + + @override + String get feedbackActionHelpDeveloping => 'Ayuda al desarrollo de Maily'; + + @override + String get feedbackTitle => 'Comentarios'; + + @override + String get feedbackIntro => '¡Gracias por probar Maily!'; + + @override + String get feedbackProvideInfoRequest => 'Por favor, proporcione esta información cuando reporte un problema:'; + + @override + String get feedbackResultInfoCopied => 'Copiado al portapapeles'; + + @override + String get accountsTitle => 'Cuentas'; + + @override + String get accountsActionReorder => 'Reordenar cuentas'; + + @override + String get settingsTitle => 'Ajustes'; + + @override + String get settingsSecurityBlockExternalImages => 'Bloquear imágenes externas'; + + @override + String get settingsSecurityBlockExternalImagesDescriptionTitle => 'Imágenes externas'; + + @override + String get settingsSecurityBlockExternalImagesDescriptionText => 'Los mensajes de correo electrónico pueden contener imágenes que están integradas o alojadas en servidores externos. Este último, imágenes externas pueden exponer información al remitente del mensaje, por ejemplo, para que el remitente sepa que ha abierto el mensaje. Esta opción le permite bloquear dichas imágenes externas, lo que reduce el riesgo de exponer información confidencial. Todavía puede optar por cargar dichas imágenes por mensaje cuando lea un mensaje.'; + + @override + String get settingsSecurityMessageRenderingHtml => 'Mostrar contenido completo del mensaje'; + + @override + String get settingsSecurityMessageRenderingPlainText => 'Mostrar sólo el texto de los mensajes'; + + @override + String get settingsSecurityLaunchModeLabel => '¿Cómo debe abrir enlaces Maily?'; + + @override + String get settingsSecurityLaunchModeExternal => 'Abrir enlaces externamente'; + + @override + String get settingsSecurityLaunchModeInApp => 'Abrir enlaces en Maily'; + + @override + String get settingsActionAccounts => 'Administrar cuentas'; + + @override + String get settingsActionDesign => 'Apariencia'; + + @override + String get settingsActionFeedback => 'Proporcionar comentarios'; + + @override + String get settingsActionWelcome => 'Mostrar bienvenida'; + + @override + String get settingsReadReceipts => 'Leer recibos'; + + @override + String get readReceiptsSettingsIntroduction => '¿Quieres mostrar las solicitudes de recibos de lectura?'; + + @override + String get readReceiptOptionAlways => 'Siempre'; + + @override + String get readReceiptOptionNever => 'Nunca'; + + @override + String get settingsFolders => 'Carpetas'; + + @override + String get folderNamesIntroduction => '¿Qué nombres prefiere para sus carpetas?'; + + @override + String get folderNamesSettingLocalized => 'Nombres dados por Maily'; + + @override + String get folderNamesSettingServer => 'Nombres dados por el servicio'; + + @override + String get folderNamesSettingCustom => 'Mis nombres personalizados'; + + @override + String get folderNamesEditAction => 'Editar nombres personalizados'; + + @override + String get folderNamesCustomTitle => 'Nombres personalizados'; + + @override + String get folderAddAction => 'Crear carpeta'; + + @override + String get folderAddTitle => 'Crear carpeta'; + + @override + String get folderAddNameLabel => 'Nombre'; + + @override + String get folderAddNameHint => 'Nombre de la nueva carpeta'; + + @override + String get folderAccountLabel => 'Cuenta'; + + @override + String get folderMailboxLabel => 'Carpeta'; + + @override + String get folderAddResultSuccess => 'Carpeta creada 😊'; + + @override + String folderAddResultFailure(String details) { + return 'No se pudo crear la carpeta.\n\nEl servidor respondió con $details'; + } + + @override + String get folderDeleteAction => 'Eliminar'; + + @override + String get folderDeleteConfirmTitle => 'Confirmar'; + + @override + String folderDeleteConfirmText(String name) { + return '¿Realmente desea eliminar la carpeta $name?'; + } + + @override + String get folderDeleteResultSuccess => 'Carpeta eliminada.'; + + @override + String folderDeleteResultFailure(String details) { + return 'No se ha podido eliminar la carpeta.\n\nEl servidor ha respondido con $details'; + } + + @override + String get settingsDevelopment => 'Configuración de desarrollo'; + + @override + String get developerModeTitle => 'Modo de desarrollo'; + + @override + String get developerModeIntroduction => 'Si activas el modo de desarrollo podrás ver el código fuente de los mensajes y convertir los archivos adjuntos de texto a mensajes.'; + + @override + String get developerModeEnable => 'Activar modo de desarrollo'; + + @override + String get developerShowAsEmail => 'Convertir texto a email'; + + @override + String get developerShowAsEmailFailed => 'Este texto no se puede convertir en un mensaje MIME.'; + + @override + String get designTitle => 'Ajustes de diseño'; + + @override + String get designSectionThemeTitle => 'Tema'; + + @override + String get designThemeOptionLight => 'Luz'; + + @override + String get designThemeOptionDark => 'Oscuro'; + + @override + String get designThemeOptionSystem => 'Sistema'; + + @override + String get designThemeOptionCustom => 'Personalizado'; + + @override + String get designSectionCustomTitle => 'Activar tema oscuro'; + + @override + String designThemeCustomStart(String time) { + return 'de $time'; + } + + @override + String designThemeCustomEnd(String time) { + return 'hasta $time'; + } + + @override + String get designSectionColorTitle => 'Esquema de color'; + + @override + String get securitySettingsTitle => 'Seguridad'; + + @override + String get securitySettingsIntro => 'Adapte la configuración de seguridad a sus necesidades personales.'; + + @override + String get securityUnlockWithFaceId => 'Desbloquea Maily con Face ID.'; + + @override + String get securityUnlockWithTouchId => 'Desbloquea Maily con Touch ID.'; + + @override + String get securityUnlockReason => 'Desbloquea Maily.'; + + @override + String get securityUnlockDisableReason => 'Desbloquear Maily para desactivar el bloqueo.'; + + @override + String get securityUnlockNotAvailable => 'Su dispositivo no soporta biométricos, posiblemente necesite configurar las opciones de desbloqueo primero.'; + + @override + String get securityUnlockLabel => 'Bloquear Maily'; + + @override + String get securityUnlockDescriptionTitle => 'Bloquear Maily'; + + @override + String get securityUnlockDescriptionText => 'Puedes elegir bloquear el acceso a Maily, para que otros no puedan leer tu correo electrónico incluso cuando tengan acceso a tu dispositivo.'; + + @override + String get securityLockImmediately => 'Bloquear inmediatamente'; + + @override + String get securityLockAfter5Minutes => 'Bloquear después de 5 minutos'; + + @override + String get securityLockAfter30Minutes => 'Bloquear después de 30 minutos'; + + @override + String get lockScreenTitle => 'Maily está bloqueado'; + + @override + String get lockScreenIntro => 'Maily está bloqueado, por favor autentifíquese para continuar.'; + + @override + String get lockScreenUnlockAction => 'Desbloquear'; + + @override + String get addAccountTitle => 'Añadir cuenta'; + + @override + String get addAccountEmailLabel => 'E-mail'; + + @override + String get addAccountEmailHint => 'Introduzca su dirección de correo electrónico'; + + @override + String addAccountResolvingSettingsLabel(String email) { + return 'Resolviendo $email...'; + } + + @override + String addAccountResolvedSettingsWrongAction(String provider) { + return '¿No está en $provider?'; + } + + @override + String addAccountResolvingSettingsFailedInfo(String email) { + return 'No se puede resolver $email. Por favor, vuelve a cambiarlo o configura la cuenta manualmente.'; + } + + @override + String get addAccountEditManuallyAction => 'Editar manualmente'; + + @override + String get addAccountPasswordLabel => 'Contraseña'; + + @override + String get addAccountPasswordHint => 'Por favor, introduce tu contraseña'; + + @override + String get addAccountApplicationPasswordRequiredInfo => 'Este proveedor requiere que establezcas una contraseña específica para la aplicación.'; + + @override + String get addAccountApplicationPasswordRequiredButton => 'Crear contraseña específica de la aplicación'; + + @override + String get addAccountApplicationPasswordRequiredAcknowledged => 'Ya tengo una contraseña de la aplicación'; + + @override + String get addAccountVerificationStep => 'Verificación'; + + @override + String get addAccountSetupAccountStep => 'Configuracion de Cuenta'; + + @override + String addAccountVerifyingSettingsLabel(String email) { + return 'Verificando $email...'; + } + + @override + String addAccountVerifyingSuccessInfo(String email) { + return 'Has iniciado sesión con éxito en $email.'; + } + + @override + String addAccountVerifyingFailedInfo(String email) { + return 'Lo sentimos, pero ha habido un problema. Por favor, comprueba tu correo electrónico $email y contraseña.'; + } + + @override + String addAccountOauthOptionsText(String provider) { + return 'Inicie sesión con $provider o cree una contraseña específica de la aplicación.'; + } + + @override + String addAccountOauthSignIn(String provider) { + return 'Iniciar sesión con $provider'; + } + + @override + String get addAccountOauthSignInGoogle => 'Iniciar sesión con Google'; + + @override + String get addAccountOauthSignInWithAppPassword => 'Alternativamente, cree una contraseña de la aplicación para iniciar sesión.'; + + @override + String get accountAddImapAccessSetupMightBeRequired => 'Su proveedor puede requerir que configure el acceso para aplicaciones de correo electrónico manualmente.'; + + @override + String get addAccountSetupImapAccessButtonLabel => 'Configurar acceso a email'; + + @override + String get addAccountNameOfUserLabel => 'Tu nombre'; + + @override + String get addAccountNameOfUserHint => 'El nombre que los destinatarios ven'; + + @override + String get addAccountNameOfAccountLabel => 'Nombre de cuenta'; + + @override + String get addAccountNameOfAccountHint => 'Introduce el nombre de tu cuenta'; + + @override + String editAccountTitle(String name) { + return 'Editar $name'; + } + + @override + String editAccountFailureToConnectInfo(String name) { + return 'Maily no pudo conectar $name.'; + } + + @override + String get editAccountFailureToConnectRetryAction => 'Reintentar'; + + @override + String get editAccountFailureToConnectChangePasswordAction => 'Cambiar contraseña'; + + @override + String get editAccountFailureToConnectFixedTitle => 'Conectado'; + + @override + String get editAccountFailureToConnectFixedInfo => 'La cuenta está conectada de nuevo.'; + + @override + String get editAccountIncludeInUnifiedLabel => 'Incluye en cuenta unificada'; + + @override + String editAccountAliasLabel(String email) { + return 'Direcciones de correo electrónico de $email:'; + } + + @override + String get editAccountNoAliasesInfo => 'Aún no tienes alias conocidos para esta cuenta.'; + + @override + String editAccountAliasRemoved(String email) { + return 'Alias $email eliminado'; + } + + @override + String get editAccountAddAliasAction => 'Añadir alias'; + + @override + String get editAccountPlusAliasesSupported => 'Soporta + alias'; + + @override + String get editAccountCheckPlusAliasAction => 'Prueba de soporte para + alias'; + + @override + String get editAccountBccMyself => 'BCC mismo'; + + @override + String get editAccountBccMyselfDescriptionTitle => 'BCC mismo'; + + @override + String get editAccountBccMyselfDescriptionText => 'Puedes enviar automáticamente mensajes a ti mismo para cada mensaje que envíes desde esta cuenta con la función \"BCC yo\". Normalmente esto no es necesario y deseado, ya que todos los mensajes salientes se almacenan en la carpeta \"Enviado\" de todos modos.'; + + @override + String get editAccountServerSettingsAction => 'Editar configuración del servidor'; + + @override + String get editAccountDeleteAccountAction => 'Eliminar cuenta'; + + @override + String get editAccountDeleteAccountConfirmationTitle => 'Confirmar'; + + @override + String editAccountDeleteAccountConfirmationQuery(String name) { + return '¿Quieres eliminar la cuenta $name?'; + } + + @override + String editAccountTestPlusAliasTitle(String name) { + return '+ Alias para $name'; + } + + @override + String get editAccountTestPlusAliasStepIntroductionTitle => 'Introducción'; + + @override + String editAccountTestPlusAliasStepIntroductionText(String accountName, String example) { + return 'Tu cuenta $accountName podría ser compatible con los alias + llamados como $example.\nUn alias A + te ayuda a proteger tu identidad y te ayuda contra el spam.\nPara probarlo, se enviará un mensaje de prueba a esta dirección generada. Si llega, su proveedor soporta + alias y puede generarlos fácilmente a petición al escribir un nuevo mensaje de correo.'; + } + + @override + String get editAccountTestPlusAliasStepTestingTitle => 'Pruebas'; + + @override + String get editAccountTestPlusAliasStepResultTitle => 'Resultado'; + + @override + String editAccountTestPlusAliasStepResultSuccess(String name) { + return 'Tu cuenta $name soporta + alias.'; + } + + @override + String editAccountTestPlusAliasStepResultNoSuccess(String name) { + return 'Tu cuenta $name no soporta + alias.'; + } + + @override + String get editAccountAddAliasTitle => 'Añadir alias'; + + @override + String get editAccountEditAliasTitle => 'Editar alias'; + + @override + String get editAccountAliasAddAction => 'Añadir'; + + @override + String get editAccountAliasUpdateAction => 'Actualizar'; + + @override + String get editAccountEditAliasNameLabel => 'Nombre del alias'; + + @override + String get editAccountEditAliasEmailLabel => 'Alias email'; + + @override + String get editAccountEditAliasEmailHint => 'Tu dirección de email de alias'; + + @override + String editAccountEditAliasDuplicateError(String email) { + return 'Ya hay un alias con $email.'; + } + + @override + String get editAccountEnableLogging => 'Activar registro'; + + @override + String get editAccountLoggingEnabled => 'Registro habilitado, por favor reinicie'; + + @override + String get editAccountLoggingDisabled => 'Registro desactivado, por favor reinicie'; + + @override + String get accountDetailsFallbackTitle => 'Ajustes del servidor'; + + @override + String get errorTitle => 'Error'; + + @override + String get accountProviderStepTitle => 'Proveedor de Servicio de Email'; + + @override + String get accountProviderCustom => 'Otro servicio de email'; + + @override + String accountDetailsErrorHostProblem(String incomingHost, String outgoingHost) { + return 'Maily no puede llegar al servidor de correo especificado. Por favor, compruebe la configuración del servidor de entrada \"$incomingHost\" y la configuración del servidor de salida \"$outgoingHost\".'; + } + + @override + String accountDetailsErrorLoginProblem(String userName, String password) { + return 'No se puede iniciar sesión. Por favor, comprueba tu nombre de usuario \"$userName\" y tu contraseña \"$password\".'; + } + + @override + String get accountDetailsUserNameLabel => 'Nombre de usuario'; + + @override + String get accountDetailsUserNameHint => 'Su nombre de usuario, si es diferente del correo electrónico'; + + @override + String get accountDetailsPasswordLabel => 'Contraseña de acceso'; + + @override + String get accountDetailsPasswordHint => 'Su contraseña'; + + @override + String get accountDetailsBaseSectionTitle => 'Ajustes de base'; + + @override + String get accountDetailsIncomingLabel => 'Servidor entrante'; + + @override + String get accountDetailsIncomingHint => 'Dominio como imap.domain.com'; + + @override + String get accountDetailsOutgoingLabel => 'Servidor saliente'; + + @override + String get accountDetailsOutgoingHint => 'Dominio como smtp.domain.com'; + + @override + String get accountDetailsAdvancedIncomingSectionTitle => 'Configuración avanzada de entrada'; + + @override + String get accountDetailsIncomingServerTypeLabel => 'Tipo de entrada:'; + + @override + String get accountDetailsOptionAutomatic => 'automático'; + + @override + String get accountDetailsIncomingSecurityLabel => 'Seguridad entrante:'; + + @override + String get accountDetailsSecurityOptionNone => 'Plain (sin cifrado)'; + + @override + String get accountDetailsIncomingPortLabel => 'Puerto entrante'; + + @override + String get accountDetailsPortHint => 'Dejar en blanco para determinar automáticamente'; + + @override + String get accountDetailsIncomingUserNameLabel => 'Nombre de usuario entrante'; + + @override + String get accountDetailsAlternativeUserNameHint => 'Tu nombre de usuario, si es diferente de arriba'; + + @override + String get accountDetailsIncomingPasswordLabel => 'Contraseña entrante'; + + @override + String get accountDetailsAlternativePasswordHint => 'Su contraseña, si es diferente de la anterior'; + + @override + String get accountDetailsAdvancedOutgoingSectionTitle => 'Ajustes avanzados de salida'; + + @override + String get accountDetailsOutgoingServerTypeLabel => 'Tipo saliente:'; + + @override + String get accountDetailsOutgoingSecurityLabel => 'Seguridad saliente:'; + + @override + String get accountDetailsOutgoingPortLabel => 'Puerto saliente'; + + @override + String get accountDetailsOutgoingUserNameLabel => 'Nombre de usuario saliente'; + + @override + String get accountDetailsOutgoingPasswordLabel => 'Contraseña saliente'; + + @override + String get composeTitleNew => 'Nuevo mensaje'; + + @override + String get composeTitleForward => 'Reenviar'; + + @override + String get composeTitleReply => 'Responder'; + + @override + String get composeEmptyMessage => 'mensaje vacío'; + + @override + String get composeWarningNoSubject => 'No ha especificado un asunto. ¿Desea enviar el mensaje sin un asunto?'; + + @override + String get composeActionSentWithoutSubject => 'Enviar'; + + @override + String get composeMailSendSuccess => 'Email enviado 😊'; + + @override + String composeSendErrorInfo(String details) { + return 'Lo sentimos, no se ha podido enviar tu correo. Hemos recibido el siguiente error:\n$details.'; + } + + @override + String get composeRequestReadReceiptAction => 'Solicitar recibo de lectura'; + + @override + String get composeSaveDraftAction => 'Guardar como borrador'; + + @override + String get composeMessageSavedAsDraft => 'Borrador guardado'; + + @override + String composeMessageSavedAsDraftErrorInfo(String details) { + return 'No se ha podido guardar tu borrador con el siguiente error:\n$details'; + } + + @override + String get composeConvertToPlainTextEditorAction => 'Convertir a texto plano'; + + @override + String get composeConvertToHtmlEditorAction => 'Convertir a mensaje enriquecido (HTML)'; + + @override + String get composeContinueEditingAction => 'Continuar editando'; + + @override + String get composeCreatePlusAliasAction => 'Crear nuevos + alias...'; + + @override + String get composeSenderHint => 'Remitente'; + + @override + String get composeRecipientHint => 'Email del destinatario'; + + @override + String get composeSubjectLabel => 'Sujeto'; + + @override + String get composeSubjectHint => 'Asunto del mensaje'; + + @override + String get composeAddAttachmentAction => 'Añadir'; + + @override + String composeRemoveAttachmentAction(String name) { + return 'Eliminar $name'; + } + + @override + String get composeLeftByMistake => '¿Dejado por error?'; + + @override + String get attachTypeFile => 'Fichero'; + + @override + String get attachTypePhoto => 'Foto'; + + @override + String get attachTypeVideo => 'Vídeo'; + + @override + String get attachTypeAudio => 'Audio'; + + @override + String get attachTypeLocation => 'Ubicación'; + + @override + String get attachTypeGif => 'Gif animado'; + + @override + String get attachTypeGifSearch => 'buscar GIPHY'; + + @override + String get attachTypeSticker => 'Pegatina'; + + @override + String get attachTypeStickerSearch => 'buscar GIPHY'; + + @override + String get attachTypeAppointment => 'Cita'; + + @override + String get languageSettingTitle => 'Idioma'; + + @override + String get languageSettingLabel => 'Elige el idioma para Maily:'; + + @override + String get languageSettingSystemOption => 'Idioma del sistema'; + + @override + String get languageSettingConfirmationTitle => '¿Usar Inglés para Maily?'; + + @override + String get languageSettingConfirmationQuery => 'Por favor confirme el uso del inglés como idioma elegido.'; + + @override + String get languageSetInfo => 'Ahora se muestra en inglés. Por favor, reinicia la aplicación para que surta efecto.'; + + @override + String get languageSystemSetInfo => 'Maily ahora utilizará el idioma del sistema o Inglés si el idioma del sistema no es compatible.'; + + @override + String get swipeSettingTitle => 'Deslizar gestos'; + + @override + String get swipeSettingLeftToRightLabel => 'Deslizar de izquierda a derecha'; + + @override + String get swipeSettingRightToLeftLabel => 'Deslizar derecha a izquierda'; + + @override + String get swipeSettingChangeAction => 'Cambiar'; + + @override + String get signatureSettingsTitle => 'Firma'; + + @override + String get signatureSettingsComposeActionsInfo => 'Activar la firma para los siguientes mensajes:'; + + @override + String get signatureSettingsAccountInfo => 'Puede especificar firmas específicas de la cuenta en la configuración de la cuenta.'; + + @override + String signatureSettingsAddForAccount(String account) { + return 'Añadir firma para $account'; + } + + @override + String get defaultSenderSettingsTitle => 'Remitente por defecto'; + + @override + String get defaultSenderSettingsLabel => 'Seleccione el remitente para nuevos mensajes.'; + + @override + String defaultSenderSettingsFirstAccount(String email) { + return 'Primera cuenta ($email)'; + } + + @override + String get defaultSenderSettingsAliasInfo => 'Puede configurar direcciones de alias de correo electrónico en la [AS].'; + + @override + String get defaultSenderSettingsAliasAccountSettings => 'configuración de cuenta'; + + @override + String get replySettingsTitle => 'Formato de mensaje'; + + @override + String get replySettingsIntro => '¿En qué formato desea responder o reenviar el correo electrónico por defecto?'; + + @override + String get replySettingsFormatHtml => 'Formato siempre rico (HTML)'; + + @override + String get replySettingsFormatSameAsOriginal => 'Usar el mismo formato que el correo original'; + + @override + String get replySettingsFormatPlainText => 'Siempre sólo texto'; + + @override + String get moveTitle => 'Mover mensaje'; + + @override + String moveSuccess(String mailbox) { + return 'Mensajes movidos a $mailbox.'; + } + + @override + String get editorArtInputLabel => 'Tu entrada'; + + @override + String get editorArtInputHint => 'Introduce el texto aquí'; + + @override + String get editorArtWaitingForInputHint => 'esperando por entrada...'; + + @override + String get fontSerifBold => 'Serif bold'; + + @override + String get fontSerifItalic => 'Serif italic'; + + @override + String get fontSerifBoldItalic => 'Serif bold italic'; + + @override + String get fontSans => 'Sans'; + + @override + String get fontSansBold => 'Sans bold'; + + @override + String get fontSansItalic => 'Sans italic'; + + @override + String get fontSansBoldItalic => 'Sans bold italic'; + + @override + String get fontScript => 'Escribir'; + + @override + String get fontScriptBold => 'Escribir negrita'; + + @override + String get fontFraktur => 'Fraktur'; + + @override + String get fontFrakturBold => 'Fraktur bold'; + + @override + String get fontMonospace => 'Monoespaciado'; + + @override + String get fontFullwidth => 'Ancho completo'; + + @override + String get fontDoublestruck => 'Doble golpeado'; + + @override + String get fontCapitalized => 'Capitalizado'; + + @override + String get fontCircled => 'Circlado'; + + @override + String get fontParenthesized => 'Parentesizado'; + + @override + String get fontUnderlinedSingle => 'Subrayado'; + + @override + String get fontUnderlinedDouble => 'Doble subrayado'; + + @override + String get fontStrikethroughSingle => 'Golpear a través'; + + @override + String get fontCrosshatch => 'Crosshatch'; + + @override + String accountLoadError(String name) { + return 'No se puede conectar a su cuenta $name. ¿Ha cambiado la contraseña?'; + } + + @override + String get accountLoadErrorEditAction => 'Editar cuenta'; + + @override + String get extensionsTitle => 'Extensiones'; + + @override + String get extensionsIntro => 'Con los proveedores de servicios de correo electrónico de extensiones, las empresas y los desarrolladores pueden adaptarse a las funcionalidades más útiles.'; + + @override + String get extensionsLearnMoreAction => 'Más información sobre extensiones'; + + @override + String get extensionsReloadAction => 'Recargar extensiones'; + + @override + String get extensionDeactivateAllAction => 'Desactivar todas las extensiones'; + + @override + String get extensionsManualAction => 'Cargar manualmente'; + + @override + String get extensionsManualUrlLabel => 'Url de la extensión'; + + @override + String extensionsManualLoadingError(String url) { + return 'No se ha podido descargar la extensión de \"$url\".'; + } + + @override + String get icalendarAcceptTentatively => 'Tentativamente'; + + @override + String get icalendarActionChangeParticipantStatus => 'Cambiar'; + + @override + String get icalendarLabelSummary => 'Título'; + + @override + String get icalendarNoSummaryInfo => '(sin título)'; + + @override + String get icalendarLabelDescription => 'Descripción'; + + @override + String get icalendarLabelStart => 'Comenzar'; + + @override + String get icalendarLabelEnd => 'Fin'; + + @override + String get icalendarLabelDuration => 'Duración'; + + @override + String get icalendarLabelLocation => 'Ubicación'; + + @override + String get icalendarLabelTeamsUrl => 'Enlace'; + + @override + String get icalendarLabelRecurrenceRule => 'Repetir'; + + @override + String get icalendarLabelParticipants => 'Participantes'; + + @override + String get icalendarParticipantStatusNeedsAction => 'Se le pide que responda a esta invitación.'; + + @override + String get icalendarParticipantStatusAccepted => 'Has aceptado esta invitación.'; + + @override + String get icalendarParticipantStatusDeclined => 'Has rechazado esta invitación.'; + + @override + String get icalendarParticipantStatusAcceptedTentatively => 'Has aceptado esta invitación de forma tentativa.'; + + @override + String get icalendarParticipantStatusDelegated => 'Usted ha delegado esta invitación.'; + + @override + String get icalendarParticipantStatusInProcess => 'La tarea está en curso.'; + + @override + String get icalendarParticipantStatusPartial => 'La tarea está parcialmente hecha.'; + + @override + String get icalendarParticipantStatusCompleted => 'La tarea está hecha.'; + + @override + String get icalendarParticipantStatusOther => 'Su estado es desconocido.'; + + @override + String get icalendarParticipantStatusChangeTitle => 'Tu estado'; + + @override + String get icalendarParticipantStatusChangeText => '¿Quieres aceptar esta invitación?'; + + @override + String icalendarParticipantStatusSentFailure(String details) { + return 'No se puede enviar la respuesta.\nEl servidor respondió con los siguientes detalles:\n$details'; + } + + @override + String get icalendarExportAction => 'Exportar'; + + @override + String icalendarReplyStatusNeedsAction(String attendee) { + return '$attendee no ha respondido a esta invitación.'; + } + + @override + String icalendarReplyStatusAccepted(String attendee) { + return '$attendee ha aceptado la cita.'; + } + + @override + String icalendarReplyStatusDeclined(String attendee) { + return '$attendee ha rechazado esta invitación.'; + } + + @override + String icalendarReplyStatusAcceptedTentatively(String attendee) { + return '$attendee ha aceptado tentativamente esta invitación.'; + } + + @override + String icalendarReplyStatusDelegated(String attendee) { + return '$attendee ha delegado esta invitación.'; + } + + @override + String icalendarReplyStatusInProcess(String attendee) { + return '$attendee ha iniciado esta tarea.'; + } + + @override + String icalendarReplyStatusPartial(String attendee) { + return '$attendee ha realizado parcialmente esta tarea.'; + } + + @override + String icalendarReplyStatusCompleted(String attendee) { + return '$attendee ha finalizado esta tarea.'; + } + + @override + String icalendarReplyStatusOther(String attendee) { + return '$attendee ha respondido con un estado desconocido.'; + } + + @override + String get icalendarReplyWithoutParticipants => 'Esta respuesta de calendario no contiene participantes.'; + + @override + String icalendarReplyWithoutStatus(String attendee) { + return '$attendee respondió sin un estado de participación.'; + } + + @override + String get composeAppointmentTitle => 'Crear cita'; + + @override + String get composeAppointmentLabelDay => 'día'; + + @override + String get composeAppointmentLabelTime => 'tiempo'; + + @override + String get composeAppointmentLabelAllDayEvent => 'Todo el día'; + + @override + String get composeAppointmentLabelRepeat => 'Repetir'; + + @override + String get composeAppointmentLabelRepeatOptionNever => 'Nunca'; + + @override + String get composeAppointmentLabelRepeatOptionDaily => 'Diario'; + + @override + String get composeAppointmentLabelRepeatOptionWeekly => 'Semanal'; + + @override + String get composeAppointmentLabelRepeatOptionMonthly => 'Mensual'; + + @override + String get composeAppointmentLabelRepeatOptionYearly => 'Anualmente'; + + @override + String get composeAppointmentRecurrenceFrequencyLabel => 'Frecuencia'; + + @override + String get composeAppointmentRecurrenceIntervalLabel => 'Intervalo'; + + @override + String get composeAppointmentRecurrenceDaysLabel => 'En días'; + + @override + String get composeAppointmentRecurrenceUntilLabel => 'Hasta'; + + @override + String get composeAppointmentRecurrenceUntilOptionUnlimited => 'Sin límite'; + + @override + String composeAppointmentRecurrenceUntilOptionRecommended(String duration) { + return 'Recomendado ($duration)'; + } + + @override + String get composeAppointmentRecurrenceUntilOptionSpecificDate => 'Hasta la fecha elegida'; + + @override + String composeAppointmentRecurrenceMonthlyOnDayOfMonth(int day) { + final intl.NumberFormat dayNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String dayString = dayNumberFormat.format(day); + + return 'El $dayString. día del mes'; + } + + @override + String get composeAppointmentRecurrenceMonthlyOnWeekDay => 'Día de la semana en mes'; + + @override + String get composeAppointmentRecurrenceFirst => 'Primero'; + + @override + String get composeAppointmentRecurrenceSecond => 'Segundo'; + + @override + String get composeAppointmentRecurrenceThird => 'Tercer'; + + @override + String get composeAppointmentRecurrenceLast => 'Último'; + + @override + String get composeAppointmentRecurrenceSecondLast => 'Segundo-último'; + + @override + String durationYears(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '$numberString años', + one: '1 año', + ); + return '$_temp0'; + } + + @override + String durationMonths(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '$numberString meses', + one: '1 mes', + ); + return '$_temp0'; + } + + @override + String durationWeeks(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '$numberString semanas', + one: '1 semana', + ); + return '$_temp0'; + } + + @override + String durationDays(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '$numberString días', + one: '1 día', + ); + return '$_temp0'; + } + + @override + String durationHours(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '$numberString horas', + one: '1 hora', + ); + return '$_temp0'; + } + + @override + String durationMinutes(int number) { + final intl.NumberFormat numberNumberFormat = intl.NumberFormat.compactLong( + locale: localeName, + + ); + final String numberString = numberNumberFormat.format(number); + + String _temp0 = intl.Intl.pluralLogic( + number, + locale: localeName, + other: '$numberString minutos', + one: '1 minuto', + ); + return '$_temp0'; + } + + @override + String get durationEmpty => 'Sin duración'; +} diff --git a/lib/localization/extension.dart b/lib/localization/extension.dart new file mode 100644 index 0000000..641f539 --- /dev/null +++ b/lib/localization/extension.dart @@ -0,0 +1,304 @@ +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 '../util/date_helper.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/services/location_service.dart b/lib/location/service.dart similarity index 51% rename from lib/services/location_service.dart rename to lib/location/service.dart index 170f78f..043ee23 100644 --- a/lib/services/location_service.dart +++ b/lib/location/service.dart @@ -1,24 +1,35 @@ import 'package:location/location.dart'; +/// Allows to query the current location class LocationService { + LocationService._(); + + static final _instance = LocationService._(); + + /// Returns the singleton instance + static LocationService get instance => _instance; + 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/screens/location_screen.dart b/lib/location/view.dart similarity index 71% rename from lib/screens/location_screen.dart rename to lib/location/view.dart index 2145dbc..1148233 100644 --- a/lib/screens/location_screen.dart +++ b/lib/location/view.dart @@ -1,22 +1,22 @@ 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'; 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 '../locator.dart'; +import '../localization/extension.dart'; +import '../logger.dart'; +import '../routes/routes.dart'; +import '../screens/base.dart'; +import 'service.dart'; class LocationScreen extends StatefulWidget { - const LocationScreen({Key? key}) : super(key: key); + const LocationScreen({super.key}); @override State createState() => _LocationScreenState(); @@ -27,13 +27,26 @@ class _LocationScreenState extends State { final _defaultLocation = const LatLng(53.07516, 8.80777); late MapController _controller; - Future? _findLocation; + Future? _findLocationFuture; late Offset _dragStart; - double _scaleStart = 1.0; + var _scaleStart = 1.0; @override void initState() { - _findLocation = locator().getCurrentLocation(); + _controller = MapController( + location: _defaultLocation, + ); + _findLocationFuture = LocationService.instance.getCurrentLocation().then( + (value) { + final latitude = value?.latitude; + final longitude = value?.longitude; + if (latitude != null && longitude != null) { + _controller.center = LatLng(latitude, longitude); + } + + return value; + }, + ); super.initState(); } @@ -41,8 +54,7 @@ class _LocationScreenState extends State { Widget build(BuildContext context) { final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.attachTypeLocation, appBarActions: [ PlatformIconButton( @@ -51,7 +63,7 @@ class _LocationScreenState extends State { ), ], content: FutureBuilder( - future: _findLocation, + future: _findLocationFuture, builder: (context, snapshot) { switch (snapshot.connectionState) { case ConnectionState.none: @@ -59,32 +71,46 @@ 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()); }, ), ); } - void _onLocationSelected() async { + 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.0); + final image = await boundary.toImage(pixelRatio: 3); final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - var pngBytes = byteData?.buffer.asUint8List(); - locator().pop(pngBytes); + final pngBytes = byteData?.buffer.asUint8List(); + 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( @@ -92,10 +118,10 @@ class _LocationScreenState extends State { onScaleStart: _onScaleStart, onScaleUpdate: (details) => _onScaleUpdate(details, transformer), onScaleEnd: (details) { - if (kDebugMode) { - print( - "Location: ${_controller.center.latitude}, ${_controller.center.longitude}"); - } + logger.d( + 'Location: ${_controller.center.latitude}, ' + '${_controller.center.longitude}', + ); }, child: SizedBox( width: size.width, @@ -122,7 +148,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, @@ -157,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/locator.dart b/lib/locator.dart deleted file mode 100644 index b0bb048..0000000 --- a/lib/locator.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:get_it/get_it.dart'; - -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'; -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'; - -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.new) - ..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/lock/provider.dart b/lib/lock/provider.dart new file mode 100644 index 0000000..0dea91a --- /dev/null +++ b/lib/lock/provider.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../app_lifecycle/provider.dart'; +import '../localization/extension.dart'; +import '../logger.dart'; +import '../routes/routes.dart'; +import '../screens/screens.dart'; +import '../settings/model.dart'; +import '../settings/provider.dart'; +import 'service.dart'; + +part 'provider.g.dart'; + +/// Checks the app life cycle and displays the lock screen if needed +@Riverpod(keepAlive: true) +class AppLock extends _$AppLock { + var _lockTime = DateTime.now(); + static var _ignoreNextSettingsChange = false; + + /// Allows to ignore the next settings change + // ignore: avoid_setters_without_getters + static set ignoreNextSettingsChange(bool value) => + _ignoreNextSettingsChange = value; + + @override + void build() { + final enableBiometricLock = ref.watch( + settingsProvider.select((value) => value.enableBiometricLock), + ); + final lockTimePreference = ref.watch( + settingsProvider.select((value) => value.lockTimePreference), + ); + final isResumed = ref.watch(appIsResumedProvider); + if (!enableBiometricLock) { + return; + } + if (_ignoreNextSettingsChange) { + _ignoreNextSettingsChange = false; + logger.d('ignoring settings change'); + + return; + } + final context = Routes.navigatorKey.currentContext; + if (context == null) { + return; + } + if (!isResumed) { + _lockTime = DateTime.now(); + logger.d( + 'setting lock time: $_lockTime', + ); + if (lockTimePreference == LockTimePreference.immediately && + !LockScreen.isShown) { + logger.d('pushing lock screen (immediately + !isResumed)'); + unawaited(context.pushNamed(Routes.lockScreen)); + } + } else { + final difference = DateTime.now().difference(_lockTime); + switch (lockTimePreference) { + case LockTimePreference.immediately: + if (!LockScreen.isShown) { + logger.d('pushing lock screen (immediately + isResumed)'); + unawaited(context.pushNamed(Routes.lockScreen)); + } + _unlock(context); + break; + case LockTimePreference.after5minutes: + if (difference.inMinutes >= 5) { + if (!LockScreen.isShown) { + logger.d('pushing lock screen 5min'); + unawaited(context.pushNamed(Routes.lockScreen)); + } + _unlock(context); + } + break; + case LockTimePreference.after30minutes: + if (difference.inMinutes >= 30) { + if (!LockScreen.isShown) { + logger.d('pushing lock screen 30min'); + unawaited(context.pushNamed(Routes.lockScreen)); + } + _unlock(context); + } + break; + } + } + } + + Future _unlock(BuildContext context) async { + final localizations = context.text; + var isUnlocked = false; + while (!isUnlocked) { + ref.read(appLifecycleProvider.notifier).ignoreNextInactivationCycle(); + isUnlocked = await BiometricsService.instance.authenticate(localizations); + } + if (isUnlocked && LockScreen.isShown) { + if (context.mounted) { + context.pop(); + } + } + } +} diff --git a/lib/lock/provider.g.dart b/lib/lock/provider.g.dart new file mode 100644 index 0000000..a7f0cee --- /dev/null +++ b/lib/lock/provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$appLockHash() => r'c79bf94cce825a31b67a46a4060d0913dc5ab9ff'; + +/// Checks the app life cycle and displays the lock screen if needed +/// +/// Copied from [AppLock]. +@ProviderFor(AppLock) +final appLockProvider = NotifierProvider.internal( + AppLock.new, + name: r'appLockProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$appLockHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AppLock = 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/services/biometrics_service.dart b/lib/lock/service.dart similarity index 64% rename from lib/services/biometrics_service.dart rename to lib/lock/service.dart index a5752db..d95bc65 100644 --- a/lib/services/biometrics_service.dart +++ b/lib/lock/service.dart @@ -1,15 +1,26 @@ -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 '../localization/app_localizations.g.dart'; + +/// Handles biometrics class BiometricsService { + /// Creates a new [BiometricsService] + BiometricsService._(); + + static final _instance = BiometricsService._(); + + /// The instance of the [BiometricsService] + static BiometricsService get instance => _instance; + bool _isResolved = false; bool _isSupported = false; final _localAuth = LocalAuthentication(); + /// Checks if the device supports biometrics Future isDeviceSupported() async { if (_isResolved) { return _isSupported; @@ -25,41 +36,47 @@ class BiometricsService { } } _isResolved = true; + return _isSupported; } - Future authenticate({String? reason}) async { + /// Authenticates the user with biometrics + Future authenticate( + AppLocalizations localizations, { + String? reason, + }) async { if (!_isResolved) { await isDeviceSupported(); } if (!_isSupported) { return false; } - reason ??= await _getLocalizedUnlockReason(); - locator().ignoreBiometricsCheckAtNextResume = true; + // AppService.instance.ignoreBiometricsCheckAtNextResume = true; try { final result = await _localAuth.authenticate( - localizedReason: reason, + localizedReason: + reason ?? await _getLocalizedUnlockReason(localizations), 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( + // (_) => AppService.instance.ignoreBiometricsCheckAtNextResume = false, + // )); + return result; } catch (e, s) { if (kDebugMode) { print('Authentication failed with $e $s'); } } + 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)) { @@ -68,6 +85,7 @@ class BiometricsService { return localizations.securityUnlockWithTouchId; } } + return localizations.securityUnlockReason; } } diff --git a/lib/lock/view.dart b/lib/lock/view.dart new file mode 100644 index 0000000..5309fa0 --- /dev/null +++ b/lib/lock/view.dart @@ -0,0 +1,77 @@ +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 '../screens/base.dart'; +import 'service.dart'; + +/// Displays a lock screen +class LockScreen extends StatefulWidget { + /// Creates a new [LockScreen] + const LockScreen({super.key}); + + static var _isShown = false; + + /// Is the lock screen currently shown? + static bool get isShown => _isShown; + + @override + State createState() => _LockScreenState(); +} + +class _LockScreenState extends State { + @override + void initState() { + super.initState(); + LockScreen._isShown = true; + } + + @override + void dispose() { + LockScreen._isShown = false; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final localizations = context.text; + + return BasePage( + includeDrawer: false, + title: localizations.lockScreenTitle, + content: _buildContent(context, localizations), + ); + } + + 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: Text(localizations.lockScreenUnlockAction), + onPressed: () => _authenticate(context), + ), + ], + ), + ), + ); + + Future _authenticate(BuildContext context) async { + final didAuthenticate = + await BiometricsService.instance.authenticate(context.text); + if (didAuthenticate && context.mounted) { + context.pop(); + } + } +} 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 new file mode 100644 index 0000000..3f9140d --- /dev/null +++ b/lib/mail/provider.dart @@ -0,0 +1,396 @@ +import 'package:collection/collection.dart'; +import 'package:enough_mail/enough_mail.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +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 '../logger.dart'; +import '../models/async_mime_source.dart'; +import '../models/message.dart'; +import '../models/message_source.dart'; +import '../notification/model.dart'; +import '../notification/service.dart'; +import '../settings/provider.dart'; +import 'service.dart'; + +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, + }) { + Future.delayed(const Duration(milliseconds: 10)).then( + (_) => ref.read(currentMailboxProvider.notifier).state = mailbox, + ); + final usedMailbox = mailbox?.isInbox ?? true ? null : mailbox; + if (account is RealAccount) { + return ref.watch( + realSourceProvider(account: account, mailbox: usedMailbox).future, + ); + } + if (account is UnifiedAccount) { + return ref.watch( + unifiedSourceProvider(account: account, mailbox: usedMailbox).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 { + logger.d( + 'Creating unified source for ${account.key}: ' + '${mailbox?.name ?? ''}', + ); + + Future resolve( + RealAccount realAccount, + Mailbox? mailbox, + ) async { + try { + 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, + ); + } + if (usedMailbox != null && usedMailbox.isInbox) { + usedMailbox = null; + } + + final source = await ref.watch( + realMimeSourceProvider( + account: realAccount, + mailbox: usedMailbox, + ).future, + ); + + return source; + } catch (e) { + logger.e('Error loading source for ${realAccount.name}', error: e); + + return null; + } + } + + final accounts = account.accounts; + final futureSources = accounts.map( + (a) => resolve(a, mailbox), + ); + final mimeSourcesWithNullValues = await Future.wait(futureSources); + final mimeSources = mimeSourcesWithNullValues.whereNotNull().toList(); + if (mimeSources.isEmpty) { + throw Exception('No mime sources could be connected'); + } + + 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 { + @override + Future build({ + required RealAccount account, + Mailbox? mailbox, + }) async { + logger.d( + 'Creating message source for ${account.key}: ' + '${mailbox?.name ?? ''}', + ); + + final source = await ref.watch( + realMimeSourceProvider(account: account, mailbox: mailbox).future, + ); + + return MailboxMessageSource.fromMimeSource( + source, + account.email, + mailbox ?? source.mailbox, + account: account, + ); + } +} + +//// Loads the mailbox tree for the given account +@Riverpod(keepAlive: true) +Future> mailboxTree( + MailboxTreeRef ref, { + required Account account, +}) async { + logger.d('Creating mailbox tree for ${account.key}'); + 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 +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; +} + +/// Provides the message source for the given account +@Riverpod(keepAlive: true) +class RealMimeSource extends _$RealMimeSource implements MimeSourceSubscriber { + @override + Future build({ + required RealAccount account, + Mailbox? mailbox, + }) async { + final usedMailboxForMailClient = + (mailbox?.isInbox ?? true) ? null : mailbox; + logger.d( + 'Creating real mime source for ${account.key}: ' + '${mailbox?.name ?? ''}', + ); + + final mailClient = ref.watch( + mailClientSourceProvider( + account: account, + mailbox: usedMailboxForMailClient, + ), + ); + try { + final mimeSource = await EmailService.instance.createMimeSource( + mailClient: mailClient, + mailbox: mailbox, + ); + if (mailbox == null || mailbox.isInbox) { + mimeSource.addSubscriber(this); + } + + return mimeSource; + } catch (e, s) { + logger.e( + 'Error creating mime source for ${account.key}', + error: e, + stackTrace: s, + ); + account.hasError = true; + + rethrow; + } + } + + @override + void onMailArrived( + MimeMessage mime, + AsyncMimeSource source, { + int index = 0, + }) { + if (source == state.value) { + 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.cancelNotificationForMime(mime); + } + } + + @override + void onMailVanished(MimeMessage mime, AsyncMimeSource source) { + NotificationService.instance.cancelNotificationForMime(mime); + } +} + +/// Provides mail clients +/// +/// Expects [Mailbox] to be `null` for the inbox. +@Riverpod(keepAlive: true) +class MailClientSource extends _$MailClientSource { + MailClient? _existingClient; + + @override + MailClient build({ + required RealAccount account, + Mailbox? mailbox, + }) { + MailClient create() { + final logName = + mailbox != null ? '${account.name}-${mailbox.name}' : account.name; + logger.d('Creating MailClient $logName'); + + return EmailService.instance.createMailClient( + account.mailAccount, + logName, + (mailAccount) => ref + .watch(realAccountsProvider.notifier) + .updateMailAccount(account, mailAccount), + ); + } + + final isResumed = ref.watch(appIsResumedProvider); + + final client = _existingClient ?? create(); + 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( + 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 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(localizations, 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), + ); + if (account == null) { + throw Exception('Account not found for ${payload.accountEmail}'); + } + final source = await ref.watch(sourceProvider(account: account).future); + + return source.loadSingleMessage(payload); +} + +/// Provides mail clients +@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 +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 +final currentMailboxProvider = StateProvider((ref) => null); diff --git a/lib/mail/provider.g.dart b/lib/mail/provider.g.dart new file mode 100644 index 0000000..ac0d6cc --- /dev/null +++ b/lib/mail/provider.g.dart @@ -0,0 +1,1850 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$mailboxTreeHash() => r'b1ccb0f9abb23fa230f80618370e187d21b10fac'; + +/// 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 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'fb113e28a8bb6904dbdd07a73898bc198afb2dda'; + +//// 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 AutoDisposeFutureProvider { + //// 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 + AutoDisposeFutureProviderElement 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 AutoDisposeFutureProviderRef { + /// The parameter `account` of this provider. + Account get account; + + /// The parameter `encodedMailboxPath` of this provider. + String get encodedMailboxPath; +} + +class _FindMailboxProviderElement + extends AutoDisposeFutureProviderElement with FindMailboxRef { + _FindMailboxProviderElement(super.provider); + + @override + Account get account => (origin as FindMailboxProvider).account; + @override + String get encodedMailboxPath => + (origin as FindMailboxProvider).encodedMailboxPath; +} + +String _$mailSearchHash() => r'12e814bd6c0f53f6209dd0f68edf09a0ec769c8b'; + +/// 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 AppLocalizations localizations, + required MailSearch search, + }) { + return MailSearchProvider( + localizations: localizations, + search: search, + ); + } + + @override + MailSearchProvider getProviderOverride( + covariant MailSearchProvider provider, + ) { + return call( + localizations: provider.localizations, + 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 AppLocalizations localizations, + required MailSearch search, + }) : this._internal( + (ref) => mailSearch( + ref as MailSearchRef, + localizations: localizations, + search: search, + ), + from: mailSearchProvider, + name: r'mailSearchProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$mailSearchHash, + dependencies: MailSearchFamily._dependencies, + allTransitiveDependencies: + MailSearchFamily._allTransitiveDependencies, + localizations: localizations, + search: search, + ); + + MailSearchProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.localizations, + required this.search, + }) : super.internal(); + + final AppLocalizations localizations; + 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, + localizations: localizations, + search: search, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _MailSearchProviderElement(this); + } + + @override + bool operator ==(Object other) { + 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); + } +} + +mixin MailSearchRef on AutoDisposeFutureProviderRef { + /// The parameter `localizations` of this provider. + AppLocalizations get localizations; + + /// The parameter `search` of this provider. + MailSearch get search; +} + +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; +} + +String _$singleMessageLoaderHash() => + r'ec18c48ee5c6ad77cb303cfea02e959979b4c9ce'; + +/// 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 _$firstTimeMailClientSourceHash() => + r'ae11f3a5ed5cb6329488bd3f9ac3569ac8ad1f36'; + +/// Provides mail clients +/// +/// Copied from [firstTimeMailClientSource]. +@ProviderFor(firstTimeMailClientSource) +const firstTimeMailClientSourceProvider = FirstTimeMailClientSourceFamily(); + +/// Provides mail clients +/// +/// Copied from [firstTimeMailClientSource]. +class FirstTimeMailClientSourceFamily + extends Family> { + /// Provides mail clients + /// + /// Copied from [firstTimeMailClientSource]. + const FirstTimeMailClientSourceFamily(); + + /// Provides mail clients + /// + /// Copied from [firstTimeMailClientSource]. + FirstTimeMailClientSourceProvider call({ + required RealAccount account, + Mailbox? mailbox, + }) { + return FirstTimeMailClientSourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + FirstTimeMailClientSourceProvider getProviderOverride( + covariant FirstTimeMailClientSourceProvider 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'firstTimeMailClientSourceProvider'; +} + +/// Provides mail clients +/// +/// Copied from [firstTimeMailClientSource]. +class FirstTimeMailClientSourceProvider + extends AutoDisposeFutureProvider { + /// Provides mail clients + /// + /// Copied from [firstTimeMailClientSource]. + FirstTimeMailClientSourceProvider({ + required RealAccount account, + Mailbox? mailbox, + }) : this._internal( + (ref) => firstTimeMailClientSource( + ref as FirstTimeMailClientSourceRef, + account: account, + mailbox: mailbox, + ), + from: firstTimeMailClientSourceProvider, + name: r'firstTimeMailClientSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$firstTimeMailClientSourceHash, + dependencies: FirstTimeMailClientSourceFamily._dependencies, + allTransitiveDependencies: + FirstTimeMailClientSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + FirstTimeMailClientSourceProvider._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(FirstTimeMailClientSourceRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: FirstTimeMailClientSourceProvider._internal( + (ref) => create(ref as FirstTimeMailClientSourceRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _FirstTimeMailClientSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + 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, account.hashCode); + hash = _SystemHash.combine(hash, mailbox.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin FirstTimeMailClientSourceRef + on AutoDisposeFutureProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _FirstTimeMailClientSourceProviderElement + extends AutoDisposeFutureProviderElement + with FirstTimeMailClientSourceRef { + _FirstTimeMailClientSourceProviderElement(super.provider); + + @override + RealAccount get account => + (origin as FirstTimeMailClientSourceProvider).account; + @override + Mailbox? get mailbox => (origin as FirstTimeMailClientSourceProvider).mailbox; +} + +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 _$sourceHash() => r'd4e787d804ab333fbd5079af8a66fc5222bdef45'; + +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'd065ee7acddd895e44ce502094eeb0bec70ab818'; + +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'd9f16bf2faa477e3d2e25f1dc9e1f89a3f3e7094'; + +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'2bebfe53595c7cc57a87b55302d680d46113a79c'; + +abstract class _$RealMimeSource + 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 [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 AsyncNotifierProviderImpl { + /// Provides the message source for the given account + /// + /// Copied from [RealMimeSource]. + RealMimeSourceProvider({ + required RealAccount account, + Mailbox? mailbox, + }) : this._internal( + () => RealMimeSource() + ..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 + Future runNotifierBuild( + covariant RealMimeSource notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(RealMimeSource Function() create) { + return ProviderOverride( + origin: this, + override: RealMimeSourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + AsyncNotifierProviderElement + 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 AsyncNotifierProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _RealMimeSourceProviderElement + extends AsyncNotifierProviderElement + with RealMimeSourceRef { + _RealMimeSourceProviderElement(super.provider); + + @override + RealAccount get account => (origin as RealMimeSourceProvider).account; + @override + Mailbox? get mailbox => (origin as RealMimeSourceProvider).mailbox; +} + +String _$mailClientSourceHash() => r'b38b5b583765b7078c959777bb9d2f346914fbb5'; + +abstract class _$MailClientSource extends BuildlessNotifier { + late final RealAccount account; + late final Mailbox? mailbox; + + MailClient build({ + required RealAccount account, + Mailbox? mailbox, + }); +} + +/// Provides mail clients +/// +/// Expects [Mailbox] to be `null` for the inbox. +/// +/// Copied from [MailClientSource]. +@ProviderFor(MailClientSource) +const mailClientSourceProvider = MailClientSourceFamily(); + +/// Provides mail clients +/// +/// Expects [Mailbox] to be `null` for the inbox. +/// +/// Copied from [MailClientSource]. +class MailClientSourceFamily extends Family { + /// Provides mail clients + /// + /// Expects [Mailbox] to be `null` for the inbox. + /// + /// Copied from [MailClientSource]. + const MailClientSourceFamily(); + + /// Provides mail clients + /// + /// Expects [Mailbox] to be `null` for the inbox. + /// + /// Copied from [MailClientSource]. + MailClientSourceProvider call({ + required RealAccount account, + Mailbox? mailbox, + }) { + return MailClientSourceProvider( + account: account, + mailbox: mailbox, + ); + } + + @override + MailClientSourceProvider getProviderOverride( + covariant MailClientSourceProvider 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'mailClientSourceProvider'; +} + +/// Provides mail clients +/// +/// Expects [Mailbox] to be `null` for the inbox. +/// +/// Copied from [MailClientSource]. +class MailClientSourceProvider + extends NotifierProviderImpl { + /// Provides mail clients + /// + /// Expects [Mailbox] to be `null` for the inbox. + /// + /// Copied from [MailClientSource]. + MailClientSourceProvider({ + required RealAccount account, + Mailbox? mailbox, + }) : this._internal( + () => MailClientSource() + ..account = account + ..mailbox = mailbox, + from: mailClientSourceProvider, + name: r'mailClientSourceProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$mailClientSourceHash, + dependencies: MailClientSourceFamily._dependencies, + allTransitiveDependencies: + MailClientSourceFamily._allTransitiveDependencies, + account: account, + mailbox: mailbox, + ); + + MailClientSourceProvider._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 + MailClient runNotifierBuild( + covariant MailClientSource notifier, + ) { + return notifier.build( + account: account, + mailbox: mailbox, + ); + } + + @override + Override overrideWith(MailClientSource Function() create) { + return ProviderOverride( + origin: this, + override: MailClientSourceProvider._internal( + () => create() + ..account = account + ..mailbox = mailbox, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + mailbox: mailbox, + ), + ); + } + + @override + NotifierProviderElement createElement() { + return _MailClientSourceProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MailClientSourceProvider && + 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 MailClientSourceRef on NotifierProviderRef { + /// The parameter `account` of this provider. + RealAccount get account; + + /// The parameter `mailbox` of this provider. + Mailbox? get mailbox; +} + +class _MailClientSourceProviderElement + extends NotifierProviderElement + with MailClientSourceRef { + _MailClientSourceProviderElement(super.provider); + + @override + RealAccount get account => (origin as MailClientSourceProvider).account; + @override + 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/mail/service.dart b/lib/mail/service.dart new file mode 100644 index 0000000..face151 --- /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 '../hoster/service.dart'; +import '../models/async_mime_source.dart'; +import '../models/async_mime_source_factory.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, + ); + + return source; + } + + /// Creates a mail client for the given account + MailClient createMailClient( + MailAccount mailAccount, + String logName, + OnMailClientConfigChanged? onMailClientConfigChanged, + ) { + final bool isLogEnabled = kDebugMode || + (mailAccount.attributes[RealAccount.attributeEnableLogging] ?? false); + + return MailClient( + mailAccount, + isLogEnabled: isLogEnabled, + logName: logName, + 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 mail hoster service with a riverpod provider + final provider = MailHosterService.instance[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, + mailAccount.name, + onMailClientConfigChanged, + ); + try { + await mailClient.connect(timeout: const Duration(seconds: 30)); + } 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, + mailAccount.name, + onMailClientConfigChanged, + ); + try { + await mailClient.connect(timeout: const Duration(seconds: 30)); + } on MailException { + await mailClient.disconnect(); + + return null; + } + } + } + + return ConnectedAccount(usedMailAccount, mailClient); + } +} diff --git a/lib/main.dart b/lib/main.dart index bbb491a..2b29b13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,224 +1,125 @@ 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/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../l10n/app_localizations.g.dart'; +import 'account/provider.dart'; import 'app_lifecycle/provider.dart'; -import 'l10n/extension.dart'; -import 'locator.dart'; +import 'background/provider.dart'; +import 'keys/service.dart'; +import 'localization/app_localizations.g.dart'; +import 'lock/provider.dart'; import 'logger.dart'; -import 'routes.dart'; -import 'screens/all_screens.dart'; -import 'services/app_service.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 'notification/service.dart'; +import 'routes/provider.dart'; +import 'routes/routes.dart'; +import 'scaffold_messenger/service.dart'; +import 'screens/screens.dart'; import 'settings/provider.dart'; import 'settings/theme/provider.dart'; -import 'widgets/inherited_widgets.dart'; +import 'share/provider.dart'; // AppStyles appStyles = AppStyles.instance; void main() { - setupLocator(); runApp( const ProviderScope( - child: MyApp(), + child: MailyApp(), ), ); } -class MyApp extends ConsumerStatefulWidget { - const MyApp({super.key}); +/// Runs the app +class MailyApp extends HookConsumerWidget { + /// Creates a new app + const MailyApp({super.key}); @override - ConsumerState createState() => _MyAppState(); -} - -class _MyAppState extends ConsumerState with WidgetsBindingObserver { - late Future _appInitialization; - Locale? _locale; - bool _isInitialized = false; + Widget build(BuildContext context, WidgetRef ref) { + useOnAppLifecycleStateChange((previous, current) { + logger.d('raw AppLifecycleState changed from $previous to $current'); + ref.read(rawAppLifecycleStateProvider.notifier).state = current; + }); + + final themeSettingsData = ref.watch(themeFinderProvider(context: context)); + final languageTag = + ref.watch(settingsProvider.select((settings) => settings.languageTag)); + final routerConfig = ref.watch(routerConfigProvider); + + ref + ..watch(incomingShareProvider) + ..watch(backgroundProvider) + ..watch(appLockProvider); + + final app = Theme( + data: themeSettingsData.brightness == Brightness.dark + ? themeSettingsData.darkTheme + : themeSettingsData.lightTheme, + child: PlatformSnackApp.router( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + debugShowCheckedModeBanner: false, + title: 'Maily', + routerConfig: routerConfig, + scaffoldMessengerKey: + ScaffoldMessengerService.instance.scaffoldMessengerKey, + materialTheme: themeSettingsData.lightTheme, + materialDarkTheme: themeSettingsData.darkTheme, + materialThemeMode: themeSettingsData.themeMode, + cupertinoTheme: themeSettingsData.cupertinoTheme, + ), + ); + if (languageTag == null) { + return app; + } - @override - void initState() { - WidgetsBinding.instance.addObserver(this); - _appInitialization = _initApp(); - super.initState(); + return Localizations.override( + context: context, + locale: Locale(languageTag), + child: app, + ); } +} + +/// Initializes the app +class InitializationScreen extends ConsumerStatefulWidget { + /// Creates a new [InitializationScreen] + const InitializationScreen({super.key}); @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } + ConsumerState createState() => + _InitializationScreenState(); +} +class _InitializationScreenState extends ConsumerState { @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (_isInitialized) { - final settings = ref.read(settingsProvider); - locator().didChangeAppLifecycleState(state, settings); - ref.read(appLifecycleStateProvider.notifier).state = state; - } + void initState() { + _initApp(); + super.initState(); } - Future _initApp() async { + Future _initApp() async { await ref.read(settingsProvider.notifier).init(); + await ref.read(realAccountsProvider.notifier).init(); + await ref.read(backgroundProvider.notifier).init(); + if (context.mounted) { - ref.read(themeProvider.notifier).init(context); - } - 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; - }); - } + // TODO(RV): check if the context is really needed for NotificationService + await NotificationService.instance.init(context: context); } - 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(); - } + await KeyService.instance.init(); + logger.d('App initialized'); + if (context.mounted) { + if (ref.read(allAccountsProvider).isEmpty) { + context.goNamed(Routes.welcome); + } else { + context.goNamed(Routes.mail); } - } 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(); } - 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) => const SplashScreen(); } diff --git a/lib/models/async_mime_source.dart b/lib/models/async_mime_source.dart index f765f89..ee72ffb 100644 --- a/lib/models/async_mime_source.dart +++ b/lib/models/async_mime_source.dart @@ -4,21 +4,37 @@ import 'package:collection/collection.dart'; import 'package:enough_mail/enough_mail.dart'; import 'package:flutter/foundation.dart'; +import '../logger.dart'; 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); } /// Defines a low level mime message source abstract class AsyncMimeSource { + /// Creates a new mime source + AsyncMimeSource() { + logger.d('Creating $this / $runtimeType'); + } + /// 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 +116,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( @@ -120,12 +139,23 @@ abstract class AsyncMimeSource { Duration? responseTimeout, }); - /// Informs this source about a new incoming [message] at the optional [index]. + /// 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. 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. @@ -161,6 +191,7 @@ abstract class AsyncMimeSource { /// Notifies subscribers about a new mime message void notifySubscriberOnMessageArrived(MimeMessage mime) { for (final subscriber in _subscribers) { + logger.d('$this: notify subscriber $subscriber'); subscriber.onMailArrived(mime, this); } } @@ -186,12 +217,45 @@ 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 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 @@ -200,6 +264,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { if (existingMessage != null) { return Future.value(existingMessage); } + return loadMessage(index); } @@ -210,6 +275,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 +291,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); } @@ -253,8 +321,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)); @@ -267,6 +334,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { messages.add(mime); } } + return handleOnMessagesVanished(messages); } @@ -289,12 +357,20 @@ 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; } notifySubscribersOnMessageFlagsUpdated(existing ?? message); + return Future.value(); } @@ -307,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 @@ -321,37 +400,41 @@ 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(); + 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 +446,7 @@ abstract class CachedMimeSource extends AsyncMimeSource { cachedMessages.remove(removed); } if (sequence.isNotEmpty) { - onMessagesVanished(sequence); + await onMessagesVanished(sequence); } } @@ -372,10 +455,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 +467,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 +484,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 +492,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; } } @@ -437,20 +523,26 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { AsyncMailboxMimeSource(this.mailbox, this.mailClient); /// The mailbox + @override final Mailbox mailbox; @override final MailClient mailClient; - late StreamSubscription _mailLoadEventSubscription; - late StreamSubscription _mailVanishedEventSubscription; - late StreamSubscription _mailUpdatedEventSubscription; - late StreamSubscription + StreamSubscription? _mailLoadEventSubscription; + StreamSubscription? _mailVanishedEventSubscription; + StreamSubscription? _mailUpdatedEventSubscription; + StreamSubscription? _mailReconnectedEventSubscription; @override Future init() { + if (_mailLoadEventSubscription != null) { + return Future.value(); + } + _registerEvents(); + return mailClient.startPolling(); } @@ -474,39 +566,45 @@ 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); } void _deregisterEvents() { - _mailLoadEventSubscription.cancel(); - _mailVanishedEventSubscription.cancel(); - _mailUpdatedEventSubscription.cancel(); - _mailReconnectedEventSubscription.cancel(); + _mailLoadEventSubscription?.cancel(); + _mailVanishedEventSubscription?.cancel(); + _mailUpdatedEventSubscription?.cancel(); + _mailReconnectedEventSubscription?.cancel(); + _mailLoadEventSubscription = null; + _mailVanishedEventSubscription = null; + _mailUpdatedEventSubscription = null; + _mailReconnectedEventSubscription = null; } Future _onMailReconnected( - MailConnectionReEstablishedEvent event) async { + MailConnectionReEstablishedEvent event, + ) async { if (event.mailClient == mailClient && event.isManualSynchronizationRequired) { final messages = await event.mailClient .fetchMessages(fetchPreference: FetchPreference.envelope); if (messages.isEmpty) { - if (kDebugMode) { - print( - '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. + logger.w( + '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. + return; } - resyncMessagesManually(messages); + await resyncMessagesManually(messages); } } @@ -514,6 +612,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { Future deleteMessages(List messages) { removeFromCache(messages); final sequence = MessageSequence.fromMessages(messages); + return mailClient.deleteMessages(sequence, messages: messages); } @@ -521,14 +620,18 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { Future undoDeleteMessages(DeleteResult deleteResult) async { final result = await mailClient.undoDeleteMessages(deleteResult); await _reAddMessages(result); + return result; } @override Future> deleteAllMessages({bool expunge = false}) async { clear(); + mailbox.messagesExists = 0; + final result = await mailClient.deleteAllMessages(mailbox, expunge: expunge); + return [result]; } @@ -539,6 +642,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { ) { removeFromCache(messages); final sequence = MessageSequence.fromMessages(messages); + return mailClient.moveMessages(sequence, targetMailbox, messages: messages); } @@ -549,14 +653,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; } @@ -576,6 +685,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { StoreAction action = StoreAction.add, }) { final sequence = MessageSequence.fromMessages(messages); + return mailClient.store(sequence, flags, action: action); } @@ -585,6 +695,7 @@ class AsyncMailboxMimeSource extends PagedCachedMimeSource { StoreAction action = StoreAction.add, }) { final sequence = MessageSequence.fromAll(); + return mailClient.store(sequence, flags, action: action); } @@ -630,14 +741,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( @@ -647,11 +756,47 @@ 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, + ); + + @override + String toString() => 'AsyncMailboxMimeSource(${mailClient.account.email}: ' + '${mailbox.name})'; } /// Accesses search results @@ -668,6 +813,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { final MailSearch mailSearch; /// The mailbox on which the search is done + @override final Mailbox mailbox; /// The parent mime source @@ -702,6 +848,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { final sequence = searchResult.pagedSequence.sequence; clear(); final deleteResult = await mailClient.deleteMessages(sequence); + return [deleteResult]; } @@ -741,6 +888,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { Future deleteMessages(List messages) async { final sequence = MessageSequence.fromMessages(messages); searchResult.removeMessageSequence(sequence); + return parent.deleteMessages(messages); } @@ -749,6 +897,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); } @@ -767,6 +916,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { searchResult.addMessage(message); notifySubscriberOnMessageArrived(message); } + return Future.value(); } @@ -779,28 +929,34 @@ class AsyncSearchMimeSource extends AsyncMimeSource { existing.flags = message.flags; notifySubscribersOnMessageFlagsUpdated(existing); } + return Future.value(); } @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(); } @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(); @@ -815,6 +971,7 @@ class AsyncSearchMimeSource extends AsyncMimeSource { Future resyncMessagesManually(List messages) { // just redo the full search for now. notifySubscribersOnCacheInvalidated(); + return init(); } @@ -825,21 +982,25 @@ class AsyncSearchMimeSource extends AsyncMimeSource { @override Future moveMessages( - List messages, Mailbox targetMailbox) { - // TODO: implement moveMessages + List messages, + Mailbox targetMailbox, + ) { + // TODO(RV): implement moveMessages throw UnimplementedError(); } @override Future moveMessagesToFlag( - List messages, MailboxFlag targetMailboxFlag) { - // TODO: implement moveMessagesToFlag + List messages, + MailboxFlag targetMailboxFlag, + ) { + // TODO(RV): implement moveMessagesToFlag throw UnimplementedError(); } @override Future undoMoveMessages(MoveResult moveResult) { - // TODO: implement undoMoveMessages + // TODO(RV): implement undoMoveMessages throw UnimplementedError(); } @@ -851,9 +1012,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/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/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 984702c..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,16 +9,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, this.messageBuilder, @@ -28,30 +19,51 @@ class ComposeData { this.composeMode = ComposeMode.html, }); - ComposeData resume(String text, {ComposeMode? composeMode}) { - return ComposeData(originalMessages, messageBuilder, action, + Message? get originalMessage { + final originalMessages = this.originalMessages; + + return (originalMessages != null && originalMessages.isNotEmpty) + ? 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}) => 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/contact.dart b/lib/models/contact.dart index 7cd2e4d..1291be4 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -23,16 +23,5 @@ class Contact { final List mailAddresses; final DateTime? birthday; //phone numbers, profile photo(s), - //TODO consider full vCard support -} - -class ContactManager { - final List addresses; - ContactManager(this.addresses); - - Iterable find(String search) { - return addresses.where((address) => - address.email.contains(search) || - (address.hasPersonalName && address.personalName!.contains(search))); - } + // TODO(RV): consider full vCard support } diff --git a/lib/models/date_sectioned_message_source.dart b/lib/models/date_sectioned_message_source.dart index 08ba69f..972c51c 100644 --- a/lib/models/date_sectioned_message_source.dart +++ b/lib/models/date_sectioned_message_source.dart @@ -1,15 +1,21 @@ 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 'package:flutter/foundation.dart'; -import '../services/i18n_service.dart'; +import '../localization/app_localizations.g.dart'; +import '../util/date_helper.dart'; import 'message.dart'; import 'message_date_section.dart'; +import 'message_source.dart'; class DateSectionedMessageSource extends ChangeNotifier { + DateSectionedMessageSource( + this.messageSource, { + required this.firstDayOfWeek, + }) { + messageSource.addListener(_update); + } + final int firstDayOfWeek; final MessageSource messageSource; int _numberOfSections = 0; int get size { @@ -17,15 +23,13 @@ class DateSectionedMessageSource extends ChangeNotifier { if (sourceSize == 0) { return 0; } + return sourceSize + _numberOfSections; } late List _sections; bool isInitialized = false; - - DateSectionedMessageSource(this.messageSource) { - messageSource.addListener(_update); - } + var _isDisposed = false; Future init() async { try { @@ -33,7 +37,9 @@ class DateSectionedMessageSource extends ChangeNotifier { _sections = await downloadDateSections(); _numberOfSections = _sections.length; isInitialized = true; - notifyListeners(); + if (!_isDisposed) { + notifyListeners(); + } } catch (e, s) { if (kDebugMode) { print('unexpected error $e at $s'); @@ -48,7 +54,9 @@ class DateSectionedMessageSource extends ChangeNotifier { _sections = await downloadDateSections(); _numberOfSections = _sections.length; isInitialized = true; - notifyListeners(); + if (!_isDisposed) { + notifyListeners(); + } } catch (e, s) { if (kDebugMode) { print('unexpected error $e at $s'); @@ -59,25 +67,31 @@ class DateSectionedMessageSource extends ChangeNotifier { @override void dispose() { messageSource.removeListener(_update); - messageSource.dispose(); + _isDisposed = true; super.dispose(); } - Future> downloadDateSections( - {int numberOfMessagesToBeConsidered = 40}) async { + Future> downloadDateSections({ + 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); } + return getDateSections(messages); } - List getDateSections(List messages) { + List getDateSections( + List messages, + ) { final sections = []; DateSectionRange? lastRange; int foundSections = 0; @@ -85,7 +99,7 @@ class DateSectionedMessageSource extends ChangeNotifier { final message = messages[i]; final dateTime = message.mimeMessage.decodeDate(); if (dateTime != null) { - final range = locator().determineDateSection(dateTime); + final range = DateHelper(firstDayOfWeek).determineDateSection(dateTime); if (range != lastRange) { final index = (lastRange == null) ? 0 : i + foundSections; sections.add(MessageDateSection(range, dateTime, index)); @@ -94,6 +108,7 @@ class DateSectionedMessageSource extends ChangeNotifier { lastRange = range; } } + return sections; } @@ -115,6 +130,7 @@ class DateSectionedMessageSource extends ChangeNotifier { if (message != null) { return SectionElement(null, message); } + return null; } @@ -133,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 []; @@ -151,21 +169,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,15 +193,16 @@ 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, ); } class SectionElement { + SectionElement(this.section, this.message); final MessageDateSection? section; final Message? message; - - SectionElement(this.section, this.message); } diff --git a/lib/models/hive/hive_mime_storage.dart b/lib/models/hive/hive_mime_storage.dart index f2db0fc..0b8ce3c 100644 --- a/lib/models/hive/hive_mime_storage.dart +++ b/lib/models/hive/hive_mime_storage.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:enough_mail/enough_mail.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import '../../logger.dart'; import '../offline_mime_storage.dart'; part 'hive_mime_storage.g.dart'; @@ -13,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: @@ -48,12 +50,16 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { late List _allMessageIds; static String _getBoxName( - MailAccount mailAccount, Mailbox mailbox, String name) => + MailAccount mailAccount, + Mailbox mailbox, + String name, + ) => '${mailAccount.email}_${mailbox.encodedPath.replaceAll('/', '_')}_$name'; static Future initGlobal() async { - Hive.registerAdapter(StorageMessageIdAdapter()); - Hive.registerAdapter(StorageMessageEnvelopeAdapter()); + Hive + ..registerAdapter(StorageMessageIdAdapter()) + ..registerAdapter(StorageMessageEnvelopeAdapter()); await Hive.initFlutter(); } @@ -81,11 +87,12 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { Future?> loadMessageEnvelopes( MessageSequence sequence, ) async { - print('load offline message for ${_mailAccount.name}'); + logger.d('load offline message for ${_mailAccount.name}'); final ids = sequence.toList(_mailbox.messagesExists); final allIds = _allMessageIds; if (allIds.length < ids.length) { - print('${_mailAccount.name}: not enough ids (${allIds.length})'); + logger.d('${_mailAccount.name}: not enough ids (${allIds.length})'); + return null; } final envelopes = []; @@ -94,20 +101,28 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { final messageId = allIds.firstWhereOrNull((messageId) => isUid ? messageId.uid == id : messageId.sequenceId == id); if (messageId == null) { - print( - '${_mailAccount.name}: ${isUid ? 'uid' : 'sequence-id'} $id not found in allIds'); + logger.d( + '${_mailAccount.name}: ${isUid ? 'uid' : 'sequence-id'}' + ' $id not found in allIds', + ); + return null; } final messageEnvelope = await _boxEnvelopes.get(messageId.guid); if (messageEnvelope == null) { - print( - '${_mailAccount.name}: message data not found for guid ${messageId.guid} belonging to ${isUid ? 'uid' : 'sequence-id'} $id '); + logger.d( + '${_mailAccount.name}: message data not found for ' + 'guid ${messageId.guid} belonging to ' + '${isUid ? 'uid' : 'sequence-id'} $id ', + ); + return null; } final mimeMessage = messageEnvelope.toMimeMessage(); envelopes.add(mimeMessage); } - print('${_mailAccount.name}: all messages loaded offline :-)'); + logger.d('${_mailAccount.name}: all messages loaded offline :-)'); + return envelopes; } @@ -130,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 = @@ -148,10 +163,13 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { } final futures = [ _boxEnvelopes.putAll(map), - _boxIds.put(_keyMessageIds, allMessageIds) + _boxIds.put(_keyMessageIds, allMessageIds), ]; - print( - '${_mailAccount.name}: saved message envelopes :-) (ids: $addedMessageIds (total: ${allMessageIds.length}) / envelopes: ${map.length})'); + logger.d( + '${_mailAccount.name}: saved message envelopes :-) ' + '(ids: $addedMessageIds (total: ${allMessageIds.length}) / ' + 'envelopes: ${map.length})', + ); await Future.wait(futures); } @@ -169,6 +187,7 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { if (existingContent == null) { return null; } + return MimeMessage.parseFromText(existingContent); } @@ -179,6 +198,7 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { Hive.deleteBoxFromDisk(_boxNameEnvelopes), Hive.deleteBoxFromDisk(_boxNameFullMessages), ]; + return Future.wait(futures); } @@ -188,8 +208,9 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { if (guid == null) { return Future.value(); } - print('delete message with guid $guid from storage'); + logger.d('delete message with guid $guid from storage'); _allMessageIds.removeWhere((id) => id.guid == guid); + return Future.wait([ _boxIds.put(_keyMessageIds, _allMessageIds), _boxEnvelopes.delete(guid), @@ -199,7 +220,7 @@ class HiveMailboxMimeStorage extends OfflineMimeStorage { @override Future moveMessages(List messages, Mailbox targetMailbox) { - // TODO: implement moveMessages + // TODO(RV): implement moveMessages throw UnimplementedError(); } } @@ -230,6 +251,7 @@ class TextHiveStorage { Future load(String key) async { final box = _textBox ?? await Hive.openBox(_keyTextBox); _textBox ??= box; + return box.get(key); } } @@ -317,6 +339,7 @@ class StorageMessageEnvelope { flags: message.flags, ); } + return StorageMessageEnvelope( uid: uid, guid: guid, @@ -377,11 +400,12 @@ 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; } + return MailAddress.parse(input); } diff --git a/lib/models/mail_operation.dart b/lib/models/mail_operation.dart index 1c06271..17892bd 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); } } @@ -80,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); } @@ -110,22 +116,22 @@ 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); @override Future execute(MailClient mailClient, OfflineMimeStorage storage) { - // TODO: implement execute + // TODO(RV): implement execute throw UnimplementedError(); } } diff --git a/lib/models/message.dart b/lib/models/message.dart index d572000..8cf859a 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,22 +1,21 @@ +import 'package:collection/collection.dart'; 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 '../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(parent.source), + : source = SingleMessageSource( + parent.source, + account: parent.source.account, + ), sourceIndex = 0 { (source as SingleMessageSource).singleMessage = this; isEmbedded = true; @@ -25,7 +24,6 @@ class Message extends ChangeNotifier { static const String keywordFlagUnsubscribed = r'$Unsubscribed'; MimeMessage mimeMessage; - final MailClient mailClient; int sourceIndex; final MessageSource source; @@ -48,11 +46,11 @@ class Message extends ChangeNotifier { infos.addAll(inlineAttachments); _attachments = infos; } + return infos; } - RealAccount get account => - locator().getAccountFor(mailClient.account)!; + Account get account => source.account; set isSelected(bool value) { if (value != _isSelected) { @@ -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; @@ -130,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 && @@ -153,17 +153,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() => '${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,18 +167,15 @@ 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'); 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; @@ -200,16 +192,17 @@ extension NewsLetter on MimeMessage { if (sender.isNotEmpty) { return sender.first.toString(); } + 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 = []; + // TODO(RV): allow comments in / before URIs, e.g. "(send a mail to unsubscribe) " + final uris = []; final parts = value.split('>'); for (var part in parts) { part = part.trimLeft(); @@ -222,50 +215,52 @@ 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; } - bool hasListUnsubscribePostHeader() { - return hasHeader('list-unsubscribe-post'); - } + bool hasListUnsubscribePostHeader() => hasHeader('list-unsubscribe-post'); Future unsubscribe(MailClient client) async { final uris = decodeListUnsubscribeUris(); 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) { - var response = await unsubscribeWithOneClick(httpUri); + final response = await unsubscribeWithOneClick(httpUri); if (response.statusCode == 200) { return true; } } // 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; } @@ -275,49 +270,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) { - var request = http.MultipartRequest('POST', 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); } } - -class DisplayMessageArguments { - 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..f7819cd 100644 --- a/lib/models/message_date_section.dart +++ b/lib/models/message_date_section.dart @@ -1,9 +1,8 @@ -import 'package:enough_mail_app/services/date_service.dart'; +import '../util/date_helper.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..52efdec 100644 --- a/lib/models/message_source.dart +++ b/lib/models/message_source.dart @@ -1,14 +1,14 @@ +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 '../localization/app_localizations.g.dart'; +import '../logger.dart'; +import '../notification/model.dart'; +import '../notification/service.dart'; +import '../scaffold_messenger/service.dart'; import '../util/indexed_cache.dart'; -import 'account.dart'; import 'async_mime_source.dart'; import 'message.dart'; @@ -25,17 +25,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(); @@ -44,6 +40,7 @@ abstract class MessageSource extends ChangeNotifier /// the description of this source String? get description => _description; + set description(String? value) { _description = value; notifyListeners(); @@ -58,6 +55,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? @@ -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); @@ -102,6 +103,7 @@ abstract class MessageSource extends ChangeNotifier message = await loadMessage(index); cache[index] = message; } + return message; } @@ -113,6 +115,7 @@ abstract class MessageSource extends ChangeNotifier if (current.sourceIndex >= size - 1) { return Future.value(); } + return getMessageAt(current.sourceIndex + 1); } @@ -121,6 +124,7 @@ abstract class MessageSource extends ChangeNotifier if (current.sourceIndex == 0) { return Future.value(); } + return getMessageAt(current.sourceIndex - 1); } @@ -133,22 +137,25 @@ 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) { final mime = message.mimeMessage; - parent.removeMime(mime, message.mailClient); + parent.removeMime(mime, getMimeSource(message)); } if (removed && notify) { notifyListeners(); } + return removed; } @override void onMailFlagsUpdated(MimeMessage mime, AsyncMimeSource source) { - final message = cache.getWithMime(mime, source.mailClient); + final message = cache.getWithMime(mime, source); if (message != null) { message.updateFlags(mime.flags); } @@ -156,7 +163,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, source); if (message != null) { removeFromCache(message); @@ -164,10 +171,13 @@ 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); + final message = createMessage(mime, source, index); insertIntoCache(index, message); notifyListeners(); } @@ -181,17 +191,20 @@ 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(AppLocalizations localizations, Message message) => + deleteMessages( + localizations, + [message], + localizations.resultDeleted, + ); /// Deletes the given messages Future deleteMessages( + AppLocalizations localizations, List messages, String notification, ) { - final notificationService = locator(); + final notificationService = NotificationService.instance; for (final message in messages) { _removeMessageFromCacheAndCancelNotification( message, @@ -200,10 +213,12 @@ abstract class MessageSource extends ChangeNotifier ); } notifyListeners(); - return _deleteMessages(messages, notification); + + return _deleteMessages(localizations, messages, notification); } Future _deleteMessages( + AppLocalizations localizations, List messages, String notification, ) async { @@ -216,7 +231,8 @@ abstract class MessageSource extends ChangeNotifier resultsBySource[source] = deleteResult; } } - locator().showTextSnackBar( + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, notification, undo: resultsBySource.isEmpty ? null @@ -230,43 +246,68 @@ abstract class MessageSource extends ChangeNotifier ); } - Future markAsJunk(Message message) { - return moveMessageToFlag(message, MailboxFlag.junk, - locator().localizations.resultMovedToJunk); - } + Future markAsJunk(AppLocalizations localizations, Message message) => + moveMessageToFlag( + localizations, + message, + MailboxFlag.junk, + localizations.resultMovedToJunk, + ); - Future markAsNotJunk(Message message) { - return moveMessageToFlag(message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox); - } + Future markAsNotJunk(AppLocalizations localizations, Message message) => + moveMessageToFlag( + localizations, + message, + MailboxFlag.inbox, + localizations.resultMovedToInbox, + ); Future moveMessageToFlag( + AppLocalizations localizations, Message message, MailboxFlag targetMailboxFlag, String notification, - ) { - return moveMessage(message, - message.mailClient.getMailbox(targetMailboxFlag)!, notification); - } + ) => + moveMessage( + localizations, + message, + message.source + .getMimeSource(message) + ?.mailClient + .getMailbox(targetMailboxFlag) ?? + Mailbox( + encodedName: 'inbox', + encodedPath: 'inbox', + flags: [], + pathSeparator: '/', + ), + notification, + ); Future moveMessage( + AppLocalizations localizations, Message message, Mailbox targetMailbox, String notification, ) async { _removeMessageFromCacheAndCancelNotification( message, - locator(), + NotificationService.instance, 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( + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, notification, undo: () async { - await message.mailClient.undoMoveMessages(moveResult); + await mailClient.undoMoveMessages(moveResult); insertIntoCache(message.sourceIndex, message); notifyListeners(); }, @@ -279,16 +320,17 @@ abstract class MessageSource extends ChangeNotifier NotificationService notificationService, { bool notify = true, }) { - notificationService.cancelNotificationForMailMessage(message); + notificationService.cancelNotificationForMessage(message); removeFromCache(message, notify: notify); } Future moveMessagesToFlag( + AppLocalizations localizations, List messages, MailboxFlag targetMailboxFlag, String notification, ) async { - final notificationService = locator(); + final notificationService = NotificationService.instance; for (final message in messages) { _removeMessageFromCacheAndCancelNotification( message, @@ -308,7 +350,8 @@ abstract class MessageSource extends ChangeNotifier } notifyListeners(); if (resultsBySource.isNotEmpty) { - locator().showTextSnackBar( + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, notification, undo: () async { for (final source in resultsBySource.keys) { @@ -322,11 +365,12 @@ abstract class MessageSource extends ChangeNotifier } Future moveMessages( + AppLocalizations localizations, List messages, Mailbox targetMailbox, String notification, ) async { - final notificationService = locator(); + final notificationService = NotificationService.instance; for (final message in messages) { _removeMessageFromCacheAndCancelNotification( message, @@ -340,7 +384,8 @@ abstract class MessageSource extends ChangeNotifier final mimes = messages.map((m) => m.mimeMessage).toList(); final moveResult = await source.moveMessages(mimes, targetMailbox); notifyListeners(); - locator().showTextSnackBar( + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, notification, undo: moveResult.canUndo ? () async { @@ -351,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, + ); } } @@ -362,33 +412,51 @@ abstract class MessageSource extends ChangeNotifier } } - Future moveToInbox(Message message) async { - return moveMessageToFlag(message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox); - } + Future moveToInbox( + AppLocalizations localizations, + Message message, + ) async => + moveMessageToFlag( + localizations, + message, + MailboxFlag.inbox, + localizations.resultMovedToInbox, + ); - Future archive(Message message) { - return moveMessageToFlag(message, MailboxFlag.archive, - locator().localizations.resultArchived); - } + Future archive(AppLocalizations localizations, Message message) => + moveMessageToFlag( + localizations, + message, + MailboxFlag.archive, + localizations.resultArchived, + ); Future markAsSeen(Message msg, bool isSeen) { final source = getMimeSource(msg); if (source != null) { onMarkedAsSeen(msg, isSeen); if (isSeen) { - locator().cancelNotificationForMailMessage(msg); + NotificationService.instance.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; - final parentMsg = - parent?.cache.getWithMime(msg.mimeMessage, msg.mailClient); + final parentMsg = parent?.cache.getWithMime(msg.mimeMessage, source); 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) { @@ -396,24 +464,31 @@ abstract class MessageSource extends ChangeNotifier final parent = _parentMessageSource; if (parent != null) { final parentMsg = - parent.cache.getWithMime(msg.mimeMessage, msg.mailClient); + parent.cache.getWithMime(msg.mimeMessage, getMimeSource(msg)); if (parentMsg != null) { - parent.onMarkedAsSeen(parentMsg, isSeen); + return parent.onMarkedAsSeen(parentMsg, isSeen); } } } 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, + getMimeSource(msg), + ); if (parentMsg != null) { parent.onMarkedAsFlagged(parentMsg, isFlagged); } @@ -421,35 +496,44 @@ abstract class MessageSource extends ChangeNotifier } Future markMessagesAsSeen(List messages, bool isSeen) { - final notificationService = locator(); + final notificationService = NotificationService.instance; for (final msg in messages) { onMarkedAsSeen(msg, isSeen); if (isSeen) { - notificationService.cancelNotificationForMailMessage(msg); + notificationService.cancelNotificationForMessage(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); 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]; @@ -459,24 +543,15 @@ abstract class MessageSource extends ChangeNotifier mimesBySource[source] = [message.mimeMessage]; } } + 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, - {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) { @@ -484,13 +559,14 @@ abstract class MessageSource extends ChangeNotifier final future = source.store(messages, flags, action: action); futures.add(future); } + return Future.wait(futures); } - MessageSource search(MailSearch search); + MessageSource search(AppLocalizations localizations, MailSearch search); - void removeMime(MimeMessage mimeMessage, MailClient mailClient) { - final existingMessage = cache.getWithMime(mimeMessage, mailClient); + void removeMime(MimeMessage mimeMessage, AsyncMimeSource? mimeSource) { + final existingMessage = cache.getWithMime(mimeMessage, mimeSource); if (existingMessage != null) { removeFromCache(existingMessage); } @@ -514,18 +590,57 @@ 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, + ); + } + + /// 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); + + /// Loads the message source for the given [payload] + Future loadSingleMessage(MailNotificationPayload payload) { + throw UnimplementedError(); } // void replaceMime(Message message, MimeMessage mime) { @@ -538,108 +653,129 @@ 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, + this.mailbox, { + required this.account, + super.parent, + super.isSearch, + }) { _description = description; - _name = name; - _mimeSource.addSubscriber(this); + _name = mailbox.name; + mimeSource.addSubscriber(this); + logger.d('Creating MailboxMessageSource for mimeSource $mimeSource'); } + /// The associated mailbox + final Mailbox mailbox; + + @override + final RealAccount account; + @override - int get size => _mimeSource.size; + int get size => mimeSource.size; - final AsyncMimeSource _mimeSource; + /// The mime source for this message source + 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); - return Message(mime, _mimeSource.mailClient, this, 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(); + logger + ..d('deleteAllMessages: in cache: ${removedMessages.length}') + ..d('size after deletion: $size'); notifyListeners(); final results = await futureResults; final parent = _parentMessageSource; if (parent != null) { for (final removedMessage in removedMessages) { final mime = removedMessage.mimeMessage; - parent.removeMime(mime, removedMessage.mailClient); + parent.removeMime(mime, getMimeSource(removedMessage)); } } + return results; } @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; } @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 localizations = locator().localizations; + MessageSource search(AppLocalizations localizations, MailSearch search) { + final searchSource = mimeSource.search(search); + return MailboxMessageSource.fromMimeSource( searchSource, - localizations.searchQueryDescription(name!), - localizations.searchQueryTitle(search.query), + search.query, + mailbox, + account: account, parent: this, isSearch: true, ); } @override - AsyncMimeSource? getMimeSource(Message message) { - return _mimeSource; - } + AsyncMimeSource? getMimeSource(Message message) => mimeSource; @override void clear() { cache.clear(); - _mimeSource.clear(); + mimeSource.clear(); } @override @@ -647,6 +783,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 { @@ -658,18 +810,26 @@ 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, + this.flag, { + required this.account, + super.parent, + super.isSearch, + }) { for (final s in mimeSources) { s.addSubscriber(this); _multipleMimeSources.add(_MultipleMimeSource(s)); } _name = name; - _flag = flag; _description = mimeSources.map((s) => s.mailClient.account.name).join(', '); } + @override + final UnifiedAccount account; + @override Future init() async { final futures = mimeSources.map((source) => source.init()); @@ -677,9 +837,13 @@ class MultipleMessageSource extends MessageSource { supportsDeleteAll = mimeSources.any((s) => s.supportsDeleteAll); } + /// The integrated mime sources final List mimeSources; final _multipleMimeSources = <_MultipleMimeSource>[]; - MailboxFlag? _flag; + + /// The identity flag of the mailbox + MailboxFlag flag; + final _indicesCache = <_MultipleMessageSourceId>[]; @override @@ -689,14 +853,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(); } @@ -733,10 +899,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; } @@ -745,11 +921,10 @@ 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); + + return _UnifiedMessage(mime, this, index, id.source); } - // 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 +936,8 @@ class MultipleMessageSource extends MessageSource { _nextCalls[index] = nextCall; } final nextMessage = await nextCall; - _nextCalls.remove(index); + await _nextCalls.remove(index); + return nextMessage; } @@ -770,6 +946,7 @@ class MultipleMessageSource extends MessageSource { @override bool removeFromCache(Message message, {bool notify = true}) { _indicesCache.removeAt(message.sourceIndex); + return super.removeFromCache(message, notify: notify); } @@ -787,23 +964,39 @@ class MultipleMessageSource extends MessageSource { if (parent != null) { for (final removedMessage in removedMessages) { parent.removeMime( - removedMessage.mimeMessage, removedMessage.mailClient); + removedMessage.mimeMessage, + getMimeSource(removedMessage), + ); } } 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) { - return mimeSources - .firstWhere((source) => source.mailClient == message.mailClient); + if (message is _UnifiedMessage) { + return message.mimeSource; + } + 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); @@ -829,21 +1022,20 @@ 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), - _flag, + flag, + account: account, parent: this, isSearch: true, - ); - searchMessageSource._description = - localizations.searchQueryDescription(name ?? ''); + ).._description = localizations.searchQueryDescription(name ?? ''); + return searchMessageSource; } @@ -874,8 +1066,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; @@ -908,6 +1103,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; @@ -916,16 +1122,13 @@ 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; - } + Future<_MultipleMimeSourceMessage?> peek() async => + _currentMessage ??= await _next(); void pop() { _currentMessage = null; @@ -938,6 +1141,7 @@ class _MultipleMimeSource { } _currentIndex++; final mime = await mimeSource.getMessage(index); + return _MultipleMimeSourceMessage(index, this, mime); } @@ -951,8 +1155,10 @@ class _MultipleMimeSource { if (mimeSource.supportsDeleteAll) { _currentIndex = 0; _currentMessage = null; + return mimeSource.deleteAllMessages(expunge: expunge); } + return Future.value([]); } @@ -967,13 +1173,15 @@ class _MultipleMimeSource { } class SingleMessageSource extends MessageSource { + SingleMessageSource(MessageSource? parent, {required this.account}) + : super(parent: parent); Message? singleMessage; - SingleMessageSource(MessageSource? parent) : super(parent: parent); @override - Future loadMessage(int index) { - return Future.value(singleMessage); - } + final Account account; + + @override + Future loadMessage(int index) => Future.value(singleMessage); @override Future> deleteAllMessages({bool expunge = false}) { @@ -981,9 +1189,7 @@ class SingleMessageSource extends MessageSource { } @override - Future init() { - return Future.value(); - } + Future init() => Future.value(); @override bool get isArchive => false; @@ -998,7 +1204,7 @@ class SingleMessageSource extends MessageSource { bool get isSent => false; @override - MessageSource search(MailSearch search) { + MessageSource search(AppLocalizations localizations, MailSearch search) { throw UnimplementedError(); } @@ -1016,14 +1222,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() { @@ -1032,19 +1235,27 @@ class SingleMessageSource extends MessageSource { @override void onMailCacheInvalidated(AsyncMimeSource source) { - // TODO: implement onMailCacheInvalidated + // TODO(RV): implement onMailCacheInvalidated } } class ListMessageSource extends MessageSource { + ListMessageSource( + MessageSource parent, + ) : account = parent.account, + super(parent: parent); + late List messages; - ListMessageSource(MessageSource parent) : super(parent: parent); + + @override + 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(); @@ -1061,9 +1272,7 @@ class ListMessageSource extends MessageSource { } @override - Future init() { - return Future.value(); - } + Future init() => Future.value(); @override bool get isArchive => false; @@ -1078,7 +1287,7 @@ class ListMessageSource extends MessageSource { bool get isSent => false; @override - MessageSource search(MailSearch search) { + MessageSource search(AppLocalizations localizations, MailSearch search) { throw UnimplementedError(); } @@ -1095,14 +1304,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() { @@ -1111,75 +1317,7 @@ class ListMessageSource extends MessageSource { @override void onMailCacheInvalidated(AsyncMimeSource source) { - // TODO: implement onMailCacheInvalidated - } -} - -class ErrorMessageSource extends MessageSource { - final Account account; - - ErrorMessageSource(this.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() { - return 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 + // TODO(RV): implement onMailCacheInvalidated } } @@ -1192,16 +1330,23 @@ 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, AsyncMimeSource? mimeSource) { + 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); + if (sequenceId != null) { + return firstWhereOrNull( + (msg) => + msg.mimeMessage.sequenceId == sequenceId && + msg.source.getMimeSource(msg) == mimeSource, + ); + } + + return null; } // Message? getWithSourceIndex(int sourceIndex, MailClient mailClient) => diff --git a/lib/models/models.dart b/lib/models/models.dart index a9c9cc2..b68916a 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'; @@ -9,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/models/offline_mime_source.dart b/lib/models/offline_mime_source.dart index ecfc41e..79d6d78 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; @@ -65,9 +65,40 @@ 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([ @@ -82,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 { @@ -104,6 +135,7 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { } final onlineMessages = await _onlineMimeSource.loadMessages(sequence); await _storage.saveMessageEnvelopes(onlineMessages); + return onlineMessages; } @@ -112,7 +144,9 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { @override Future moveMessages( - List messages, Mailbox targetMailbox) async { + List messages, + Mailbox targetMailbox, + ) async { // TODO(RV): this and most other offline ops should be done with a queue // Some ops can be done offline and an later online, e.g. store flags // Some ops must update their offline part after having finished it online, @@ -123,37 +157,40 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { final result = await _onlineMimeSource.moveMessages(messages, targetMailbox); await _storage.moveMessages(messages, targetMailbox); + return result; } @override Future moveMessagesToFlag( - List messages, MailboxFlag targetMailboxFlag) { - // TODO: implement moveMessagesToFlag + List messages, + MailboxFlag targetMailboxFlag, + ) { + // 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(); } @@ -162,12 +199,12 @@ class OfflineMailboxMimeSource extends PagedCachedMimeSource { @override AsyncMimeSource search(MailSearch search) { - // TODO: implement search + // TODO(RV): implement search throw UnimplementedError(); } @override - int get size => _mailbox.messagesExists; + int get size => mailbox.messagesExists; @override Future store( @@ -191,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/models/offline_mime_storage_factory.dart b/lib/models/offline_mime_storage_factory.dart index 8dbf6ef..7624a10 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,6 @@ 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/search.dart b/lib/models/search.dart index f8ad617..1722bf5 100644 --- a/lib/models/search.dart +++ b/lib/models/search.dart @@ -1,3 +1 @@ -class Search { - -} \ No newline at end of file +class Search {} diff --git a/lib/models/sender.dart b/lib/models/sender.dart index 1788705..83c92a7 100644 --- a/lib/models/sender.dart +++ b/lib/models/sender.dart @@ -1,15 +1,37 @@ import 'package:enough_mail/enough_mail.dart'; -import 'account.dart'; +import 'package:flutter/foundation.dart'; +import '../account/model.dart'; + +/// Contains information about a sender for composing new messages +@immutable class Sender { - 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; - Sender(this.address, this.account, {this.isPlaceHolderForPlusAlias = false}); + /// The lowercase email address for comparisons + final String emailLowercase; + + @override + String toString() => address.toString(); + + @override + int get hashCode => emailLowercase.hashCode; @override - String toString() { - return address.toString(); - } + bool operator ==(Object other) => + other is Sender && other.emailLowercase == emailLowercase; } diff --git a/lib/models/swipe.dart b/lib/models/swipe.dart index 3198864..a6dc7f7 100644 --- a/lib/models/swipe.dart +++ b/lib/models/swipe.dart @@ -1,8 +1,7 @@ -import 'package:enough_mail_app/services/icon_service.dart'; import 'package:flutter/material.dart'; -import '../l10n/app_localizations.g.dart'; -import '../locator.dart'; +import '../localization/app_localizations.g.dart'; +import '../settings/theme/icon_service.dart'; enum SwipeAction { markRead, @@ -17,15 +16,15 @@ extension SwipeExtension on SwipeAction { Color get colorBackground { switch (this) { case SwipeAction.markRead: - return Colors.blue[200]!; + return Colors.blue[200] ?? Colors.lightBlue; case SwipeAction.archive: - return Colors.amber[200]!; + return Colors.amber[200] ?? Colors.amber; case SwipeAction.markJunk: - return Colors.red[200]!; + return Colors.red[200] ?? Colors.orangeAccent; case SwipeAction.delete: - return Colors.red[600]!; + return Colors.red[600] ?? Colors.red; case SwipeAction.flag: - return Colors.lime[600]!; + return Colors.lime[600] ?? Colors.lime; } } @@ -47,15 +46,15 @@ extension SwipeExtension on SwipeAction { Color get colorIcon { switch (this) { case SwipeAction.markRead: - return Colors.blue[900]!; + return Colors.blue[900] ?? Colors.blue; case SwipeAction.archive: - return Colors.amber[900]!; + return Colors.amber[900] ?? Colors.amber; case SwipeAction.markJunk: - return Colors.red[900]!; + return Colors.red[900] ?? Colors.red; case SwipeAction.delete: return Colors.white; case SwipeAction.flag: - return Colors.lime[900]!; + return Colors.lime[900] ?? Colors.lime; } } @@ -76,7 +75,7 @@ extension SwipeExtension on SwipeAction { /// Icon of the action IconData get icon { - final iconService = locator(); + final iconService = IconService.instance; switch (this) { case SwipeAction.markRead: return iconService.messageIsNotSeen; diff --git a/lib/models/web_view_configuration.dart b/lib/models/web_view_configuration.dart index 73f2a2a..5cfacad 100644 --- a/lib/models/web_view_configuration.dart +++ b/lib/models/web_view_configuration.dart @@ -1,6 +1,5 @@ class WebViewConfiguration { + WebViewConfiguration(this.title, this.uri); final String? title; final Uri uri; - - WebViewConfiguration(this.title, this.uri); } 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 56% rename from lib/services/notification_service.dart rename to lib/notification/service.dart index 52906d7..b49ee62 100644 --- a/lib/services/notification_service.dart +++ b/lib/notification/service.dart @@ -2,31 +2,35 @@ import 'dart:convert'; import 'dart:io'; import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:go_router/go_router.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 '../logger.dart'; +import '../models/message.dart' as maily; +import '../routes/routes.dart'; +import 'model.dart'; -import '../locator.dart'; -import '../routes.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(); - Future init( - {bool checkForLaunchDetails = true}) async { + Future init({ + BuildContext? context, + 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; @@ -42,6 +46,12 @@ class NotificationService { initSettings, onDidReceiveNotificationResponse: _selectNotification, ); + if (Platform.isAndroid) { + await _flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.requestNotificationsPermission(); + } if (checkForLaunchDetails) { final launchDetails = await _flutterLocalNotificationsPlugin .getNotificationAppLaunchDetails(); @@ -49,8 +59,12 @@ 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}'); + if (context != null && context.mounted) { + _selectNotification(response, context: context); + } + return NotificationServiceInitResult.appLaunchedByNotification; } } @@ -97,72 +111,77 @@ class NotificationService { MailNotificationPayload _deserialize(String payloadText) { final json = jsonDecode(payloadText.substring(_messagePayloadStart.length)); + return MailNotificationPayload.fromJson(json); } - Future _selectNotification(NotificationResponse response) async { + void _selectNotification( + NotificationResponse response, { + BuildContext? context, + }) { final payloadText = response.payload; if (kDebugMode) { print('select notification: $payloadText'); } - if (payloadText!.startsWith(_messagePayloadStart)) { - try { - final payload = _deserialize(payloadText); + final usedContext = context ?? Routes.navigatorKey.currentContext; + if (usedContext == null) { + logger.e('Unable to show notification: no context found'); - 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); - final message = - maily.Message(mimeMessage, mailClient, messageSource, 0); - messageSource.singleMessage = message; - locator() - .push(Routes.mailDetails, arguments: message); - } on MailException catch (e, s) { - if (kDebugMode) { - print('Unable to fetch notification message $payloadText: $e $s '); - } - } + return; } - } - Future sendLocalNotificationForMailLoadEvent(MailLoadEvent event) { - return sendLocalNotificationForMail(event.message, event.mailClient); + if (payloadText != null && payloadText.startsWith(_messagePayloadStart)) { + final payload = _deserialize(payloadText); + usedContext.pushNamed( + Routes.mailDetailsForNotification, + extra: payload, + ); + } } - Future sendLocalNotificationForMailMessage(maily.Message message) { - return sendLocalNotificationForMail( - message.mimeMessage, message.mailClient); - } + Future sendLocalNotificationForMailMessage(maily.Message message) => + sendLocalNotificationForMail( + message.mimeMessage, + message.source.getMimeSource(message)?.mailClient.account.email ?? + message.account.email, + ); Future sendLocalNotificationForMail( - MimeMessage mimeMessage, MailClient mailClient) { - if (kDebugMode) { - print( - '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 + MimeMessage mimeMessage, + String accountEmail, + ) { + 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 ?? 0; + final from = retrieveFromName(); + 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) { @@ -173,7 +192,7 @@ class NotificationService { // return (email?.hashCode ?? 0) + uid; // } - Future sendLocalNotification( + Future _sendLocalNotification( int id, String title, String? text, { @@ -181,6 +200,7 @@ class NotificationService { DateTime? when, bool channelShowBadge = true, }) async { + logger.d('sendLocalNotification: $id: $title $text'); AndroidNotificationDetails? androidPlatformChannelSpecifics; DarwinNotificationDetails? iosPlatformChannelSpecifics; if (Platform.isAndroid) { @@ -191,12 +211,11 @@ 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) { + } else if (PlatformInfo.isCupertino) { iosPlatformChannelSpecifics = const DarwinNotificationDetails( presentSound: true, presentBadge: true, @@ -210,11 +229,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); @@ -231,43 +249,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, MailClient mailClient) - : uid = mimeMessage.uid!, - guid = mimeMessage.guid!, - sequenceId = mimeMessage.sequenceId!, - subject = mimeMessage.decodeSubject() ?? '', - size = mimeMessage.size!, - accountEmail = mailClient.account.email; - - /// 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/oauth/oauth.dart b/lib/oauth/oauth.dart index 193912c..48967ba 100644 --- a/lib/oauth/oauth.dart +++ b/lib/oauth/oauth.dart @@ -1,58 +1,116 @@ 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 'package:flutter/foundation.dart'; import 'package:flutter_web_auth/flutter_web_auth.dart'; +import 'package:http/http.dart' as http; +import '../keys/service.dart'; +import '../logger.dart'; +import '../util/http_helper.dart'; + +/// Defines the ID and secret of an OAuth client class OauthClientId { + /// Creates a new [OauthClientId] + const OauthClientId(this.id, this.secret); + + /// The ID of the OAuth client final String id; - final String? secret; - const OauthClientId(this.id, this.secret); + /// The secret of the OAuth client + final String? secret; } +/// Provides means to authenticate with an OAuth provider +/// and to refresh the access token abstract class OauthClient { + /// Creates a new [OauthClient] + OauthClient(this.incomingHostName); + + /// The hostname of the incoming mail server final String incomingHostName; - bool get isEnabled => (oauthClientId != null); + + /// Whether this client is enabled + bool get isEnabled => oauthClientId != null; + + /// The [OauthClientId] for this client OauthClientId? get oauthClientId => - locator().oauth[incomingHostName]; - OauthClient(this.incomingHostName); + KeyService.instance.oauth[incomingHostName]; + /// Authenticates with the given [email] address Future authenticate(String email) async { try { - final token = await _authenticate(email, incomingHostName); + final oauthClientId = this.oauthClientId; + if (oauthClientId == null) { + logger.d('no oauth client id for $incomingHostName'); + + return Future.value(); + } + final token = await _authenticate(oauthClientId, email, incomingHostName); + logger.d( + 'authenticated $email and received refresh ' + 'token ${token.refreshToken}', + ); + return token; } catch (e, s) { - if (kDebugMode) { - print('Unable to authenticate: $e $s'); - } + logger.e('Unable to authenticate: $e', error: e, stackTrace: s); + return Future.value(); } } + /// Refreshes the given [token] Future refresh(OauthToken token) async { + final oauthClientId = this.oauthClientId; + if (oauthClientId == null) { + logger.d('no oauth client id for $incomingHostName'); + + return Future.value(); + } try { - final refreshedToken = await _refresh(token, incomingHostName); + final refreshedToken = await _refresh( + oauthClientId, + token, + incomingHostName, + ); + logger.d( + 'refreshed token and received refresh token ' + '${refreshedToken.refreshToken}', + ); + return refreshedToken; } catch (e, s) { - if (kDebugMode) { - print('Unable to refresh tokens: $e $s'); - } + logger.e('Unable to refresh tokens: $e', error: e, stackTrace: s); + return Future.value(); } } - Future _authenticate(String email, String provider); - Future _refresh(OauthToken token, String provider); + /// Subclasses have to implement the actual authentication + Future _authenticate( + OauthClientId oauthClientId, + String email, + String provider, + ); + + /// Subclasses have to implement the actual token refresh + Future _refresh( + OauthClientId oauthClientId, + OauthToken token, + String provider, + ); } +/// Provide Gmail OAuth authentication class GmailOAuthClient extends OauthClient { + /// Creates a new [GmailOAuthClient] GmailOAuthClient() : super('imap.gmail.com'); @override - Future _authenticate(String email, String provider) async { - final clientId = oauthClientId!.id; + Future _authenticate( + OauthClientId oauthClientId, + String email, + String provider, + ) async { + final clientId = oauthClientId.id; final callbackUrlScheme = clientId.split('.').reversed.join('.'); // Construct the url @@ -74,8 +132,8 @@ class GmailOAuthClient extends OauthClient { final code = Uri.parse(result).queryParameters['code']; // Use this code to get an access token - final response = await HttpHelper.httpPost( - 'https://oauth2.googleapis.com/token', + final response = await http.post( + Uri.parse('https://oauth2.googleapis.com/token'), body: { 'client_id': clientId, 'redirect_uri': '$callbackUrlScheme:/', @@ -87,56 +145,85 @@ class GmailOAuthClient extends OauthClient { // Get the access token from the response final text = response.text; if (response.statusCode != 200 || text == null) { + logger.e('received status code ${response.statusCode} with $text'); throw StateError( - 'Unable to get Google OAuth token with code $code, status code=${response.statusCode}, response=$text'); + 'Unable to get Google OAuth token with code $code, ' + 'status code=${response.statusCode}, response=$text', + ); } + return OauthToken.fromText(text, provider: provider); } @override - Future _refresh(OauthToken token, String provider) async { - final clientId = oauthClientId!.id; + Future _refresh( + OauthClientId oauthClientId, + OauthToken token, + String provider, + ) async { + final clientId = oauthClientId.id; final callbackUrlScheme = clientId.split('.').reversed.join('.'); - final response = - await HttpHelper.httpPost('https://oauth2.googleapis.com/token', body: { - 'client_id': clientId, - 'redirect_uri': '$callbackUrlScheme:/', - 'refresh_token': token.refreshToken, - 'grant_type': 'refresh_token', - }); + final response = await http.post( + Uri.parse('https://oauth2.googleapis.com/token'), + body: { + 'client_id': clientId, + 'redirect_uri': '$callbackUrlScheme:/', + 'refresh_token': token.refreshToken, + 'grant_type': 'refresh_token', + }, + ); final text = response.text; if (response.statusCode != 200 || text == null) { + logger.e( + 'refresh: received status code ${response.statusCode} with $text', + ); throw StateError( - 'Unable to refresh Google OAuth token $token, status code=${response.statusCode}, response=$text'); + 'Unable to refresh Google OAuth token $token, ' + 'status code=${response.statusCode}, response=$text', + ); } - return OauthToken.fromText(text, provider: provider); + + return OauthToken.fromText( + text, + provider: provider, + refreshToken: token.refreshToken, + ); } } +/// Provide Outlook OAuth authentication class OutlookOAuthClient extends OauthClient { + /// Creates a new [OutlookOAuthClient] + 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'); + 'https://outlook.office.com/IMAP.AccessAsUser.All ' + 'https://outlook.office.com/SMTP.Send offline_access'; @override - Future _authenticate(String email, String provider) async { - final clientId = oauthClientId!.id; - final clientSecret = oauthClientId!.secret; - const callbackUrlScheme = - //'https://login.microsoftonline.com/common/oauth2/nativeclient'; - 'maily://oauth'; + Future _authenticate( + OauthClientId oauthClientId, + String email, + String provider, + ) async { + final clientId = oauthClientId.id; + final clientSecret = oauthClientId.secret; + const callbackUrlScheme = 'maily://oauth'; // Construct the url final uri = Uri.https( - 'login.microsoftonline.com', '/common/oauth2/v2.0/authorize', { - 'response_type': 'code', - 'client_id': clientId, - 'client_secret': clientSecret, - 'redirect_uri': callbackUrlScheme, - 'scope': _scope, - 'login_hint': email, - }).toString(); + // cSpell: disable-next-line + 'login.microsoftonline.com', + '/common/oauth2/v2.0/authorize', + { + 'response_type': 'code', + 'client_id': clientId, + 'client_secret': clientSecret, + 'redirect_uri': callbackUrlScheme, + 'scope': _scope, + 'login_hint': email, + }, + ).toString(); // print('authenticate URL: $uri'); // Present the dialog to the user @@ -148,40 +235,56 @@ class OutlookOAuthClient extends OauthClient { // Extract code from resulting url final code = Uri.parse(result).queryParameters['code']; // Use this code to get an access token - final response = await HttpHelper.httpPost( - 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - body: { - 'client_id': clientId, - 'redirect_uri': callbackUrlScheme, - 'grant_type': 'authorization_code', - 'code': code, - }); + final response = await http.post( + Uri.parse('https://login.microsoftonline.com/common/oauth2/v2.0/token'), + body: { + 'client_id': clientId, + 'redirect_uri': callbackUrlScheme, + 'grant_type': 'authorization_code', + 'code': code, + }, + ); // Get the access token from the response final responseText = response.text; if (responseText == null) { throw StateError( - 'no response from https://login.microsoftonline.com/common/oauth2/v2.0/token'); + 'no response from ' + 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + ); } + return OauthToken.fromText(responseText, provider: provider); } @override - Future _refresh(OauthToken token, String provider) async { - final clientId = oauthClientId!.id; - final response = await HttpHelper.httpPost( - 'https://login.microsoftonline.com/common/oauth2/v2.0/token', - body: { - 'client_id': clientId, - 'scope': _scope, - 'refresh_token': token.refreshToken, - 'grant_type': 'refresh_token', - }); + Future _refresh( + OauthClientId oauthClientId, + OauthToken token, + String provider, + ) async { + final clientId = oauthClientId.id; + final response = await http.post( + Uri.parse('https://login.microsoftonline.com/common/oauth2/v2.0/token'), + body: { + 'client_id': clientId, + 'scope': _scope, + 'refresh_token': token.refreshToken, + 'grant_type': 'refresh_token', + }, + ); final text = response.text; if (response.statusCode != 200 || text == null) { - throw StateError('Unable to refresh Outlook OAuth token $token, ' - 'status code=${response.statusCode}, response=$text'); + throw StateError( + 'Unable to refresh Outlook OAuth token $token, ' + 'status code=${response.statusCode}, response=$text', + ); } - return OauthToken.fromText(text, provider: provider); + + return OauthToken.fromText( + text, + provider: provider, + refreshToken: token.refreshToken, + ); } } diff --git a/lib/routes.dart b/lib/routes.dart deleted file mode 100644 index 146d775..0000000 --- a/lib/routes.dart +++ /dev/null @@ -1,168 +0,0 @@ -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 'settings/view/view.dart'; - -class Routes { - static const String home = '/'; - static const String accountAdd = '/accountAdd'; - static const String accountEdit = '/accountEdit'; - static const String accountServerDetails = '/accountServerDetails'; - static const String settings = '/settings'; - static const String settingsSecurity = '/settings/security'; - static const String settingsAccounts = '/settings/accounts'; - static const String settingsDesign = '/settings/design'; - static const String settingsFeedback = '/settings/feedback'; - static const String settingsLanguage = '/settings/language'; - static const String settingsFolders = '/settings/folders'; - static const String settingsReadReceipts = '/settings/readReceipts'; - static const String settingsDevelopment = '/settings/developerMode'; - static const String settingsSwipe = '/settings/swipe'; - static const String settingsSignature = '/settingsSignature'; - static const String settingsDefaultSender = '/settingsDefaultSender'; - static const String settingsReplyFormat = '/settingsReplyFormat'; - static const String messageSource = '/messageSource'; - static const String messageSourceFuture = '/messageSource/future'; - static const String mailDetails = '/mailDetails'; - static const String mailContents = '/mailContents'; - static const String mailCompose = '/mailCompose'; - static const String welcome = '/welcome'; - static const String 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'; -} - -class AppRouter { - static Widget generatePage(String? name, Object? arguments) { - Widget page; - switch (name) { - case Routes.accountAdd: - page = AccountAddScreen( - launchedFromWelcome: (arguments == true), - ); - break; - case Routes.accountServerDetails: - page = AccountServerDetailsScreen(account: arguments as RealAccount); - break; - case Routes.accountEdit: - page = AccountEditScreen(account: arguments as RealAccount); - break; - case Routes.settings: - page = const SettingsScreen(); - break; - case Routes.settingsSecurity: - page = const SettingsSecurityScreen(); - break; - case Routes.settingsAccounts: - page = const SettingsAccountsScreen(); - break; - case Routes.settingsDesign: - page = const SettingsThemeScreen(); - break; - case Routes.settingsFeedback: - page = const SettingsFeedbackScreen(); - break; - case Routes.settingsLanguage: - page = const SettingsLanguageScreen(); - break; - case Routes.settingsFolders: - page = const SettingsFoldersScreen(); - break; - case Routes.settingsReadReceipts: - page = const SettingsReadReceiptsScreen(); - break; - case Routes.settingsDevelopment: - page = const SettingsDeveloperModeScreen(); - break; - case Routes.settingsSwipe: - page = const SettingsSwipeScreen(); - break; - case Routes.settingsSignature: - page = const SettingsSignatureScreen(); - break; - case Routes.settingsDefaultSender: - page = const SettingsDefaultSenderScreen(); - break; - case Routes.settingsReplyFormat: - page = const SettingsReplyScreen(); - break; - case Routes.messageSourceFuture: - page = AsyncMessageSourceScreen( - messageSourceFuture: arguments as Future); - break; - case Routes.messageSource: - page = MessageSourceScreen(messageSource: arguments as MessageSource); - break; - case Routes.mailDetails: - if (arguments is Message) { - page = MessageDetailsScreen(message: arguments); - } else if (arguments is DisplayMessageArguments) { - page = MessageDetailsScreen( - message: arguments.message, - blockExternalContents: arguments.blockExternalContent, - ); - } else { - page = const WelcomeScreen(); - } - break; - case Routes.mailContents: - page = MessageContentsScreen(message: arguments as Message); - break; - case Routes.mailCompose: - page = ComposeScreen(data: arguments as ComposeData); - break; - case Routes.interactiveMedia: - page = InteractiveMediaScreen( - mediaWidget: arguments as InteractiveMediaWidget); - break; - case Routes.locationPicker: - page = const LocationScreen(); - break; - case Routes.splash: - page = const SplashScreen(); - break; - case Routes.welcome: - page = const WelcomeScreen(); - break; - case Routes.sourceCode: - page = SourceCodeScreen(mimeMessage: arguments as MimeMessage); - break; - case Routes.webview: - page = WebViewScreen(configuration: arguments as WebViewConfiguration); - break; - case Routes.appDrawer: - page = const AppDrawer(); - break; - case Routes.lockScreen: - page = const LockScreen(); - break; - default: - if (kDebugMode) { - print('Unknown route: $name'); - } - page = Scaffold( - 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/routes/provider.dart b/lib/routes/provider.dart new file mode 100644 index 0000000..7dc442c --- /dev/null +++ b/lib/routes/provider.dart @@ -0,0 +1,354 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_media/enough_media.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../account/model.dart'; +import '../main.dart'; +import '../models/compose_data.dart'; +import '../models/message.dart'; +import '../models/message_source.dart'; +import '../models/web_view_configuration.dart'; +import '../notification/model.dart'; +import '../screens/screens.dart'; +import '../settings/view/view.dart'; +import '../widgets/app_drawer.dart'; +import 'routes.dart'; + +part 'provider.g.dart'; + +/// Provides the [GoRouter] configuration +@Riverpod(keepAlive: true) +RouterConfig routerConfig(RouterConfigRef ref) => GoRouter( + navigatorKey: Routes.navigatorKey, + // redirect: (context, state) { + // logger.d('redirect for ${state.uri}'); + + // return null; + // }, + routes: [ + if (useAppDrawerAsRoot) ...[ + _rootRoute, + _appDrawerRoute, + _lockRoute, + _welcomeRoute, + ] else ...[ + _rootRoute, + _accountAddRoute, + _welcomeRoute, + _mailRoute, + _mailDetailsRoute, + _mailDetailsForNotificationRoute, + _mailContentsRoute, + _sourceCodeRoute, + _mailComposeRoute, + _interactiveMediaRoute, + _settingsRoute, + _webviewRoute, + _lockRoute, + ], + ], + ); + +String _path(String routeName) => + useAppDrawerAsRoot ? routeName : '/$routeName'; + +GoRoute get _rootRoute => GoRoute( + path: Routes.root, + builder: (context, state) => const InitializationScreen(), + ); + +GoRoute get _appDrawerRoute => GoRoute( + name: Routes.appDrawer, + path: Routes.appDrawer, + builder: (context, state) => const AppDrawer(), + routes: [ + _accountAddRoute, + _mailRoute, + _mailForAccountRoute, + _mailDetailsRoute, + _mailDetailsForNotificationRoute, + _mailContentsRoute, + _sourceCodeRoute, + _mailComposeRoute, + _interactiveMediaRoute, + _settingsRoute, + _webviewRoute, + ], + ); + +GoRoute get _accountAddRoute => GoRoute( + name: Routes.accountAdd, + path: _path(Routes.accountAdd), + builder: (context, state) => const AccountAddScreen(), + ); +GoRoute get _welcomeRoute => GoRoute( + name: Routes.welcome, + path: Routes.welcome, + builder: (context, state) => const WelcomeScreen(), + ); + +GoRoute get _mailRoute => GoRoute( + name: Routes.mail, + path: _path(Routes.mail), + builder: (context, state) => const MailScreenForDefaultAccount(), + routes: [ + if (!useAppDrawerAsRoot) _mailForAccountRoute, + ], + ); + +GoRoute get _mailForAccountRoute => GoRoute( + name: Routes.mailForAccount, + path: '${Routes.mailForAccount}/:${Routes.pathParameterEmail}', + builder: (context, state) { + final email = state.pathParameters[Routes.pathParameterEmail] ?? ''; + + return EMailScreen(key: ValueKey(email), email: email); + }, + routes: [ + GoRoute( + name: Routes.mailForMailbox, + path: '${Routes.mailForMailbox}/' + ':${Routes.pathParameterEncodedMailboxPath}', + builder: (context, state) { + final email = state.pathParameters[Routes.pathParameterEmail] ?? ''; + final encodedMailboxPath = + state.pathParameters[Routes.pathParameterEncodedMailboxPath] ?? + ''; + + return EMailScreen( + key: ValueKey('$email/$encodedMailboxPath'), + email: email, + encodedMailboxPath: encodedMailboxPath, + ); + }, + ), + GoRoute( + name: Routes.messageSource, + path: Routes.messageSource, + builder: (context, state) { + final extra = state.extra; + + return extra is MessageSource + ? MessageSourceScreen(messageSource: extra) + : const MailScreenForDefaultAccount(); + }, + ), + GoRoute( + name: Routes.mailSearch, + path: Routes.mailSearch, + builder: (context, state) { + final extra = state.extra; + + return extra is MailSearch + ? MailSearchScreen(search: extra) + : const MailScreenForDefaultAccount(); + }, + ), + GoRoute( + name: Routes.accountEdit, + path: Routes.accountEdit, + builder: (context, state) => AccountEditScreen( + accountEmail: state.pathParameters[Routes.pathParameterEmail] ?? '', + ), + ), + GoRoute( + name: Routes.accountServerDetails, + path: Routes.accountServerDetails, + builder: (context, state) { + final email = state.pathParameters[Routes.pathParameterEmail]; + if (email != null) { + return AccountServerDetailsScreen( + accountEmail: email, + ); + } + final account = state.extra; + if (account is RealAccount) { + return AccountServerDetailsScreen( + account: account, + ); + } + + return const MailScreenForDefaultAccount(); + }, + ), + ], + ); + +GoRoute get _mailComposeRoute => GoRoute( + name: Routes.mailCompose, + path: _path(Routes.mailCompose), + builder: (context, state) { + final data = state.extra; + + return data is ComposeData + ? ComposeScreen(data: data) + : const MailScreenForDefaultAccount(); + }, + routes: [ + GoRoute( + name: Routes.locationPicker, + path: Routes.locationPicker, + builder: (context, state) => const LocationScreen(), + ), + ], + ); + +GoRoute get _mailDetailsRoute => GoRoute( + name: Routes.mailDetails, + path: _path(Routes.mailDetails), + builder: (context, state) { + final extra = state.extra; + final blockExternalContent = state.uri + .queryParameters[Routes.queryParameterBlockExternalContent] == + 'true'; + + return extra is Message + ? MessageDetailsScreen( + message: extra, + blockExternalContent: blockExternalContent, + ) + : const MailScreenForDefaultAccount(); + }, + ); +GoRoute get _mailDetailsForNotificationRoute => GoRoute( + name: Routes.mailDetailsForNotification, + path: _path(Routes.mailDetailsForNotification), + builder: (context, state) { + final extra = state.extra; + final blockExternalContent = state.uri + .queryParameters[Routes.queryParameterBlockExternalContent] == + 'true'; + + return extra is MailNotificationPayload + ? MessageDetailsForNotificationScreen( + payload: extra, + blockExternalContent: blockExternalContent, + ) + : const MailScreenForDefaultAccount(); + }, + ); + +GoRoute get _mailContentsRoute => GoRoute( + name: Routes.mailContents, + path: _path(Routes.mailContents), + builder: (context, state) { + final extra = state.extra; + + return extra is Message + ? MessageContentsScreen( + message: extra, + ) + : const MailScreenForDefaultAccount(); + }, + ); +GoRoute get _sourceCodeRoute => GoRoute( + name: Routes.sourceCode, + path: _path(Routes.sourceCode), + builder: (context, state) { + final mimeMessage = state.extra; + + return mimeMessage is MimeMessage + ? SourceCodeScreen(mimeMessage: mimeMessage) + : const MailScreenForDefaultAccount(); + }, + ); + +GoRoute get _interactiveMediaRoute => GoRoute( + name: Routes.interactiveMedia, + path: _path(Routes.interactiveMedia), + builder: (context, state) { + final widget = state.extra; + + return widget is InteractiveMediaWidget + ? InteractiveMediaScreen(mediaWidget: widget) + : const MailScreenForDefaultAccount(); + }, + ); + +GoRoute get _settingsRoute => GoRoute( + name: Routes.settings, + path: _path(Routes.settings), + builder: (context, state) => const SettingsScreen(), + routes: [ + GoRoute( + name: Routes.settingsAccounts, + path: Routes.settingsAccounts, + builder: (context, state) => const SettingsAccountsScreen(), + ), + GoRoute( + name: Routes.settingsDefaultSender, + path: Routes.settingsDefaultSender, + builder: (context, state) => const SettingsDefaultSenderScreen(), + ), + GoRoute( + name: Routes.settingsDesign, + path: Routes.settingsDesign, + builder: (context, state) => const SettingsDesignScreen(), + ), + GoRoute( + name: Routes.settingsDevelopment, + path: Routes.settingsDevelopment, + builder: (context, state) => const SettingsDeveloperModeScreen(), + ), + GoRoute( + name: Routes.settingsFeedback, + path: Routes.settingsFeedback, + builder: (context, state) => const SettingsFeedbackScreen(), + ), + GoRoute( + name: Routes.settingsFolders, + path: Routes.settingsFolders, + builder: (context, state) => const SettingsFoldersScreen(), + ), + GoRoute( + name: Routes.settingsLanguage, + path: Routes.settingsLanguage, + builder: (context, state) => const SettingsLanguageScreen(), + ), + GoRoute( + name: Routes.settingsReadReceipts, + path: Routes.settingsReadReceipts, + builder: (context, state) => const SettingsReadReceiptsScreen(), + ), + GoRoute( + name: Routes.settingsReplyFormat, + path: Routes.settingsReplyFormat, + builder: (context, state) => const SettingsReplyScreen(), + ), + GoRoute( + name: Routes.settingsSecurity, + path: Routes.settingsSecurity, + builder: (context, state) => const SettingsSecurityScreen(), + ), + GoRoute( + name: Routes.settingsSignature, + path: Routes.settingsSignature, + builder: (context, state) => const SettingsSignatureScreen(), + ), + GoRoute( + name: Routes.settingsSwipe, + path: Routes.settingsSwipe, + builder: (context, state) => const SettingsSwipeScreen(), + ), + ], + ); + +GoRoute get _lockRoute => GoRoute( + name: Routes.lockScreen, + path: Routes.lockScreen, + builder: (context, state) => const LockScreen(), + ); + +GoRoute _webviewRoute = GoRoute( + name: Routes.webview, + path: _path(Routes.webview), + builder: (context, state) { + final configuration = state.extra; + + return configuration is WebViewConfiguration + ? WebViewScreen(configuration: configuration) + : const MailScreenForDefaultAccount(); + }, +); diff --git a/lib/routes/provider.g.dart b/lib/routes/provider.g.dart new file mode 100644 index 0000000..d883db9 --- /dev/null +++ b/lib/routes/provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$routerConfigHash() => r'b3479cc7eaa3b65781bb64af17381aeed32ac0a4'; + +/// Provides the [GoRouter] configuration +/// +/// Copied from [routerConfig]. +@ProviderFor(routerConfig) +final routerConfigProvider = Provider>.internal( + routerConfig, + name: r'routerConfigProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$routerConfigHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef RouterConfigRef = ProviderRef>; +// 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/routes/routes.dart b/lib/routes/routes.dart new file mode 100644 index 0000000..a4b4f79 --- /dev/null +++ b/lib/routes/routes.dart @@ -0,0 +1,182 @@ +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:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../account/model.dart'; +import '../models/models.dart'; +import '../notification/model.dart'; + +/// Should the app drawer be used as root? +/// +/// This is `true` on Cupertino platforms. +final useAppDrawerAsRoot = PlatformInfo.isCupertino; + +/// Defines app navigation routes +class Routes { + Routes._(); + + /// The root route + static const String root = '/'; + + /// Displays the welcome screen + static const String welcome = '/welcome'; + + /// Creates a new account + static const String accountAdd = 'accountAdd'; + + /// Allows to edit a single account + /// + /// pathParameters: [pathParameterEmail] + static const String accountEdit = 'edit'; + + /// Allows to edit a the account server settings + /// + /// pathParameters: [pathParameterEmail] or + /// + /// extra: [RealAccount] + /// + static const String accountServerDetails = 'serverDetails'; + + /// Displays inbox messages of the default account + /// + static const String mail = 'mail'; + + /// Displays inbox messages of the given account + /// + /// pathParameters: [pathParameterEmail] + /// + static const String mailForAccount = 'account'; + + /// Displays messages of the given account and mailbox + /// + /// pathParameters: [pathParameterEmail] and [pathParameterEncodedMailboxPath] + /// + static const String mailForMailbox = 'box'; + + /// Displays the settings + static const String settings = 'settings'; + + /// Displays security settings + static const String settingsSecurity = 'security'; + + /// Displays the settings for all accounts + static const String settingsAccounts = 'accounts'; + + /// Displays theme settings + static const String settingsDesign = 'design'; + + /// Displays feedback options + static const String settingsFeedback = 'feedback'; + + /// Displays language settings + static const String settingsLanguage = 'language'; + + /// Displays folder naming settings + static const String settingsFolders = 'folders'; + + /// Displays read receipts settings + static const String settingsReadReceipts = 'readReceipts'; + + /// Displays developer settings + static const String settingsDevelopment = 'developerMode'; + + /// Displays swipe settings + static const String settingsSwipe = 'swipe'; + + /// Displays signature settings + static const String settingsSignature = 'signature'; + + /// Displays default sender settings + static const String settingsDefaultSender = 'defaultSender'; + + /// Displays reply settings + static const String settingsReplyFormat = 'replyFormat'; + + /// Displays a message source directly + /// + /// extra: [MessageSource] + /// + static const String messageSource = 'messageSource'; + + /// 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 = 'mailNotification'; + + /// Shows all message contents + /// + /// extra: [Message] + /// + static const String mailContents = 'mailContents'; + + /// Composes a new message + /// + /// extra: [ComposeData] + /// + static const String mailCompose = 'mailCompose'; + + /// Allows to pick a location + /// + /// Pops the [Uint8List] after selecting a location + /// + static const String locationPicker = 'locationPicker'; + + /// Displays interactive media + /// + /// extra: [InteractiveMediaWidget] + /// + static const String interactiveMedia = 'interactiveMedia'; + + /// 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'; + + /// Displays the account and mailbox switcher on a separate screen. + /// + /// This is only applicable on iOS. + static const String appDrawer = '/appDrawer'; + + /// Displays the lock screen + static const String lockScreen = '/lock'; + + /// Path parameter name for an email address + static const String pathParameterEmail = 'email'; + + /// Query parameter name for an encoded mailbox path + static const String pathParameterEncodedMailboxPath = '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(); +} diff --git a/lib/scaffold_messenger/service.dart b/lib/scaffold_messenger/service.dart new file mode 100644 index 0000000..0d707a3 --- /dev/null +++ b/lib/scaffold_messenger/service.dart @@ -0,0 +1,71 @@ +import 'package:enough_platform_widgets/enough_platform_widgets.dart'; +import 'package:flutter/material.dart'; + +import '../localization/app_localizations.g.dart'; +import '../widgets/cupertino_status_bar.dart'; + +/// Allows to show snack bars +class ScaffoldMessengerService { + /// Creates a new [ScaffoldMessengerService] + ScaffoldMessengerService._(); + + static final _instance = ScaffoldMessengerService._(); + + /// The instance of the [ScaffoldMessengerService] + static ScaffoldMessengerService get instance => _instance; + + /// The key of the scaffold messenger + final GlobalKey scaffoldMessengerKey = + GlobalKey(); + + final _statusBarStates = []; + CupertinoStatusBarState? _statusBarState; + set statusBarState(CupertinoStatusBarState state) { + final current = _statusBarState; + if (current != null) { + _statusBarStates.add(current); + } + _statusBarState = state; + } + + void popStatusBarState() { + _statusBarState = + _statusBarStates.isNotEmpty ? _statusBarStates.removeLast() : null; + } + + 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( + AppLocalizations localizations, + String text, { + Function()? undo, + }) { + if (PlatformInfo.isCupertino) { + final state = _statusBarState; + if (state != null) { + state.showTextStatus(text, undo: undo); + } else { + _showSnackBar(_buildTextSnackBar(localizations, text, undo: undo)); + } + } else { + _showSnackBar(_buildTextSnackBar(localizations, text, undo: undo)); + } + } +} diff --git a/lib/screens/account_add_screen.dart b/lib/screens/account_add_screen.dart index eae8471..ecff85f 100644 --- a/lib/screens/account_add_screen.dart +++ b/lib/screens/account_add_screen.dart @@ -1,41 +1,41 @@ -import 'dart:io'; +import 'dart:async'; 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:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; -import '../l10n/app_localizations.g.dart'; - -class AccountAddScreen extends StatefulWidget { - final bool launchedFromWelcome; +import '../account/model.dart'; +import '../account/provider.dart'; +import '../extensions/extensions.dart'; +import '../hoster/service.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../logger.dart'; +import '../mail/provider.dart'; +import '../oauth/oauth.dart'; +import '../routes/routes.dart'; +import '../util/modal_bottom_sheet_helper.dart'; +import '../util/validator.dart'; +import '../widgets/account_hoster_selector.dart'; +import '../widgets/password_field.dart'; +import 'base.dart'; +/// Adds a new account +class AccountAddScreen extends ConsumerStatefulWidget { + /// Creates a new [AccountAddScreen] const AccountAddScreen({ - Key? key, - required this.launchedFromWelcome, - }) : super(key: key); + super.key, + }); @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; @@ -45,14 +45,15 @@ class _AccountAddScreenState extends State { int _progressedSteps = 0; bool _isContinueAvailable = false; bool? _isApplicationSpecificPasswordAcknowledged = false; - final TextEditingController _emailController = TextEditingController(); - final TextEditingController _passwordController = TextEditingController(); - final TextEditingController _accountNameController = TextEditingController(); - final TextEditingController _userNameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _accountNameController = TextEditingController(); + final _userNameController = TextEditingController(); + final _accountNameNode = FocusNode(); bool _isProviderResolving = false; - Provider? _provider; - final bool _isManualSettings = false; + MailHoster? _mailHoster; + final _isManualSettings = false; bool _isAccountVerifying = false; bool _isAccountVerified = false; MailClient? _mailClient; @@ -61,53 +62,54 @@ class _AccountAddScreenState extends State { RealAccount? _realAccount; Future _navigateToManualSettings( - BuildContext context, AppLocalizations localizations) async { - Provider? selectedProvider; - final result = await ModelBottomSheetHelper.showModalBottomSheet( + BuildContext context, + AppLocalizations localizations, + ) async { + final selectedHoster = + await ModelBottomSheetHelper.showModalBottomSheet( context, localizations.accountProviderStepTitle, - AccountProviderSelector( - onSelected: (provider) { - selectedProvider = provider; - Navigator.of(context).pop(true); + MailHosterSelector( + onSelected: (hoster) { + context.pop(hoster); }, ), useScrollView: false, appBarActions: [], ); - if (!result) { - return; - } - - if (selectedProvider != null) { - // a standard provider has been chosen, now query the password or start the oauth process: + if (selectedHoster != null) { + // a standard hoster has been chosen, + // now query the password or start the oauth process: setState(() { - _provider = selectedProvider; + _mailHoster = selectedHoster; }); - _onProviderChanged(selectedProvider!, _emailController.text); + _onMailHosterChanged(selectedHoster, _emailController.text); } else { final account = MailAccount( - email: _emailController.text, - name: _userNameController.text, - incoming: MailServerConfig( - authentication: const PlainAuthentication('', ''), - serverConfig: ServerConfig(), + email: _emailController.text.trim(), + name: _userNameController.text.trim(), + incoming: const MailServerConfig( + authentication: PlainAuthentication('', ''), + serverConfig: ServerConfig.empty(), ), - outgoing: MailServerConfig( - authentication: const PlainAuthentication('', ''), - serverConfig: ServerConfig(), + outgoing: const MailServerConfig( + authentication: PlainAuthentication('', ''), + serverConfig: ServerConfig.empty(), ), ); - - 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; + }); + } } } } @@ -115,35 +117,38 @@ 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(); } + @override + void dispose() { + _accountNameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _userNameController.dispose(); + _accountNameNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { // print('build: current step=$_currentStep'); final localizations = context.text; - return Base.buildAppChrome( - context, + + return BasePage( title: localizations.addAccountTitle, content: Column( children: [ Expanded( child: PlatformStepper( - type: StepperType.vertical, onStepContinue: _isContinueAvailable ? () async { - var step = _currentStep + 1; - if (step < _availableSteps) { - setState(() { - _currentStep = step; - _isContinueAvailable = false; - }); - } - _onStepProgressed(step); + final step = _currentStep + 1; + await _onStepProgressed(step); } : null, onStepCancel: () => Navigator.pop(context), @@ -162,22 +167,31 @@ class _AccountAddScreenState extends State { _buildAccountSetupStep(context, localizations), ], ), - ) + ), ], ), ); } Future _onStepProgressed(int step) async { + if (step < _availableSteps) { + setState(() { + _currentStep = step; + _isContinueAvailable = false; + }); + } _progressedSteps = step; - switch (step) { - case _stepEmail + 1: + switch (step - 1) { + case _stepEmail: await _discover(_emailController.text); break; - case _stepPassword + 1: - await _verifyAccount(); + case _stepPassword: + final mailHoster = _mailHoster; + if (mailHoster != null) { + await _verifyAccount(mailHoster); + } break; - case _stepAccountSetup + 1: + case _stepAccountSetup: await _finalizeAccount(); break; } @@ -193,7 +207,7 @@ class _AccountAddScreenState extends State { if (kDebugMode) { print('discover settings for $email'); } - final provider = await locator().discover(email); + final provider = await MailHosterService.instance.discover(email); if (!mounted) { // ignore if user has cancelled operation return; @@ -208,24 +222,28 @@ class _AccountAddScreenState extends State { final domainName = email.substring(email.lastIndexOf('@') + 1); _accountNameController.text = domainName; if (provider != null) { - _onProviderChanged(provider, email); + _onMailHosterChanged(provider, email); } setState(() { _isProviderResolving = false; - _provider = provider; + _mailHoster = provider; _isContinueAvailable = (provider != null) && _passwordController.text.isNotEmpty; }); } - Future _loginWithOAuth(Provider provider, String email) async { + Future _loginWithOAuth( + MailHoster mailHoster, + OauthClient oauthClient, + String email, + ) async { setState(() { _isAccountVerifying = true; _currentStep = _stepAccountSetup; _progressedSteps = _stepAccountSetup; }); - final token = await provider.oauthClient!.authenticate(email); + final token = await oauthClient.authenticate(email); // when the user either has cancelled the verification, // not granted the scope or the verification failed for other reasons, @@ -238,19 +256,34 @@ 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), - config: provider.clientConfig, + config: mailHoster.clientConfig, + ); + final connectedAccount = await ref.read( + firstTimeMailClientSourceProvider( + account: RealAccount(mailAccount), + ).future, ); - final connectedAccount = - await locator().connectFirstTime(mailAccount); _mailClient = connectedAccount?.mailClient; final isVerified = _mailClient?.isConnected ?? false; - if (isVerified) { - final extensions = await AppExtension.loadFor(mailAccount); - _realAccount = RealAccount(mailAccount, appExtensions: extensions); + if (connectedAccount != null && isVerified) { + if (mailHoster is GmailMailHoster || mailHoster is OutlookMailHoster) { + _realAccount = connectedAccount; + } else { + try { + final extensions = await AppExtension.loadFor(mailAccount); + _realAccount = connectedAccount.copyWith(appExtensions: extensions); + } catch (e, s) { + logger.e( + 'Unable to load app extensions for ${mailAccount.email}: $e', + error: e, + stackTrace: s, + ); + } + } } else { FocusManager.instance.primaryFocus?.unfocus(); } @@ -258,38 +291,42 @@ class _AccountAddScreenState extends State { _isAccountVerifying = false; _isAccountVerified = isVerified; _isContinueAvailable = - isVerified && _userNameController.text.isNotEmpty; + isVerified && _userNameController.text.trim().isNotEmpty; }); } } - Future _verifyAccount() async { + Future _verifyAccount(MailHoster mailHoster) async { // password and possibly manual account details have been specified setState(() { _isAccountVerifying = true; }); - var mailAccount = MailAccount.fromDiscoveredSettings( + final mailAccount = MailAccount.fromDiscoveredSettings( name: _emailController.text, userName: _emailController.text, email: _emailController.text, password: _passwordController.text, - config: _provider!.clientConfig, + config: mailHoster.clientConfig, + ); + final connectedAccount = await ref.read( + firstTimeMailClientSourceProvider( + account: RealAccount(mailAccount), + ).future, ); - final connectedAccount = - await locator().connectFirstTime(mailAccount); _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(); } setState(() { _isAccountVerifying = false; _isAccountVerified = isVerified; - _isContinueAvailable = isVerified && _userNameController.text.isNotEmpty; + _isContinueAvailable = + isVerified && _userNameController.text.trim().isNotEmpty; }); } @@ -300,86 +337,97 @@ 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, context); - if (added) { - if (Platform.isIOS && widget.launchedFromWelcome) { - locator().push(Routes.appDrawer, clear: true); - } - locator().push( - Routes.messageSource, - arguments: service.messageSource, - clear: !Platform.isIOS && widget.launchedFromWelcome, - replace: !widget.launchedFromWelcome, - fade: true, + account + ..name = _accountNameController.text.trim() + ..userName = _userNameController.text.trim(); + ref.read(realAccountsProvider.notifier).addAccount(account); + + if (PlatformInfo.isCupertino) { + context.goNamed(Routes.appDrawer); + unawaited( + context.pushNamed( + Routes.mailForAccount, + pathParameters: {Routes.pathParameterEmail: account.key}, + ), + ); + } else { + context.pushReplacementNamed( + Routes.mailForAccount, + pathParameters: {Routes.pathParameterEmail: account.key}, ); } } - 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, + onSubmitted: (value) { + if (_isContinueAvailable) { + _onStepProgressed(1); + } + }, ), - autofocus: true, - ), - ], - ), - //state: StepState.editing, - isActive: true, - ); - } + ], + ), + //state: StepState.editing, + isActive: true, + ); Step _buildPasswordStep( - BuildContext context, AppLocalizations localizations) { - final provider = _provider; - final appSpecificPasswordSetupUrl = provider?.appSpecificPasswordSetupUrl; + BuildContext context, + AppLocalizations localizations, + ) { + final mailHoster = _mailHoster; + final oauthClient = mailHoster?.oauthClient; + final appSpecificPasswordSetupUrl = mailHoster?.appSpecificPasswordSetupUrl; + final extensionForgotPassword = _extensionForgotPassword; + return Step( title: Text(localizations.addAccountPasswordLabel), //state: StepState.complete, isActive: _currentStep >= 1, content: Column( - mainAxisSize: MainAxisSize.max, children: [ if (_isProviderResolving) Row( @@ -391,112 +439,21 @@ class _AccountAddScreenState extends State { Expanded( child: Text( localizations.addAccountResolvingSettingsLabel( - _emailController.text), - ), - ), - ], - ) - else if (provider != null) - Column( - children: [ - if (provider.hasOAuthClient) ...[ - // The user can continue to sign in with the provider or by using an app-specific password - Text( - localizations.addAccountOauthOptionsText( - provider.displayName ?? ''), - ), - FittedBox( - child: provider.buildSignInButton( - context, - onPressed: () => - _loginWithOAuth(provider, _emailController.text), - isSignInButton: true, - ), - ), - if (appSpecificPasswordSetupUrl != null) ...[ - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - localizations.addAccountOauthSignInWithAppPassword), - ), - PlatformTextButton( - onPressed: () async { - await launcher - .launchUrl(Uri.parse(appSpecificPasswordSetupUrl)); - }, - child: ButtonText(localizations - .addAccountApplicationPasswordRequiredButton), - ), - PlatformCheckboxListTile( - onChanged: (value) => setState(() => - _isApplicationSpecificPasswordAcknowledged = value), - value: _isApplicationSpecificPasswordAcknowledged, - title: Text(localizations - .addAccountApplicationPasswordRequiredAcknowledged), + _emailController.text, ), - ], - ] else if (provider.appSpecificPasswordSetupUrl != null) ...[ - Text(localizations.addAccountApplicationPasswordRequiredInfo), - PlatformTextButton( - onPressed: () async { - await launcher.launchUrl( - Uri.parse(provider.appSpecificPasswordSetupUrl!)); - }, - child: ButtonText(localizations - .addAccountApplicationPasswordRequiredButton), - ), - PlatformCheckboxListTile( - onChanged: (value) => setState(() => - _isApplicationSpecificPasswordAcknowledged = value), - value: _isApplicationSpecificPasswordAcknowledged, - title: Text(localizations - .addAccountApplicationPasswordRequiredAcknowledged), - ), - ], - if (provider.appSpecificPasswordSetupUrl == null || - _isApplicationSpecificPasswordAcknowledged!) - PasswordField( - controller: _passwordController, - cupertinoShowLabel: false, - onChanged: (value) { - bool isValid = value.isNotEmpty && - (_provider?.clientConfig != null || - _isManualSettings); - if (isValid != _isContinueAvailable) { - setState(() { - _isContinueAvailable = isValid; - }); - } - }, - autofocus: true, - labelText: localizations.addAccountPasswordLabel, - hintText: localizations.addAccountPasswordHint, - ), - PlatformTextButton( - onPressed: () => - _navigateToManualSettings(context, localizations), - child: ButtonText( - localizations.addAccountResolvedSettingsWrongAction( - _provider?.displayName ?? ''), ), ), - 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); - launcher.launchUrl(Uri.parse(url)); - }, - child: ButtonText(_extensionForgotPassword! - .getLabel(locator().locale!.languageCode)), - ), - ], ], ) + else if (mailHoster != null) + _buildPasswordStepWithMailHoster( + oauthClient, + localizations, + mailHoster, + context, + appSpecificPasswordSetupUrl, + extensionForgotPassword, + ) else Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -507,10 +464,10 @@ class _AccountAddScreenState extends State { ), ), PlatformElevatedButton( - child: ButtonText(localizations.addAccountEditManuallyAction), + child: Text(localizations.addAccountEditManuallyAction), onPressed: () => _navigateToManualSettings(context, localizations), - ) + ), ], ), ], @@ -518,112 +475,276 @@ class _AccountAddScreenState extends State { ); } - Step _buildAccountSetupStep( - BuildContext context, + Column _buildPasswordStepWithMailHoster( + OauthClient? oauthClient, AppLocalizations localizations, - ) { - return Step( - title: Text(_isAccountVerified - ? localizations.addAccountSetupAccountStep - : localizations.addAccountVerificationStep), - content: Column( - mainAxisSize: MainAxisSize.max, + MailHoster mailHoster, + BuildContext context, + String? appSpecificPasswordSetupUrl, + AppExtensionActionDescription? extensionForgotPassword, + ) => + Column( children: [ - if (_isAccountVerifying) - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - child: const PlatformProgressIndicator(), - ), - Expanded( - child: Text( - localizations.addAccountVerifyingSettingsLabel( - _emailController.text, - ), - ), - ), - ], + if (oauthClient != null) + ..._buildOauthAuthenticationSection( + localizations, + mailHoster, + context, + oauthClient, + appSpecificPasswordSetupUrl, ) - else if (_isAccountVerified) ...[ - Text( - localizations.addAccountVerifyingSuccessInfo( - _emailController.text, - ), - ), - DecoratedPlatformTextField( - controller: _userNameController, - keyboardType: TextInputType.text, - textCapitalization: TextCapitalization.words, + else if (mailHoster.appSpecificPasswordSetupUrl != null) + ..._buildAppSpecificPasswordSection(localizations, mailHoster), + if (mailHoster.appSpecificPasswordSetupUrl == null || + (_isApplicationSpecificPasswordAcknowledged ?? false)) + PasswordField( + controller: _passwordController, + cupertinoShowLabel: false, onChanged: (value) { - bool isValid = - value.isNotEmpty && _accountNameController.text.isNotEmpty; + final bool isValid = value.isNotEmpty && + (_mailHoster?.clientConfig != null || _isManualSettings); 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; - }); + labelText: localizations.addAccountPasswordLabel, + hintText: localizations.addAccountPasswordHint, + onSubmitted: (value) { + if (_isContinueAvailable) { + _onStepProgressed(2); } }, - decoration: InputDecoration( - labelText: localizations.addAccountNameOfAccountLabel, - hintText: localizations.addAccountNameOfAccountHint, - icon: const Icon(Icons.email), - ), - cupertinoAlignLabelOnTop: true, ), - ] else ...[ - Text( - localizations.addAccountVerifyingFailedInfo( - _emailController.text, + PlatformTextButton( + onPressed: () => _navigateToManualSettings(context, localizations), + child: Text( + localizations.addAccountResolvedSettingsWrongAction( + _mailHoster?.displayName ?? '', ), ), - if (_provider?.manualImapAccessSetupUrl != null) ...[ - Padding( - padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), - child: Text( - localizations.accountAddImapAccessSetupMightBeRequired, - ), - ), - PlatformTextButton( - child: ButtonText( - localizations.addAccountSetupImapAccessButtonLabel, - ), - onPressed: () => launcher.launchUrl( - Uri.parse(_provider!.manualImapAccessSetupUrl!), - ), + ), + if (extensionForgotPassword != null) + PlatformTextButton( + onPressed: () { + final languageCode = localizations.localeName; + final url = (extensionForgotPassword.action?.url ?? '') + .replaceAll('{user.email}', _emailController.text) + .replaceAll('{language}', languageCode); + launcher.launchUrl(Uri.parse(url)); + }, + child: Text( + extensionForgotPassword.getLabel(localizations.localeName) ?? + '', ), - ], + ), + ], + ); + + List _buildAppSpecificPasswordSection( + AppLocalizations localizations, + MailHoster mailHoster, + ) => + [ + Text(localizations.addAccountApplicationPasswordRequiredInfo), + PlatformTextButton( + onPressed: () async { + await launcher.launchUrl( + Uri.parse(mailHoster.appSpecificPasswordSetupUrl ?? ''), + ); + }, + child: Text( + localizations.addAccountApplicationPasswordRequiredButton, + ), + ), + PlatformCheckboxListTile( + onChanged: (value) => setState( + () => _isApplicationSpecificPasswordAcknowledged = value, + ), + value: _isApplicationSpecificPasswordAcknowledged, + title: Text( + localizations.addAccountApplicationPasswordRequiredAcknowledged, + ), + ), + ]; + + List _buildOauthAuthenticationSection( + AppLocalizations localizations, + MailHoster mailHoster, + BuildContext context, + OauthClient oauthClient, + String? appSpecificPasswordSetupUrl, + ) => + [ + // The user can continue to sign in with the provider + // or by using an app-specific password + Text( + localizations.addAccountOauthOptionsText( + mailHoster.displayName ?? '', + ), + ), + FittedBox( + child: mailHoster.buildSignInButton( + context, + onPressed: () => _loginWithOAuth( + mailHoster, + oauthClient, + _emailController.text, + ), + isSignInButton: true, + ), + ), + if (appSpecificPasswordSetupUrl != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + localizations.addAccountOauthSignInWithAppPassword, + ), + ), + PlatformTextButton( + onPressed: () async { + await launcher.launchUrl(Uri.parse(appSpecificPasswordSetupUrl)); + }, + child: Text( + localizations.addAccountApplicationPasswordRequiredButton, + ), + ), + PlatformCheckboxListTile( + onChanged: (value) => setState( + () => _isApplicationSpecificPasswordAcknowledged = value, + ), + value: _isApplicationSpecificPasswordAcknowledged, + title: Text( + localizations.addAccountApplicationPasswordRequiredAcknowledged, + ), + ), + ], + ]; + + Step _buildAccountSetupStep( + BuildContext context, + AppLocalizations localizations, + ) => + 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) + ..._buildAccountVerifiedSection(localizations) + else + ..._buildAccountVerificationFailedSection(localizations), ], + ), + ); + + List _buildAccountVerificationFailedSection( + AppLocalizations localizations, + ) => + [ + Text( + localizations.addAccountVerifyingFailedInfo( + _emailController.text, + ), + ), + if (_mailHoster?.manualImapAccessSetupUrl != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Text( + localizations.accountAddImapAccessSetupMightBeRequired, + ), + ), + PlatformTextButton( + child: Text( + localizations.addAccountSetupImapAccessButtonLabel, + ), + onPressed: () => launcher.launchUrl( + Uri.parse(_mailHoster?.manualImapAccessSetupUrl ?? ''), + ), + ), ], - ), - ); - } + ]; + + List _buildAccountVerifiedSection(AppLocalizations localizations) => [ + Text( + localizations.addAccountVerifyingSuccessInfo( + _emailController.text, + ), + ), + DecoratedPlatformTextField( + controller: _userNameController, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.words, + onChanged: (value) { + final bool isValid = value.trim().isNotEmpty && + _accountNameController.text.trim().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, + onSubmitted: (_) => _accountNameNode.requestFocus(), + ), + DecoratedPlatformTextField( + focusNode: _accountNameNode, + controller: _accountNameController, + keyboardType: TextInputType.text, + onChanged: (value) { + final bool isValid = + value.isNotEmpty && _userNameController.text.trim().isNotEmpty; + if (isValid != _isContinueAvailable) { + setState(() { + _isContinueAvailable = isValid; + }); + } + }, + decoration: InputDecoration( + labelText: localizations.addAccountNameOfAccountLabel, + hintText: localizations.addAccountNameOfAccountHint, + icon: const Icon(Icons.email), + ), + cupertinoAlignLabelOnTop: true, + onSubmitted: (_) { + if (_isContinueAvailable) { + _onStepProgressed(3); + } + }, + ), + ]; - void _onProviderChanged(Provider provider, String email) { + void _onMailHosterChanged(MailHoster provider, String email) { + final email = _emailController.text.trim(); final mailAccount = MailAccount.fromDiscoveredSettings( - name: _emailController.text, - email: _emailController.text, - userName: _emailController.text, + name: email, + email: email, + userName: email, password: _passwordController.text, config: provider.clientConfig, ); diff --git a/lib/screens/account_edit_screen.dart b/lib/screens/account_edit_screen.dart index 0fe660f..02ecba6 100644 --- a/lib/screens/account_edit_screen.dart +++ b/lib/screens/account_edit_screen.dart @@ -5,22 +5,22 @@ 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 '../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'; -import '../services/navigation_service.dart'; -import '../services/providers.dart'; -import '../services/scaffold_messenger_service.dart'; +import '../account/model.dart'; +import '../account/provider.dart'; +import '../hoster/service.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../logger.dart'; +import '../mail/provider.dart'; +import '../routes/routes.dart'; +import '../scaffold_messenger/service.dart'; import '../settings/provider.dart'; +import '../settings/theme/icon_service.dart'; import '../util/localized_dialog_helper.dart'; import '../util/validator.dart'; -import '../widgets/button_text.dart'; import '../widgets/password_field.dart'; import '../widgets/signature.dart'; import 'base.dart'; @@ -28,19 +28,25 @@ 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), + ); + if (account == null) { + return Center(child: PlatformCircularProgressIndicator()); + } + 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 iconService = IconService.instance; final enableDeveloperMode = ref.watch( settingsProvider.select((value) => value.enableDeveloperMode), @@ -48,6 +54,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), @@ -57,7 +66,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( @@ -78,11 +87,13 @@ class AccountEditScreen extends HookConsumerWidget { child: PlatformTextButtonIcon( onPressed: () => _reconnect( context, + ref, + account, account.mailAccount, isRetryingToConnectState, ), icon: Icon(iconService.retry), - label: PlatformText( + label: Text( localizations .editAccountFailureToConnectRetryAction, ), @@ -92,14 +103,16 @@ class AccountEditScreen extends HookConsumerWidget { child: PlatformTextButton( onPressed: () => _updateAuthentication( context, + ref, + account, isRetryingToConnectState, ), - child: PlatformText( + child: Text( localizations .editAccountFailureToConnectChangePasswordAction, ), ), - ) + ), ], ), const Divider(), @@ -112,7 +125,7 @@ class AccountEditScreen extends HookConsumerWidget { ), onChanged: (value) async { account.name = value; - await locator().saveAccounts(); + await saveAccounts(); }, ), DecoratedPlatformTextField( @@ -123,24 +136,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, - context, - ); + ref.invalidate(unifiedAccountProvider); + await saveAccounts(); }, title: Text( - localizations.editAccountIncludeInUnifiedLabel), + localizations.editAccountIncludeInUnifiedLabel, + ), ), const Divider(), Text( @@ -160,9 +170,10 @@ class AccountEditScreen extends HookConsumerWidget { if (account.hasNoAlias) Padding( padding: const EdgeInsets.all(8), - child: Text(localizations.editAccountNoAliasesInfo, - style: - const TextStyle(fontStyle: FontStyle.italic)), + child: Text( + localizations.editAccountNoAliasesInfo, + style: const TextStyle(fontStyle: FontStyle.italic), + ), ), for (final alias in account.aliases) @@ -172,11 +183,15 @@ class AccountEditScreen extends HookConsumerWidget { color: Colors.red, child: Icon(iconService.messageActionDelete), ), - onDismissed: (direction) async { - await account.removeAlias(alias); - locator().showTextSnackBar( - localizations - .editAccountAliasRemoved(alias.email)); + onDismissed: (direction) { + account.removeAlias(alias); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.editAccountAliasRemoved( + alias.email, + ), + ); + ref.read(realAccountsProvider.notifier).save(); }, child: PlatformListTile( title: Text(alias.toString()), @@ -217,8 +232,8 @@ class AccountEditScreen extends HookConsumerWidget { Text(localizations.editAccountPlusAliasesSupported), ), PlatformTextButton( - child: ButtonText( - localizations.editAccountCheckPlusAliasAction), + child: + Text(localizations.editAccountCheckPlusAliasAction), onPressed: () async { final result = await showPlatformDialog( context: context, @@ -226,12 +241,13 @@ class AccountEditScreen extends HookConsumerWidget { _PlusAliasTestingDialog(account: account), ); if (result != null) { - account.supportsPlusAliases = result; - locator() - .markAccountAsTestedForPlusAlias(account); - await locator().saveAccount( - account.mailAccount, - ); + account + ..supportsPlusAliases = result + ..setAttribute( + RealAccount.attributePlusAliasTested, + true, + ); + await saveAccounts(); } }, ), @@ -244,9 +260,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), ), @@ -264,12 +278,15 @@ 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), + label: + Text(localizations.editAccountServerSettingsAction), ), const Divider(), Padding( @@ -282,37 +299,37 @@ class AccountEditScreen extends HookConsumerWidget { Icons.delete, color: Colors.white, ), - label: ButtonText( + label: Text( localizations.editAccountDeleteAccountAction, - style: Theme.of(context) - .textTheme - .labelLarge - ?.copyWith(color: Colors.white), + style: + Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + ), ), onPressed: () async { final result = await LocalizedDialogHelper.askForConfirmation( - context, - title: localizations - .editAccountDeleteAccountConfirmationTitle, - query: localizations - .editAccountDeleteAccountConfirmationQuery( - accountNameController.text), - action: localizations.actionDelete, - isDangerousAction: true); + context, + title: localizations + .editAccountDeleteAccountConfirmationTitle, + query: localizations + .editAccountDeleteAccountConfirmationQuery( + accountNameController.text, + ), + action: localizations.actionDelete, + isDangerousAction: true, + ); if (result ?? false) { - final mailService = locator(); if (!context.mounted) { return; } - await mailService.removeAccount(account, context); - if (mailService.accounts.isEmpty) { - await locator().push( - Routes.welcome, - clear: true, - ); + ref + .read(realAccountsProvider.notifier) + .removeAccount(account); + if (ref.read(realAccountsProvider).isEmpty) { + context.goNamed(Routes.welcome); } else { - locator().pop(); + context.goNamed(Routes.mail); } } }, @@ -328,17 +345,17 @@ 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; - locator() - .showTextSnackBar(message); + ScaffoldMessengerService.instance + .showTextSnackBar(localizations, message); } }, ), ), - ] + ], ], ), ), @@ -355,10 +372,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, @@ -393,22 +412,23 @@ class AccountEditScreen extends HookConsumerWidget { ), ); } - final result = await _reconnect( - context, - updatedMailAccount, - isRetryingToConnectState, - ); - if (result) { - await mailService.saveAccounts(); + if (context.mounted) { + await _reconnect( + context, + ref, + account, + updatedMailAccount, + isRetryingToConnectState, + ); } } } else if (authentication is OauthAuthentication) { // oauth case: restart oauth authentication, // save new token - final provider = locator()[ - account.mailAccount.incoming.serverConfig.hostname ?? '']; - final oauthClient = provider?.oauthClient; - if (provider != null && oauthClient != null) { + final hoster = MailHosterService + .instance[account.mailAccount.incoming.serverConfig.hostname]; + final oauthClient = hoster?.oauthClient; + if (hoster != null && oauthClient != null) { final token = await oauthClient.authenticate(account.mailAccount.email); if (token != null) { final adaptedIncomingAuth = authentication.copyWith( @@ -430,12 +450,15 @@ class AccountEditScreen extends HookConsumerWidget { outgoing: mailAccount.outgoing .copyWith(authentication: adaptedOutgoingAuth), ); - await _reconnect( - context, - updatedMailAccount, - isRetryingToConnectState, - ); - await mailService.saveAccounts(); + if (context.mounted) { + await _reconnect( + context, + ref, + account, + updatedMailAccount, + isRetryingToConnectState, + ); + } } } } @@ -443,16 +466,31 @@ class AccountEditScreen extends HookConsumerWidget { Future _reconnect( BuildContext context, + WidgetRef ref, + RealAccount account, MailAccount mailAccount, ValueNotifier isRetryingToConnectState, ) async { isRetryingToConnectState.value = true; - final account = this.account.copyWith(mailAccount: mailAccount); - final mailService = locator(); - final result = await mailService.reconnect(account); - 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, @@ -460,8 +498,25 @@ class AccountEditScreen extends HookConsumerWidget { localizations.editAccountFailureToConnectFixedInfo, ); } + if (!account.excludeFromUnified) { + ref.invalidate(unifiedAccountProvider); + } + + return true; + } catch (e) { + logger.e('Unable to reconnect account: $e'); + isRetryingToConnectState.value = false; + if (context.mounted) { + final localizations = context.text; + await LocalizedDialogHelper.showTextDialog( + context, + localizations.errorTitle, + localizations.editAccountFailureToConnectInfo(account.name), + ); + } + + return false; } - return result; } } @@ -501,25 +556,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(); } @@ -536,7 +593,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()) && @@ -547,32 +605,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), @@ -580,14 +636,14 @@ class _PlusAliasTestingDialogState extends State<_PlusAliasTestingDialog> { content: SizedBox( width: double.maxFinite, child: PlatformStepper( - onStepCancel: _step == 3 ? null : () => Navigator.of(context).pop(), + onStepCancel: _step == 3 ? null : () => context.pop(), onStepContinue: !_isContinueAvailable ? null : () async { if (_step < _maxStep) { _step++; } else { - Navigator.of(context).pop( + context.pop( widget.account.supportsPlusAliases, ); } @@ -598,13 +654,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; @@ -613,10 +673,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, @@ -650,9 +713,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, @@ -663,10 +741,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; @@ -684,6 +762,7 @@ class _AliasEditDialogState extends State<_AliasEditDialog> { @override Widget build(BuildContext context) { final localizations = context.text; + return PlatformAlertDialog( title: Text(widget.isNewAlias ? localizations.editAccountAddAliasTitle @@ -693,12 +772,12 @@ class _AliasEditDialogState extends State<_AliasEditDialog> { : _buildContent(localizations), actions: [ PlatformTextButton( - child: ButtonText(localizations.actionCancel), - onPressed: () => Navigator.of(context).pop(), + child: Text(localizations.actionCancel), + onPressed: () => context.pop(), ), PlatformTextButton( onPressed: _isEmailValid - ? () async { + ? () { setState(() { _isSaving = true; }); @@ -706,15 +785,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: Text( + widget.isNewAlias + ? localizations.editAccountAliasAddAction + : localizations.editAccountAliasUpdateAction, + ), ), ], ); @@ -727,20 +807,23 @@ 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, decoration: InputDecoration( - labelText: localizations.editAccountEditAliasEmailLabel, - hintText: localizations.editAccountEditAliasEmailHint), + labelText: localizations.editAccountEditAliasEmailLabel, + hintText: localizations.editAccountEditAliasEmailHint, + ), onChanged: (value) { final bool isValid = Validator.validateEmail(value); final emailValue = value.toLowerCase(); if (isValid) { final existingAlias = widget.account.aliases.firstWhereOrNull( - (e) => e.email.toLowerCase() == emailValue); + (e) => e.email.toLowerCase() == emailValue, + ); if (existingAlias != null && existingAlias != widget.alias) { setState(() { _errorMessage = @@ -763,9 +846,11 @@ class _AliasEditDialogState extends State<_AliasEditDialog> { Padding( padding: const EdgeInsets.all(8), child: Text( - _errorMessage!, + _errorMessage ?? '', style: const TextStyle( - color: Colors.red, fontStyle: FontStyle.italic), + color: Colors.red, + fontStyle: FontStyle.italic, + ), ), ), ], diff --git a/lib/screens/account_server_details_screen.dart b/lib/screens/account_server_details_screen.dart index 378ceb9..655bb80 100644 --- a/lib/screens/account_server_details_screen.dart +++ b/lib/screens/account_server_details_screen.dart @@ -1,63 +1,82 @@ 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 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -class AccountServerDetailsScreen extends StatelessWidget { - final RealAccount account; - final String? title; - final bool includeDrawer; +import '../account/model.dart'; +import '../account/provider.dart'; +import '../localization/extension.dart'; +import '../mail/provider.dart'; +import '../util/localized_dialog_helper.dart'; +import '../widgets/password_field.dart'; +import 'base.dart'; +import 'mail_screen_for_default_account.dart'; + +/// Allows to edit server details for an account. +class AccountServerDetailsScreen extends ConsumerWidget { + /// Creates a [AccountServerDetailsScreen]. const AccountServerDetailsScreen({ - Key? key, - required this.account, + super.key, + this.accountEmail, + this.account, this.title, - this.includeDrawer = true, - }) : super(key: key); + }); + + /// The email address of the account to edit. + final String? accountEmail; + + /// The account to edit. + final RealAccount? account; + + /// The title of the screen, if it should differ from the account's name. + final String? title; @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 MailScreenForDefaultAccount(); + } 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 { - final RealAccount account; - +class AccountServerDetailsEditor extends StatefulHookConsumerWidget { const AccountServerDetailsEditor({ - Key? key, + super.key, required this.account, - }) : super(key: key); + }); + final RealAccount account; @override - State createState() => + ConsumerState 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(); @@ -70,6 +89,7 @@ class _SaveButtonState extends State<_SaveButton> { if (_isSaving) { return const PlatformProgressIndicator(); } + return PlatformIconButton( icon: Icon(PlatformInfo.isCupertino ? CupertinoIcons.check_mark_circled @@ -91,7 +111,7 @@ class _SaveButtonState extends State<_SaveButton> { } class _AccountServerDetailsEditorState - extends State { + extends ConsumerState { static _AccountServerDetailsEditorState? _currentState; final TextEditingController _emailController = TextEditingController(); final TextEditingController _userNameController = TextEditingController(); @@ -128,29 +148,34 @@ class _AccountServerDetailsEditorState final outgoingAuth = outgoing.authentication as UserNameBasedAuthentication?; _emailController.text = mailAccount.email; - _setupFields(incoming.serverConfig, outgoing.serverConfig, incomingAuth, - outgoingAuth); + _setupFields( + incoming.serverConfig, + outgoing.serverConfig, + incomingAuth, + outgoingAuth, + ); super.initState(); } void _setupFields( - ServerConfig? incoming, - ServerConfig? outgoing, - UserNameBasedAuthentication? incomingAuth, - UserNameBasedAuthentication? outgoingAuth) { + ServerConfig? incoming, + ServerConfig? outgoing, + UserNameBasedAuthentication? incomingAuth, + UserNameBasedAuthentication? outgoingAuth, + ) { final incomingPassword = incomingAuth is PlainAuthentication ? incomingAuth.password : null; if (incomingAuth?.userName != null) { - _userNameController.text = incomingAuth!.userName; + _userNameController.text = incomingAuth?.userName ?? ''; } if (incomingPassword != null) { _passwordController.text = incomingPassword; } final incomingHostName = incoming?.hostname; _incomingHostDomainController.text = incomingHostName ?? ''; - _incomingHostPortController.text = incoming?.port?.toString() ?? ''; + _incomingHostPortController.text = incoming?.port.toString() ?? ''; if (incomingAuth?.userName != null) { - _incomingUserNameController.text = incomingAuth!.userName; + _incomingUserNameController.text = incomingAuth?.userName ?? ''; } if (incomingPassword != null) { _incomingPasswordController.text = incomingPassword; @@ -158,9 +183,9 @@ class _AccountServerDetailsEditorState _incomingSecurity = incoming?.socketType ?? SocketType.ssl; _incomingServerType = incoming?.type ?? ServerType.imap; _outgoingHostDomainController.text = outgoing?.hostname ?? ''; - _outgoingHostPortController.text = outgoing?.port?.toString() ?? ''; + _outgoingHostPortController.text = outgoing?.port.toString() ?? ''; if (outgoingAuth?.userName != null) { - _outgoingUserNameController.text = outgoingAuth!.userName; + _outgoingUserNameController.text = outgoingAuth?.userName ?? ''; } if (outgoingAuth is PlainAuthentication) { _outgoingPasswordController.text = outgoingAuth.password; @@ -195,8 +220,10 @@ class _AccountServerDetailsEditorState final incomingServerConfig = ServerConfig( type: _incomingServerType, hostname: _incomingHostDomainController.text, - port: int.tryParse(_incomingHostPortController.text), + port: int.tryParse(_incomingHostPortController.text) ?? 0, socketType: _incomingSecurity, + authentication: Authentication.plain, + usernameType: UsernameType.unknown, ); final incomingUserName = (_incomingUserNameController.text.isEmpty) ? userName @@ -207,8 +234,10 @@ class _AccountServerDetailsEditorState final outgoingServerConfig = ServerConfig( type: _outgoingServerType, hostname: _outgoingHostDomainController.text, - port: int.tryParse(_outgoingHostPortController.text), + port: int.tryParse(_outgoingHostPortController.text) ?? 0, socketType: _outgoingSecurity, + authentication: Authentication.plain, + usernameType: UsernameType.unknown, ); final outgoingUserName = (_outgoingUserNameController.text.isEmpty) ? userName @@ -243,6 +272,7 @@ class _AccountServerDetailsEditorState ), ); } + return; } else { final incoming = mailAccount.incoming; @@ -250,24 +280,28 @@ class _AccountServerDetailsEditorState if (mounted) { setState(() { _incomingHostPortController.text = - incoming.serverConfig.port?.toString() ?? ''; - _incomingServerType = incoming.serverConfig.type ?? ServerType.imap; - _incomingSecurity = - incoming.serverConfig.socketType ?? SocketType.ssl; + incoming.serverConfig.port.toString(); + _incomingServerType = incoming.serverConfig.type; + _incomingSecurity = incoming.serverConfig.socketType; _outgoingHostPortController.text = - outgoing.serverConfig.port?.toString() ?? ''; - _outgoingServerType = outgoing.serverConfig.type ?? ServerType.smtp; - _outgoingSecurity = - outgoing.serverConfig.socketType ?? SocketType.ssl; + outgoing.serverConfig.port.toString(); + _outgoingServerType = outgoing.serverConfig.type; + _outgoingSecurity = outgoing.serverConfig.socketType; }); } } // 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,11 +317,12 @@ class _AccountServerDetailsEditorState @override Widget build(BuildContext context) { final localizations = context.text; + return SingleChildScrollView( child: Material( child: SafeArea( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Column( children: [ DecoratedPlatformTextField( @@ -335,65 +370,79 @@ class _AccountServerDetailsEditorState ), ExpansionTile( title: Text( - localizations.accountDetailsAdvancedIncomingSectionTitle), + 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), ), PlatformDropdownButton( - items: [ - DropdownMenuItem( - child: Text(localizations - .accountDetailsOptionAutomatic)), - const DropdownMenuItem( - value: ServerType.imap, - child: Text('IMAP'), - ), - const DropdownMenuItem( - value: ServerType.pop, - child: Text('POP'), + items: [ + DropdownMenuItem( + child: Text( + localizations.accountDetailsOptionAutomatic, ), - ], - value: _incomingServerType, - onChanged: (value) => - setState(() => _incomingServerType = value!)), + ), + const DropdownMenuItem( + value: ServerType.imap, + child: Text('IMAP'), + ), + const DropdownMenuItem( + value: ServerType.pop, + child: Text('POP'), + ), + ], + value: _incomingServerType, + onChanged: (value) { + if (value != null) { + setState( + () => _incomingServerType = value, + ); + } + }, + ), ], ), Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Text(localizations .accountDetailsIncomingSecurityLabel), ), PlatformDropdownButton( - items: [ - DropdownMenuItem( - child: Text(localizations - .accountDetailsOptionAutomatic)), - const DropdownMenuItem( - value: SocketType.ssl, - child: Text('SSL'), - ), - const DropdownMenuItem( - value: SocketType.starttls, - child: Text('Start TLS'), - ), - DropdownMenuItem( - value: SocketType.plain, - child: Text(localizations - .accountDetailsSecurityOptionNone), + items: [ + DropdownMenuItem( + child: Text( + localizations.accountDetailsOptionAutomatic, ), - ], - value: _incomingSecurity, - onChanged: (value) => - setState(() => _incomingSecurity = value!)), + ), + const DropdownMenuItem( + value: SocketType.ssl, + child: Text('SSL'), + ), + const DropdownMenuItem( + // cSpell: ignore starttls + value: SocketType.starttls, + child: Text('Start TLS'), + ), + DropdownMenuItem( + value: SocketType.plain, + child: Text(localizations + .accountDetailsSecurityOptionNone), + ), + ], + value: _incomingSecurity, + onChanged: (value) { + if (value != null) { + setState(() => _incomingSecurity = value); + } + }, + ), ], ), DecoratedPlatformTextField( @@ -418,70 +467,84 @@ class _AccountServerDetailsEditorState ), ), PasswordField( - controller: _incomingPasswordController, - labelText: - localizations.accountDetailsIncomingPasswordLabel, - hintText: localizations - .accountDetailsAlternativePasswordHint), + controller: _incomingPasswordController, + labelText: + localizations.accountDetailsIncomingPasswordLabel, + hintText: + localizations.accountDetailsAlternativePasswordHint, + ), ], ), ExpansionTile( title: Text( - localizations.accountDetailsAdvancedOutgoingSectionTitle), + localizations.accountDetailsAdvancedOutgoingSectionTitle, + ), children: [ Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Text(localizations - .accountDetailsOutgoingServerTypeLabel), + padding: const EdgeInsets.only(right: 8), + child: Text( + localizations.accountDetailsOutgoingServerTypeLabel, + ), ), PlatformDropdownButton( - items: [ - DropdownMenuItem( - child: Text(localizations - .accountDetailsOptionAutomatic)), - const DropdownMenuItem( - value: ServerType.smtp, - child: Text('SMTP'), + items: [ + DropdownMenuItem( + child: Text( + localizations.accountDetailsOptionAutomatic, ), - ], - value: _outgoingServerType, - onChanged: (value) => - setState(() => _outgoingServerType = value!)), + ), + const DropdownMenuItem( + value: ServerType.smtp, + child: Text('SMTP'), + ), + ], + value: _outgoingServerType, + onChanged: (value) { + if (value != null) { + setState(() => _outgoingServerType = value); + } + }, + ), ], ), Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(right: 8.0), + padding: const EdgeInsets.only(right: 8), child: Text(localizations .accountDetailsOutgoingSecurityLabel), ), PlatformDropdownButton( - items: [ - DropdownMenuItem( - child: Text(localizations - .accountDetailsOptionAutomatic)), - const DropdownMenuItem( - value: SocketType.ssl, - child: Text('SSL'), + items: [ + DropdownMenuItem( + child: Text( + localizations.accountDetailsOptionAutomatic, ), - const DropdownMenuItem( - value: SocketType.starttls, - child: Text('Start TLS'), + ), + const DropdownMenuItem( + value: SocketType.ssl, + child: Text('SSL'), + ), + const DropdownMenuItem( + value: SocketType.starttls, + child: Text('Start TLS'), + ), + DropdownMenuItem( + value: SocketType.plain, + child: Text( + localizations.accountDetailsSecurityOptionNone, ), - DropdownMenuItem( - value: SocketType.plain, - child: Text(localizations - .accountDetailsSecurityOptionNone), - ), - ], - value: _outgoingSecurity, - onChanged: (value) => - setState(() => _outgoingSecurity = value!)), + ), + ], + value: _outgoingSecurity, + onChanged: (value) { + if (value != null) { + setState(() => _outgoingSecurity = value); + } + }, + ), ], ), DecoratedPlatformTextField( diff --git a/lib/screens/base.dart b/lib/screens/base.dart index e35feb3..2e957ba 100644 --- a/lib/screens/base.dart +++ b/lib/screens/base.dart @@ -1,16 +1,18 @@ -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 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../locator.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({ - Key? key, + super.key, this.title, this.subtitle, this.content, @@ -20,210 +22,165 @@ class BasePage extends StatelessWidget { this.drawer, this.bottom, this.includeDrawer = true, - this.isRoot = false, - }) : super(key: key); + }); + /// 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) { - return 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({ - Key? key, - this.title, - this.actions, - this.subtitle, - this.floatingActionButton, - this.includeDrawer = true, - }) : super(key: key); + /// 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) { - return 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, - }) { - 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!, - ), - ), - 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.0), - 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) { - return 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, + }); + final double maxHeight; final double minHeight; final double? elevation; final Widget child; final Widget? background; - SliverSingleChildHeaderDelegate( - {required this.maxHeight, - required this.minHeight, - required this.child, - this.elevation, - this.background}); - @override Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { - return Material( - elevation: elevation ?? 0, - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: maxHeight), - child: Stack( - children: [ - Positioned( - bottom: 0.0, - left: 0.0, - right: 0.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; @@ -232,20 +189,13 @@ class SliverSingleChildHeaderDelegate extends SliverPersistentHeaderDelegate { double get minExtent => kToolbarHeight + minHeight; @override - bool shouldRebuild(SliverSingleChildHeaderDelegate oldDelegate) { - return 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 { - final Widget? child; - final Widget? title; - final Widget? background; - final double minHeight; - final double maxHeight; - CustomApBarSliverDelegate({ this.title, this.child, @@ -253,34 +203,43 @@ 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( - 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( children: [ Positioned( - bottom: 0.0, - left: 0.0, - right: 0.0, + bottom: 0, + left: 0, + right: 0, top: 0, - child: background!, + child: background ?? const SizedBox.shrink(), ), 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 +262,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..09a4c7c 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,40 +8,30 @@ 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'; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; -import '../locator.dart'; -import '../models/account.dart'; +import '../account/model.dart'; +import '../account/provider.dart'; +import '../contact/provider.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../mail/provider.dart'; import '../models/compose_data.dart'; +import '../models/message.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 '../routes/routes.dart'; +import '../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'; -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 { - const ComposeScreen({super.key, required this.data}); - - final ComposeData data; - - @override - ConsumerState createState() => _ComposeScreenState(); -} - enum _OverflowMenuChoice { showSourceCode, saveAsDraft, @@ -50,6 +42,102 @@ 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); + + 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 = 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; + } + + final senderState = useState(getInitialSender()); + + return PlatformDropdownButton( + material: (context, platform) => MaterialDropdownButtonData( + isExpanded: true, + ), + 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 + 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; @@ -66,12 +154,13 @@ class _ComposeScreenState extends ConsumerState { ComposeData? _resumeComposeData; bool _isReadReceiptRequested = false; late ComposeMode _composeMode; + late RealAccount _realAccount; TextEditorApi? _plainTextEditorApi; @override - void initState() { - locator().onSharedData = _onSharedData; + void didChangeDependencies() { + onSharedData = _onSharedData; _composeMode = widget.data.composeMode; final mb = widget.data.messageBuilder; _toRecipients = mb.to ?? []; @@ -84,34 +173,31 @@ 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.read(sendersProvider); + 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 { 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); - _senders.insert(0, from); + from = Sender(mbFrom.first, currentAccount); + _senders = [from, ..._senders]; } _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; @@ -121,23 +207,26 @@ class _ComposeScreenState extends ConsumerState { }); }); } - super.initState(); + super.didChangeDependencies(); } @override void dispose() { _subjectController.dispose(); _plainTextController.dispose(); - locator().onSharedData = null; + onSharedData = null; super.dispose(); } 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( + context, + _from.account, + widget.data.action, + context.text.localeName, + ); Future _loadMailTextFromMessage() async { final signature = _signature; @@ -145,41 +234,55 @@ class _ComposeScreenState extends ConsumerState { final mb = widget.data.messageBuilder; if (mb.originalMessage == null) { if (_composeMode == ComposeMode.html) { + // cSpell:ignore nbsp final html = '

${mb.text ?? ' '}

$signature'; + return html; } else { return '${mb.text ?? ''}\n$signature'; } } 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: 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; } } - //TODO localize quote templates + // TODO(RV): localize quote templates final quoteTemplate = widget.data.action == ComposeAction.answer ? MailConventions.defaultReplyHeaderTemplate : widget.data.action == ComposeAction.forward ? MailConventions.defaultForwardHeaderTemplate : MailConventions.defaultReplyHeaderTemplate; if (_composeMode == ComposeMode.html) { - final args = _HtmlGenerationArguments(quoteTemplate, mb.originalMessage, - blockExternalImages, emptyMessageText, maxImageWidth); + final args = _HtmlGenerationArguments( + quoteTemplate, + mb.originalMessage, + blockExternalImages, + emptyMessageText, + maxImageWidth, + ); final html = await compute(_generateQuoteHtmlImpl, args) + signature; + return html; } else { final original = mb.originalMessage; @@ -187,6 +290,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'; @@ -196,33 +300,37 @@ class _ComposeScreenState extends ConsumerState { } static String _generateQuoteHtmlImpl(_HtmlGenerationArguments args) { - final html = args.mimeMessage!.quoteToHtml( + final html = args.mimeMessage?.quoteToHtml( quoteHeaderTemplate: args.quoteTemplate, blockExternalImages: args.blockExternalImages, emptyMessageText: args.emptyMessageText, maxImageWidth: args.maxImageWidth, ); - return html; + + return html ?? ''; } static String _generateDraftHtmlImpl(_HtmlGenerationArguments args) { - final html = args.mimeMessage!.transformToHtml( - emptyMessageText: args.emptyMessageText, - maxImageWidth: args.maxImageWidth, - blockExternalImages: args.blockExternalImages); - return html; + final html = args.mimeMessage?.transformToHtml( + emptyMessageText: args.emptyMessageText, + maxImageWidth: args.maxImageWidth, + blockExternalImages: args.blockExternalImages, + ); + + return html ?? ''; } - Future _populateMessageBuilder( - {bool storeComposeDataForResume = false}) async { - final mb = widget.data.messageBuilder; - mb.to = _toRecipients; - mb.cc = _ccRecipients; - mb.bcc = _bccRecipients; - mb.subject = _subjectController.text; + Future _populateMessageBuilder({ + bool storeComposeDataForResume = false, + }) async { + final mb = widget.data.messageBuilder + ..to = _toRecipients + ..cc = _ccRecipients + ..bcc = _bccRecipients + ..subject = _subjectController.text; final text = _composeMode == ComposeMode.html - ? await _htmlEditorApi!.getText() + ? await _htmlEditorApi?.getText() ?? '' : _plainTextController.text; _resumeComposeData = widget.data.resume(text, composeMode: _composeMode); if (storeComposeDataForResume) { @@ -238,8 +346,9 @@ class _ComposeScreenState extends ConsumerState { mb.addTextPlain(text); } } else { - mb.text = text; - mb.setContentType(MediaType.textPlain); + mb + ..text = text + ..setContentType(MediaType.textPlain); } } else { // create a normal mail with an HTML and a plain text part: @@ -248,12 +357,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) { @@ -262,7 +373,7 @@ class _ComposeScreenState extends ConsumerState { multipartAlternativeBuilder.addTextPlain(plainText); } final fullHtmlMessageText = - await _htmlEditorApi!.getFullHtml(content: text); + await _htmlEditorApi?.getFullHtml(content: text) ?? ''; final htmlTextBuilder = multipartAlternativeBuilder.getTextHtmlPart(); if (htmlTextBuilder != null) { htmlTextBuilder.text = fullHtmlMessageText; @@ -290,11 +401,12 @@ class _ComposeScreenState extends ConsumerState { } } final mimeMessage = mb.buildMimeMessage(); + 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(); @@ -309,8 +421,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; @@ -319,64 +433,80 @@ class _ComposeScreenState extends ConsumerState { from: _from.account.fromAddress, appendToSent: append, ); - locator() - .showTextSnackBar(localizations.composeMailSendSuccess); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.composeMailSendSuccess, + ); } catch (e, s) { if (kDebugMode) { 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(); - }, - ), - ], - ); + // this state's context is now invalid because this widget is not + // mounted anymore + 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( + onPressed: currentContext.pop, + child: Text(localizations.actionCancel), + ), + PlatformTextButton( + child: Text(localizations.composeContinueEditingAction), + onPressed: () { + currentContext.pop(); + _returnToCompose(); + }, + ), + ], + ); + } + return; } final action = widget.data.action; final storeFlags = action != ComposeAction.newMessage; if (storeFlags) { - for (final originalMessage in widget.data.originalMessages!) { + for (final originalMessage + in widget.data.originalMessages ?? const []) { + if (originalMessage == null) { + continue; + } if (action == ComposeAction.answer) { - originalMessage!.isAnswered = true; + originalMessage.isAnswered = true; } else { - originalMessage!.isForwarded = true; + originalMessage.isForwarded = true; } 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 } } } - } else if ((widget.data.originalMessage != null) && - widget.data.originalMessage!.mimeMessage.hasFlag(MessageFlags.draft)) { + } else if (widget.data.originalMessage?.mimeMessage + .hasFlag(MessageFlags.draft) ?? + false) { // delete draft message: try { - final originalMessage = widget.data.originalMessage!; - final source = originalMessage.source; - source.removeFromCache(originalMessage); - await mailClient.flagMessage(originalMessage.mimeMessage, - isDeleted: true); + final originalMessage = widget.data.originalMessage; + if (originalMessage != null) { + 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 @@ -393,271 +523,307 @@ class _ComposeScreenState extends ConsumerState { : widget.data.action == ComposeAction.forward ? localizations.composeTitleForward : localizations.composeTitleNew; + final htmlEditorApi = _htmlEditorApi; + return WillPopScope( onWillPop: () async { - // let it pop but show snackbar to return: await _populateMessageBuilder(storeComposeDataForResume: true); - locator().showTextSnackBar( + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, localizations.composeLeftByMistake, undo: _returnToCompose, ); - return true; + + return Future.value(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), + // wait for https://github.com/flutter/flutter/issues/138525 before + // switching to PopScope + // onPopInvoked: (didPop) async { + // // let it pop but show snackbar to return: + // await _populateMessageBuilder(storeComposeDataForResume: true); + // ScaffoldMessengerService.instance.showTextSnackBar( + // localizations, + // localizations.composeLeftByMistake, + // undo: _returnToCompose, + // ); + // }, + 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( + () {}, ), - 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) => [ + ), + 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, + ], + ), + ], // actions + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.detailsHeaderFrom, + style: Theme.of(context).textTheme.bodySmall, + ), + // SenderDropdown( + // from: widget.data.messageBuilder.from, + // onChanged: + PlatformDropdownButton( + material: (context, platform) => + MaterialDropdownButtonData( + isExpanded: true, ), - PlatformDropdownButton( - //isExpanded: true, - items: _senders - .map( - (s) => DropdownMenuItem( - value: s, - child: Text( - s.toString(), - overflow: TextOverflow.fade, - ), + 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]; + ), + ) + .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); + await _htmlEditorApi?.replaceAll( + lastSignature, + newSignature, + ); } if (_isReadReceiptRequested) { builder.requestReadReceipt( - recipient: _from.address); + recipient: _from.address, + ); } - setState(() {}); + ref.read(currentAccountProvider.notifier).state = + s.account; + setState(() { + _realAccount = s.account; + }); - _checkAccountContactManager(_from.account); - }, - value: _from, - hint: Text(localizations.composeSenderHint), + await _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: Text(localizations.detailsHeaderCc), + onPressed: () => setState( + () => _isCcBccVisible = !_isCcBccVisible, + ), ), + ), + if (_isCcBccVisible) ...[ RecipientInputField( + addresses: _ccRecipients, contactManager: _from.account.contactManager, - addresses: _toRecipients, - autofocus: _focus == _Autofocus.to, - labelText: localizations.detailsHeaderTo, + labelText: localizations.detailsHeaderCc, 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, + 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, ), - 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; - }); - }, - ), - ); - } - } - }, - ), + ), + ); + } + } + }, ), - ], - ), + ), + ], ), ), ); } 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(); - final localizations = locator().localizations; - final mailClient = await locator().getClientFor(_from.account); + context.pop(); + final localizations = context.text; + final mailClient = _getMailClient(); final mime = await _buildMimeMessage(mailClient); try { await mailClient.saveDraftMessage(mime); - locator() - .showTextSnackBar(localizations.composeMessageSavedAsDraft); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.composeMessageSavedAsDraft, + ); final originalMessage = widget.data.originalMessage; if (originalMessage != null) { await Future.delayed(const Duration(milliseconds: 20)); @@ -678,25 +844,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: Text(localizations.actionCancel), + ), + PlatformTextButton( + child: Text(localizations.composeContinueEditingAction), + onPressed: () { + currentContext.pop(); + _returnToCompose(); + }, + ), + ], + ); + } } } @@ -708,7 +876,7 @@ class _ComposeScreenState extends ConsumerState { } void _convertToPlainTextEditor() { - final future = _htmlEditorApi!.getText(); + final future = _htmlEditorApi?.getText() ?? Future.value(''); setState(() { _loadMailTextFuture = future.then(_convertToPlainText); _composeMode = ComposeMode.plainText; @@ -731,31 +899,39 @@ class _ComposeScreenState extends ConsumerState { } void _returnToCompose() { - locator() - .push(Routes.mailCompose, arguments: _resumeComposeData); + final currentContext = Routes.navigatorKey.currentContext; + if (currentContext != null && currentContext.mounted) { + currentContext.pushNamed( + Routes.mailCompose, + extra: _resumeComposeData, + ); + } } - void _checkAccountContactManager(RealAccount account) { - if (account.contactManager == null) { - locator().getForAccount(account).then((value) { - setState(() {}); - }); + Future _checkAccountContactManager(RealAccount account) async { + final contactManager = account.contactManager; + if (contactManager == null) { + account.contactManager = + await ref.read(contactsLoaderProvider(account: account).future); + setState(() {}); } } Future _onSharedData(List sharedData) { final firstData = sharedData.first; if (firstData is SharedMailto) { - //TODO add the recipients, set the subject, set the text? + // TODO(RV): add the recipients, set the subject, set the text? } else { 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(); } @@ -764,8 +940,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 new file mode 100644 index 0000000..bf2734e --- /dev/null +++ b/lib/screens/email_screen.dart @@ -0,0 +1,61 @@ +import 'package:enough_platform_widgets/enough_platform_widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../account/provider.dart'; +import '../mail/provider.dart'; +import 'base.dart'; +import 'error_screen.dart'; +import 'screens.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 (account == null) { + if (ref.read(realAccountsProvider).isEmpty) { + return const WelcomeScreen(); + } + + return const MailScreenForDefaultAccount(); + } + + if (encodedMailboxPath == null) { + return MailScreen( + account: account, + ); + } + + final mailboxValue = ref.watch( + findMailboxProvider( + account: account, + encodedMailboxPath: encodedMailboxPath, + ), + ); + + return mailboxValue.when( + loading: () => const BasePage( + content: Center( + child: PlatformProgressIndicator(), + ), + ), + error: (error, stack) => ErrorScreen(error: error, stackTrace: stack), + data: (mailbox) => MailScreen( + account: account, + mailbox: mailbox, + ), + ); + } +} diff --git a/lib/screens/error_screen.dart b/lib/screens/error_screen.dart new file mode 100644 index 0000000..d4e5e3e --- /dev/null +++ b/lib/screens/error_screen.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.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: SelectableText(message ?? '$error'), + ), + ), + ); +} diff --git a/lib/screens/lock_screen.dart b/lib/screens/lock_screen.dart deleted file mode 100644 index c9437ce..0000000 --- a/lib/screens/lock_screen.dart +++ /dev/null @@ -1,55 +0,0 @@ -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'; - -class LockScreen extends StatelessWidget { - const LockScreen({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final localizations = context.text; - - return Base.buildAppChrome( - context, - includeDrawer: false, - title: localizations.lockScreenTitle, - content: _buildContent(context, localizations), - ); - } - - Widget _buildContent(BuildContext context, AppLocalizations localizations) { - return 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.0), - child: Text(localizations.lockScreenIntro), - ), - PlatformTextButton( - child: PlatformText(localizations.lockScreenUnlockAction), - onPressed: () => _authenticate(context), - ) - ], - ), - ), - ); - } - - void _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..bc0fc11 --- /dev/null +++ b/lib/screens/mail_screen.dart @@ -0,0 +1,81 @@ +import 'package:enough_mail/enough_mail.dart'; +import 'package:enough_platform_widgets/platform.dart'; +import 'package:flutter/widgets.dart'; +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 '../mail/provider.dart'; +import '../routes/routes.dart'; +import 'base.dart'; +import 'error_screen.dart'; +import 'screens.dart'; + +/// Displays the mail for a given account +class MailScreen extends HookConsumerWidget { + /// Creates a [MailScreen] + const MailScreen({ + super.key, + required this.account, + this.mailbox, + this.showSplashWhileLoading = false, + }); + + /// The account to display + final Account account; + + /// 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; + final sourceFuture = ref.watch( + sourceProvider( + account: account, + mailbox: mailbox, + ), + ); + if (useAppDrawerAsRoot) { + // when the app drawer is below in the widget tree, + // set the account and mailbox: + useMemoized(() async { + await Future.delayed(const Duration(milliseconds: 10)); + ref.read(currentAccountProvider.notifier).state = account; + ref.read(currentMailboxProvider.notifier).state = mailbox; + }); + } + + final title = + account is UnifiedAccount ? text.unifiedAccountName : account.name; + final subtitle = account.fromAddress.email; + + return ProviderScope( + overrides: [ + currentMailboxProvider.overrideWith((ref) => mailbox), + currentAccountProvider.overrideWith((ref) => account), + ], + child: sourceFuture.when( + 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/mail_screen_for_default_account.dart b/lib/screens/mail_screen_for_default_account.dart new file mode 100644 index 0000000..b146459 --- /dev/null +++ b/lib/screens/mail_screen_for_default_account.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../account/provider.dart'; +import 'screens.dart'; + +/// Shows the inbox of the default account +class MailScreenForDefaultAccount extends ConsumerWidget { + /// Creates a [MailScreenForDefaultAccount] + const MailScreenForDefaultAccount({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(allAccountsProvider); + if (accounts.isEmpty) { + return const WelcomeScreen(); + } + final account = accounts.first; + + return MailScreen( + account: account, + showSplashWhileLoading: true, + ); + } +} diff --git a/lib/screens/mail_search_screen.dart b/lib/screens/mail_search_screen.dart new file mode 100644 index 0000000..095cb4e --- /dev/null +++ b/lib/screens/mail_search_screen.dart @@ -0,0 +1,46 @@ +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 [MailSearchScreen] + 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( + localizations: text, + 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 202b3e5..171cb91 100644 --- a/lib/screens/media_screen.dart +++ b/lib/screens/media_screen.dart @@ -1,5 +1,5 @@ +import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:enough_mail/enough_mail.dart'; import 'package:enough_media/enough_media.dart'; @@ -7,21 +7,19 @@ 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 '../l10n/extension.dart'; -import '../locator.dart'; -import '../models/account.dart'; +import '../account/model.dart'; +import '../account/provider.dart'; +import '../localization/extension.dart'; import '../models/compose_data.dart'; 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 '../routes/routes.dart'; import '../settings/provider.dart'; +import '../settings/theme/icon_service.dart'; import '../util/localized_dialog_helper.dart'; import 'base.dart'; @@ -36,15 +34,15 @@ class InteractiveMediaScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final localizations = context.text; - final iconService = locator(); - return Base.buildAppChrome( - context, + final iconService = IconService.instance; + + return BasePage( title: mediaWidget.mediaProvider.name, content: mediaWidget, appBarActions: [ DensePlatformIconButton( icon: Icon(iconService.messageActionForward), - onPressed: _forward, + onPressed: () => _forward(context), ), DensePlatformIconButton( icon: Icon(iconService.share), @@ -66,18 +64,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 client = await mailService.getClientFor(account); - final source = - SingleMessageSource(mailService.messageSource); - final message = Message(mime, client, source, 0); - message.isEmbedded = true; + final source = SingleMessageSource( + null, + account: account, + ); + 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) { @@ -86,11 +85,15 @@ class InteractiveMediaScreen extends ConsumerWidget { } } if (showErrorMessage) { - await LocalizedDialogHelper.showTextDialog( + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( context, localizations.errorTitle, - localizations.developerShowAsEmailFailed); + localizations.developerShowAsEmailFailed, + ); + } } + break; } }, @@ -105,23 +108,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), + 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() { @@ -138,49 +144,27 @@ class InteractiveMediaScreen extends ConsumerWidget { if (kDebugMode) { print('Unable to share media provider $provider'); } + return Future.value(); } } - static Future _shareText(TextMediaProvider provider) async { - await Share.share(provider.text, - subject: provider.description ?? provider.name); - } + static Future _shareText(TextMediaProvider provider) => Share.share( + provider.text, + subject: provider.description ?? provider.name, + ); static Future _shareFile(MemoryMediaProvider provider) async { - final tempDir = await pathprovider.getTemporaryDirectory(); - final originalFileName = provider.name; - final lastDotIndex = originalFileName.lastIndexOf('.'); - final ext = - lastDotIndex != -1 ? originalFileName.substring(lastDotIndex) : ''; - final safeFileName = _filterNonAscii(originalFileName); - final path = '${tempDir.path}/$safeFileName$ext'; - final file = File(path); - await file.writeAsBytes(provider.data); - - final paths = [path]; - final mimeTypes = [provider.mediaType]; - await Share.shareFiles(paths, - mimeTypes: mimeTypes, - subject: originalFileName, - text: provider.description); - } + final file = XFile.fromData( + provider.data, + mimeType: provider.mediaType, + name: provider.name, + ); - static String _filterNonAscii(String input) { - final buffer = StringBuffer(); - for (final rune in input.runes) { - if ((rune >= 48 && rune <= 57) || // 0-9 - (rune >= 65 && rune <= 90) || // A-Z - (rune >= 97 && rune <= 122)) // a-z - { - buffer.writeCharCode(rune); - } else if (rune == 46) { - // dot / period - break; - } else { - buffer.write('_'); - } - } - return buffer.toString(); + await Share.shareXFiles( + [file], + subject: provider.name, + text: provider.description, + ); } } diff --git a/lib/screens/message_details_screen.dart b/lib/screens/message_details_screen.dart index d6972a8..b37ae0c 100644 --- a/lib/screens/message_details_screen.dart +++ b/lib/screens/message_details_screen.dart @@ -6,30 +6,27 @@ 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 '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; -import '../locator.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../logger.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 '../notification/service.dart'; +import '../routes/routes.dart'; import '../settings/model.dart'; import '../settings/provider.dart'; +import '../settings/theme/icon_service.dart'; import '../util/localized_dialog_helper.dart'; import '../widgets/attachment_chip.dart'; -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'; @@ -39,10 +36,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(); @@ -59,7 +56,6 @@ class _DetailsScreenState extends ConsumerState { void initState() { _pageController = PageController(initialPage: widget.message.sourceIndex); _current = widget.message; - _current.addListener(_update); _source = _current.source; super.initState(); } @@ -67,99 +63,91 @@ class _DetailsScreenState extends ConsumerState { @override void dispose() { _pageController.dispose(); - _current.removeListener(_update); super.dispose(); } - void _update() { - setState(() {}); - } - Future _getMessageAt(int index) { 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, - appBarActions: [ - //PlatformIconButton(icon: Icon(Icons.reply), onPressed: reply), - PlatformPopupMenuButton<_OverflowMenuChoice>( - onSelected: (_OverflowMenuChoice result) { - switch (result) { - case _OverflowMenuChoice.showContents: - locator() - .push(Routes.mailContents, arguments: _current); - break; - case _OverflowMenuChoice.showSourceCode: - _showSourceCode(); - break; - } - }, - itemBuilder: (BuildContext context) => [ - PlatformPopupMenuItem<_OverflowMenuChoice>( - value: _OverflowMenuChoice.showContents, - child: Text(localizations.viewContentsAction), - ), - if (ref.read(settingsProvider).enableDeveloperMode) + + return ListenableBuilder( + listenable: _current, + builder: (context, child) => BasePage( + title: _current.mimeMessage.decodeSubject() ?? + localizations.subjectUndefined, + appBarActions: [ + //PlatformIconButton(icon: Icon(Icons.reply), onPressed: reply), + PlatformPopupMenuButton<_OverflowMenuChoice>( + onSelected: (_OverflowMenuChoice result) { + switch (result) { + case _OverflowMenuChoice.showContents: + context.pushNamed(Routes.mailContents, extra: _current); + break; + case _OverflowMenuChoice.showSourceCode: + _showSourceCode(); + break; + } + }, + itemBuilder: (BuildContext context) => [ PlatformPopupMenuItem<_OverflowMenuChoice>( - value: _OverflowMenuChoice.showSourceCode, - child: Text(localizations.viewSourceAction), + value: _OverflowMenuChoice.showContents, + child: Text(localizations.viewContentsAction), ), - ], - ), - ], - bottom: MessageActions(message: _current), - content: PageView.builder( - controller: _pageController, - itemCount: _source.size, - itemBuilder: (context, index) => FutureBuilder( - future: _getMessageAt(index), - builder: (context, snapshot) { - final data = snapshot.data; - if (data == null) { - return const EmptyMessage(); - } - return _MessageContent( - data, - blockExternalContents: _blockExternalContents(index), - ); + if (ref.read(settingsProvider).enableDeveloperMode) + PlatformPopupMenuItem<_OverflowMenuChoice>( + value: _OverflowMenuChoice.showSourceCode, + child: Text(localizations.viewSourceAction), + ), + ], + ), + ], + bottom: MessageActions(message: _current), + content: PageView.builder( + controller: _pageController, + itemCount: _source.size, + itemBuilder: (context, index) => FutureBuilder( + future: _getMessageAt(index), + builder: (context, snapshot) { + final data = snapshot.data; + if (data == null) { + return const EmptyMessage(); + } + + return _MessageContent( + data, + blockExternalContents: _blockExternalContents(index), + ); + }, + ), + onPageChanged: (index) async { + final current = await _getMessageAt(index); + setState(() { + _current = current; + }); }, ), - onPageChanged: (index) async { - final current = await _getMessageAt(index); - setState(() { - _current.removeListener(_update); - _current = current; - _current.addListener(_update); - }); - }, ), ); } - void _showSourceCode() { - locator() - .push(Routes.sourceCode, arguments: _current.mimeMessage); - } + void _showSourceCode() => + context.pushNamed(Routes.sourceCode, extra: _current.mimeMessage); } class _MessageContent extends ConsumerStatefulWidget { const _MessageContent(this.message, {this.blockExternalContents = false}); + final Message message; final bool blockExternalContents; @@ -175,17 +163,22 @@ class _MessageContentState extends ConsumerState<_MessageContent> { Object? errorObject; StackTrace? errorStackTrace; bool _notifyMarkedAsSeen = false; + late bool _settingsBlockExternalImages; @override void initState() { final message = widget.message; final mime = message.mimeMessage; + _settingsBlockExternalImages = + ref.read(settingsProvider).blockExternalImages; if (widget.blockExternalContents) { _blockExternalImages = true; } else if (mime.isDownloaded) { _blockExternalImages = _shouldImagesBeBlocked(mime); if (!mime.isSeen) { - unawaited(message.source.markAsSeen(message, true)); + Future.delayed(const Duration(milliseconds: 50)).then( + (_) => message.source.markAsSeen(message, true), + ); } } else { _messageRequiresRefresh = mime.envelope == null; @@ -198,33 +191,40 @@ class _MessageContentState extends ConsumerState<_MessageContent> { @override Widget build(BuildContext context) { final localizations = context.text; - return MessageWidget( - message: widget.message, - child: _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(); + + TableRow rowWithLabel({required String label, required Widget child}) => + TableRow( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), + child: Text(label), + ), + child, + ], + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -233,42 +233,22 @@ class _MessageContentState extends ConsumerState<_MessageContent> { textBaseline: TextBaseline.alphabetic, columnWidths: const {0: IntrinsicColumnWidth(), 1: FlexColumnWidth()}, children: [ - TableRow( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), - child: Text(localizations.detailsHeaderFrom), - ), - _buildMailAddresses(mime.from) - ], + rowWithLabel( + label: localizations.detailsHeaderFrom, + child: _buildMailAddresses(mime.from), ), - TableRow( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), - child: Text(localizations.detailsHeaderTo), - ), - _buildMailAddresses(mime.to) - ], + rowWithLabel( + label: localizations.detailsHeaderTo, + child: _buildMailAddresses(mime.to), ), if (mime.cc?.isNotEmpty ?? false) - TableRow( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), - child: Text(localizations.detailsHeaderCc), - ), - _buildMailAddresses(mime.cc) - ], + rowWithLabel( + label: localizations.detailsHeaderCc, + child: _buildMailAddresses(mime.cc), ), - TableRow( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), - child: Text(localizations.detailsHeaderDate), - ), - Text(date), - ], + rowWithLabel( + label: localizations.detailsHeaderDate, + child: Text(date), ), ], ), @@ -291,20 +271,20 @@ class _MessageContentState extends ConsumerState<_MessageContent> { if (mime.threadSequence != null) ThreadSequenceButton(message: widget.message) else - Container(), + const SizedBox.shrink(), 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(), + const SizedBox.shrink(), if (_blockExternalImages) PlatformElevatedButton( - child: ButtonText(localizations.detailsActionShowImages), + child: Text(localizations.detailsActionShowImages), onPressed: () => setState( () { _blockExternalImages = false; @@ -312,26 +292,29 @@ class _MessageContentState extends ConsumerState<_MessageContent> { ), ) else - Container(), + const SizedBox.shrink(), if (mime.isNewsletter) UnsubscribeButton( message: widget.message, ) else - Container(), + const SizedBox.shrink(), ], ), if (ReadReceiptButton.shouldBeShown(mime, ref.read(settingsProvider))) - const ReadReceiptButton(), + ReadReceiptButton( + message: widget.message, + ), ], ); } Widget _buildMailAddresses(List? addresses) { - if (addresses?.isEmpty ?? true) { - return Container(); + if (addresses == null || addresses.isEmpty) { + return const SizedBox.shrink(); } - return MailAddressList(mailAddresses: addresses!); + + return MailAddressList(mailAddresses: addresses); } Widget _buildAttachments(List attachments) => Wrap( @@ -341,9 +324,8 @@ class _MessageContentState extends ConsumerState<_MessageContent> { ], ); - Widget _buildContent(AppLocalizations localizations) { - if (_messageDownloadError) { - return Column( + Widget _buildMessageDownloadErrorContent(AppLocalizations localizations) => + Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -353,7 +335,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { ), TextButton.icon( icon: Icon(CommonPlatformIcons.refresh), - label: ButtonText(localizations.detailsErrorDownloadRetry), + label: Text(localizations.detailsErrorDownloadRetry), onPressed: () { setState(() { _messageDownloadError = false; @@ -366,7 +348,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { SelectableText(errorStackTrace?.toString() ?? ''), TextButton.icon( icon: const Icon(Icons.copy), - label: const ButtonText('Copy to clipboard'), + label: const Text('Copy to clipboard'), onPressed: () { final text = '${errorObject?.toString() ?? ''} \n' @@ -378,12 +360,15 @@ class _MessageContentState extends ConsumerState<_MessageContent> { ], ], ); + + Widget _buildContent(AppLocalizations localizations) { + if (_messageDownloadError) { + return _buildMessageDownloadErrorContent(localizations); } final message = widget.message; return MimeMessageDownloader( mimeMessage: message.mimeMessage, - mailClient: message.mailClient, fetchMessageContents: ( mimeMessage, { int? maxSize, @@ -421,6 +406,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { uri, mode: ref.read(settingsProvider).urlLaunchMode, ); + return Future.value(true); }, onZoomed: (controller, factor) { @@ -430,6 +416,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { }); } }, + logger: logger, builder: (context, mimeMessage) { final textCalendarPart = mimeMessage.getAlternativePart(MediaSubtype.textCalendar); @@ -439,10 +426,14 @@ 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; }, ); @@ -450,7 +441,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { bool _shouldImagesBeBlocked(MimeMessage mimeMessage) { var blockExternalImages = widget.blockExternalContents || - ref.read(settingsProvider).blockExternalImages || + _settingsBlockExternalImages || widget.message.source.shouldBlockImages; if (blockExternalImages) { final html = mimeMessage.decodeTextHtmlPart(); @@ -459,6 +450,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { blockExternalImages = false; } } + return blockExternalImages; } @@ -476,8 +468,7 @@ class _MessageContentState extends ConsumerState<_MessageContent> { _blockExternalImages = blockExternalImages; }); } - locator() - .cancelNotificationForMailMessage(widget.message); + NotificationService.instance.cancelNotificationForMessage(widget.message); if (_notifyMarkedAsSeen) { widget.message.source.onMarkedAsSeen(widget.message, true); } @@ -494,22 +485,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); @@ -532,8 +521,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( @@ -541,33 +529,38 @@ 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, + logger: logger, ), ), ); 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 { @@ -601,24 +594,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( @@ -627,8 +633,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); } }, ), @@ -636,19 +643,22 @@ class _ThreadSequenceButtonState extends State { } void _removeOverlay() { - _overlayEntry!.remove(); - _overlayEntry = null; + final overlayEntry = _overlayEntry; + if (overlayEntry != null) { + overlayEntry.remove(); + _overlayEntry = null; + } } void _select(Message message) { _removeOverlay(); - locator().push(Routes.mailDetails, arguments: message); + context.pushNamed(Routes.mailDetails, extra: message); } 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; @@ -669,13 +679,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( @@ -706,7 +718,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,30 +734,36 @@ 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 { return ElevatedButton( - child: ButtonText(localizations.detailsSendReadReceiptAction), + child: Text(localizations.detailsSendReadReceiptAction), onPressed: () async { 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; }); @@ -771,40 +790,40 @@ class _UnsubscribeButtonState extends State { return const PlatformProgressIndicator(); } final localizations = context.text; - if (widget.message.isNewsletterUnsubscribed) { - return widget.message.isNewsLetterSubscribable - ? PlatformElevatedButton( - onPressed: _resubscribe, - child: - ButtonText(localizations.detailsNewsletterActionResubscribe), - ) - : Text( - localizations.detailsNewsletterStatusUnsubscribed, - style: const TextStyle(fontStyle: FontStyle.italic), - ); - } else { - return PlatformElevatedButton( - onPressed: _unsubscribe, - child: ButtonText(localizations.detailsNewsletterActionUnsubscribe), - ); - } + + return widget.message.isNewsletterUnsubscribed + ? widget.message.isNewsLetterSubscribable + ? PlatformElevatedButton( + onPressed: _resubscribe, + child: Text(localizations.detailsNewsletterActionResubscribe), + ) + : Text( + localizations.detailsNewsletterStatusUnsubscribed, + style: const TextStyle(fontStyle: FontStyle.italic), + ) + : PlatformElevatedButton( + onPressed: _unsubscribe, + child: Text(localizations.detailsNewsletterActionUnsubscribe), + ); } 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; }); @@ -812,12 +831,15 @@ class _UnsubscribeButtonState extends State { setState(() { 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); + // TODO(RV): store flag only when server/mailbox supports arbitrary flags? + await mailClient.store( + MessageSequence.fromMessage(mime), + [Message.keywordFlagUnsubscribed], + action: StoreAction.remove, + ); } - await LocalizedDialogHelper.showTextDialog( + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( context, subscribed ? localizations.detailsNewsletterResubscribeSuccessTitle @@ -826,28 +848,31 @@ 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, action: localizations.detailsNewsletterUnsubscribeDialogAction, query: localizations.detailsNewsletterUnsubscribeDialogQuestion(listName), ); - if (confirmation == true) { + if (confirmation ?? false) { 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'); @@ -860,26 +885,31 @@ class _UnsubscribeButtonState extends State { setState(() { widget.message.isNewsletterUnsubscribed = true; }); - //TODO store flag only when server/mailbox supports arbitrary flags? + // TODO(RV): 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_details_screen_for_notification.dart b/lib/screens/message_details_screen_for_notification.dart new file mode 100644 index 0000000..1833202 --- /dev/null +++ b/lib/screens/message_details_screen_for_notification.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +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 +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) => ErrorScreen(error: error, stackTrace: stack), + data: (data) => MessageDetailsScreen( + message: data, + blockExternalContent: blockExternalContent, + ), + ); + } +} diff --git a/lib/screens/message_source_screen.dart b/lib/screens/message_source_screen.dart index a6c467f..50124ef 100644 --- a/lib/screens/message_source_screen.dart +++ b/lib/screens/message_source_screen.dart @@ -4,80 +4,41 @@ 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 '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; -import '../locator.dart'; +import '../account/provider.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../logger.dart'; +import '../mail/model.dart'; import '../models/compose_data.dart'; import '../models/date_sectioned_message_source.dart'; import '../models/message.dart'; import '../models/message_source.dart'; 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'; +import '../notification/service.dart'; +import '../routes/routes.dart'; +import '../scaffold_messenger/service.dart'; import '../settings/provider.dart'; +import '../settings/theme/icon_service.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/inherited_widgets.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 } -/// Loads a message source future -class AsyncMessageSourceScreen extends StatelessWidget { - const AsyncMessageSourceScreen({ - super.key, - required this.messageSourceFuture, - }); - final Future messageSourceFuture; - - @override - Widget build(BuildContext context) => FutureBuilder( - future: messageSourceFuture, - builder: (context, snapshot) { - final data = snapshot.data; - if (data != null) { - return MessageSourceScreen(messageSource: data); - } - if (snapshot.hasError) { - return BasePage( - title: context.text.errorTitle, - content: Center( - child: Text(context.text.detailsErrorDownloadInfo), - ), - ); - } - return BasePage( - title: context.text.homeLoadingMessageSourceTitle, - content: Center( - child: PlatformCircularProgressIndicator(), - ), - ); - }, - ); -} - /// Displays a list of mails class MessageSourceScreen extends ConsumerStatefulWidget { + /// Creates a new [MessageSourceScreen] const MessageSourceScreen({ super.key, required this.messageSource, }); + + /// The source for the shown messages final MessageSource messageSource; @override @@ -95,28 +56,27 @@ class _MessageSourceScreenState extends ConsumerState bool _isInSearchMode = false; bool _hasSearchInput = false; late TextEditingController _searchEditingController; - bool _updateMessageSource = false; @override void initState() { super.initState(); _searchEditingController = TextEditingController(); - _sectionedMessageSource = DateSectionedMessageSource(widget.messageSource); + _sectionedMessageSource = DateSectionedMessageSource( + widget.messageSource, + firstDayOfWeek: context.firstDayOfWeek, + ); _sectionedMessageSource.addListener(_update); - _messageLoader = initMessageSource(); + _messageLoader = _initMessageSource(); } - Future initMessageSource() { - //print('${DateTime.now()}: initMessageSource()'); - return _sectionedMessageSource.init(); - //print('${DateTime.now()}: loaded ${_sectionedMessageSource.size} messages'); - } + Future _initMessageSource() => _sectionedMessageSource.init(); @override void dispose() { _searchEditingController.dispose(); - _sectionedMessageSource.removeListener(_update); - _sectionedMessageSource.dispose(); + _sectionedMessageSource + ..removeListener(_update) + ..dispose(); super.dispose(); } @@ -125,16 +85,24 @@ class _MessageSourceScreenState extends ConsumerState } void _search(String query) { - if (query.isEmpty) { + final trimmedQuery = query.trim(); + if (trimmedQuery.isEmpty) { setState(() { _isInSearchMode = false; }); + return; } - final search = MailSearch(query, SearchQueryType.allTextHeaders); - final searchSource = _sectionedMessageSource.messageSource.search(search); - locator() - .push(Routes.messageSource, arguments: searchSource); + final search = MailSearch(trimmedQuery, SearchQueryType.allTextHeaders); + final searchSource = + _sectionedMessageSource.messageSource.search(context.text, search); + context.pushNamed( + Routes.messageSource, + pathParameters: { + Routes.pathParameterEmail: widget.messageSource.account.email, + }, + extra: searchSource, + ); setState(() { _isInSearchMode = false; }); @@ -143,29 +111,10 @@ 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; - if (source is ErrorMessageSource) { - return buildForLoadingError(context, localizations, source); - } - 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 searchColor = theme.brightness == Brightness.light ? theme.colorScheme.onSecondary : theme.colorScheme.onPrimary; @@ -195,8 +144,11 @@ class _MessageSourceScreenState extends ConsumerState }, ) : (PlatformInfo.isCupertino) - ? Text(source.name ?? '') - : Base.buildTitle(source.name ?? '', source.description ?? ''); + ? Text(source.localizedName(localizations, settings)) + : BaseTitle( + title: source.localizedName(localizations, settings), + subtitle: source.description, + ); final appBarActions = [ if (_isInSearchMode && _hasSearchInput) @@ -256,7 +208,6 @@ class _MessageSourceScreenState extends ConsumerState // ], // ), ]; - final i18nService = locator(); Widget? zeroPosWidget; if (_sectionedMessageSource.isInitialized && source.size == 0) { final emptyMessage = source.isSearch @@ -267,7 +218,7 @@ class _MessageSourceScreenState extends ConsumerState child: Text(emptyMessage), ); } else if (source.supportsDeleteAll) { - final iconService = locator(); + final iconService = IconService.instance; final style = TextButton.styleFrom(foregroundColor: Colors.grey[600]); final textStyle = Theme.of(context).textTheme.labelLarge; zeroPosWidget = Padding( @@ -305,6 +256,7 @@ class _MessageSourceScreenState extends ConsumerState final isSentFolder = source.isSent; final showSearchTextField = PlatformInfo.isCupertino && source.supportsSearching; + final hasAccountWithError = ref.watch(hasAccountWithErrorProvider); return PlatformPageScaffold( bottomBar: _isInSelectionMode @@ -313,11 +265,11 @@ class _MessageSourceScreenState extends ConsumerState ? CupertinoStatusBar( info: CupertinoStatusBar.createInfo(source.description), rightAction: PlatformIconButton( - //TODO use CupertinoIcons.create once it's not buggy anymore + // TODO(RV): use CupertinoIcons.create once available icon: const Icon(CupertinoIcons.pen), - onPressed: () => locator().push( + onPressed: () => context.pushNamed( Routes.mailCompose, - arguments: ComposeData( + extra: ComposeData( null, MessageBuilder(), ComposeAction.newMessage, @@ -330,328 +282,305 @@ class _MessageSourceScreenState extends ConsumerState drawer: const AppDrawer(), 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) ? PlatformAppBar( title: appBarTitle, trailingActions: appBarActions, - leading: (locator().hasAccountsWithErrors()) - ? const MenuWithBadge() - : null, + leading: hasAccountWithError ? const MenuWithBadge() : 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, + 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: hasAccountWithError + ? MenuWithBadge( + iOSText: + '\u2329 ${localizations.accountsTitle}', + ) + : null, + previousPageTitle: + source.parentName ?? localizations.accountsTitle, + floating: !_isInSearchMode, + pinned: _isInSearchMode, + actions: appBarActions, + cupertinoTransitionBetweenRoutes: true, + ), + if (showSearchTextField) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: CupertinoSearch( + messageSource: source, + ), + ), ), - 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(() {}); - }, - ); - } - final element = snapshot.data; + if (zeroPosWidget != null) + SliverToBoxAdapter( + child: zeroPosWidget, + ), + SliverFixedExtentList.builder( + itemExtent: 52, + itemBuilder: (context, index) => + 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; + + if (element == null) { + return const EmptyMessage(); + } + final section = element.section; + + if (section != null) { + final text = context.getDateRangeName( + section.range, + ); - if (element == null) { - return const EmptyMessage(); + return GestureDetector( + onLongPress: () async { + _selectedMessages = + await _sectionedMessageSource + .getMessagesForSection(section); + for (final m in _selectedMessages) { + m.isSelected = true; } - final section = element.section; - if (section != null) { - final text = i18nService.formatDateRange( - section.range, section.date); - return GestureDetector( - onLongPress: () async { - _selectedMessages = + setState(() { + _isInSelectionMode = true; + }); + }, + onTap: !_isInSelectionMode + ? null + : () async { + final sectionMessages = await _sectionedMessageSource - .getMessagesForSection(section); - for (final m in _selectedMessages) { - m.isSelected = true; + .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(() { - _isInSelectionMode = true; - }); + setState(() {}); }, - 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, - ), - ), - ), - const Divider() - ], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 8, + bottom: 4, + top: 16, ), - ); - } - 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: Text( + text, + style: TextStyle( + color: theme.colorScheme.secondary, + ), ), ), - secondaryBackground: Container( - color: swipeRightToLeftAction - .colorBackground, + const Divider(), + ], + ), + ); + } + final message = element.message; + + if (message == null) { + return const SizedBox.shrink(); + } + + 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), - 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), - ), - ), - ], + horizontal: 8, + ), + child: Text( + swipeLeftToRightAction + .name(localizations), + style: TextStyle( + color: swipeLeftToRightAction + .colorForeground, + ), ), ), - child: MessageOverview( - message, - _isInSelectionMode, - onMessageTap, - onMessageLongPress, - isSentMessage: isSentFolder, + Icon( + swipeLeftToRightAction.icon, + color: swipeLeftToRightAction.colorIcon, ), - 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; - }, - ), + ], + ), + ), + 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, + ), + ), + ), + ], + ), + ), + child: MessageOverview( + message, + _isInSelectionMode, + onMessageTap, + onMessageLongPress, + isSentMessage: isSentFolder, + ), + confirmDismiss: (direction) { + final swipeAction = + direction == DismissDirection.startToEnd + ? swipeLeftToRightAction + : swipeRightToLeftAction; + fireSwipeAction( + localizations, + swipeAction, + message, + ); + + return Future.value( + swipeAction.isMessageMoving, + ); + }, + ); + }, ), - ], - ), + itemCount: _sectionedMessageSource.size, + ), + ], ), - ); - } - }, - ), + ), + ); + } + }, ), ); } @@ -662,7 +591,8 @@ class _MessageSourceScreenState extends ConsumerState final isJunk = source.isJunk; final isAnyUnseen = _selectedMessages.any((m) => !m.isSeen); final isAnyUnflagged = _selectedMessages.any((m) => !m.isFlagged); - final iconService = locator(); + final iconService = IconService.instance; + return PlatformBottomBar( cupertinoBlurBackground: true, child: SafeArea( @@ -676,50 +606,50 @@ class _MessageSourceScreenState extends ConsumerState if (isAnyUnseen) PlatformIconButton( icon: Icon(iconService.messageIsNotSeen), - onPressed: () => handleMultipleChoice(_MultipleChoice.seen), + onPressed: () => _handleMultipleChoice(_MultipleChoice.seen), ) else PlatformIconButton( icon: Icon(iconService.messageIsSeen), - onPressed: () => handleMultipleChoice(_MultipleChoice.unseen), + onPressed: () => _handleMultipleChoice(_MultipleChoice.unseen), ), if (isAnyUnflagged) PlatformIconButton( icon: Icon(iconService.messageIsNotFlagged), - onPressed: () => handleMultipleChoice(_MultipleChoice.flag), + onPressed: () => _handleMultipleChoice(_MultipleChoice.flag), ) else PlatformIconButton( icon: Icon(iconService.messageIsFlagged), - onPressed: () => handleMultipleChoice(_MultipleChoice.unflag), + onPressed: () => _handleMultipleChoice(_MultipleChoice.unflag), ), if (isJunk) PlatformIconButton( icon: Icon(iconService.messageActionMoveFromJunkToInbox), - onPressed: () => handleMultipleChoice(_MultipleChoice.inbox), + onPressed: () => _handleMultipleChoice(_MultipleChoice.inbox), ) else PlatformIconButton( icon: Icon(iconService.messageActionMoveToJunk), - onPressed: () => handleMultipleChoice(_MultipleChoice.junk), + onPressed: () => _handleMultipleChoice(_MultipleChoice.junk), ), const Spacer(), if (isTrash) PlatformIconButton( icon: Icon(iconService.messageActionMoveToInbox), - onPressed: () => handleMultipleChoice(_MultipleChoice.inbox), + onPressed: () => _handleMultipleChoice(_MultipleChoice.inbox), ) else PlatformIconButton( icon: Icon(iconService.messageActionDelete), - onPressed: () => handleMultipleChoice(_MultipleChoice.delete), + onPressed: () => _handleMultipleChoice(_MultipleChoice.delete), ), PlatformIconButton( icon: const Icon(Icons.close), onPressed: leaveSelectionMode, ), PlatformPopupMenuButton<_MultipleChoice>( - onSelected: handleMultipleChoice, + onSelected: _handleMultipleChoice, itemBuilder: (context) => [ PlatformPopupMenuItem( value: _MultipleChoice.forwardAsAttachment, @@ -784,7 +714,8 @@ class _MessageSourceScreenState extends ConsumerState child: IconText( icon: Icon(iconService.messageIsNotFlagged), label: Text( - localizations.messageActionMultipleMarkUnflagged), + localizations.messageActionMultipleMarkUnflagged, + ), ), ), if (source.supportsMessageFolders) ...[ @@ -855,14 +786,45 @@ class _MessageSourceScreenState extends ConsumerState ); } - Future handleMultipleChoice(_MultipleChoice choice) async { + Future _handleMultipleChoice(_MultipleChoice choice) async { final source = _sectionedMessageSource.messageSource; - final localizations = locator().localizations; + final localizations = context.text; if (_selectedMessages.isEmpty) { - locator() - .showTextSnackBar(localizations.multipleSelectionNeededInfo); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.multipleSelectionNeededInfo, + ); + return; } + + try { + final endSelectionMode = + await _handleChoice(choice, source, localizations); + if (endSelectionMode) { + setState(() { + _isInSelectionMode = false; + }); + } + } catch (e, s) { + logger.e( + 'Unable to handle multiple choice $choice: $e', + error: e, + stackTrace: s, + ); + + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.multipleSelectionActionFailed(e.toString()), + ); + } + } + + Future _handleChoice( + _MultipleChoice choice, + MessageSource source, + AppLocalizations localizations, + ) async { var endSelectionMode = true; switch (choice) { case _MultipleChoice.forwardAsAttachment: @@ -874,13 +836,21 @@ 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; @@ -910,26 +880,38 @@ 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( - _selectedMessages, MailboxFlag.archive, notification); + localizations, + _selectedMessages, + MailboxFlag.archive, + notification, + ); break; case _MultipleChoice.viewInSafeMode: - if (_selectedMessages.isNotEmpty) { - await locator().push(Routes.mailDetails, - arguments: - DisplayMessageArguments(_selectedMessages.first, true)); + if (_selectedMessages.isNotEmpty && context.mounted) { + unawaited(context.pushNamed( + Routes.mailDetails, + extra: _selectedMessages.first, + queryParameters: { + Routes.queryParameterBlockExternalContent: 'true', + }, + )); } endSelectionMode = false; leaveSelectionMode(); break; case _MultipleChoice.addNotification: endSelectionMode = false; - final notificationService = locator(); + final notificationService = NotificationService.instance; for (final message in _selectedMessages) { await notificationService .sendLocalNotificationForMailMessage(message); @@ -937,11 +919,8 @@ class _MessageSourceScreenState extends ConsumerState leaveSelectionMode(); break; } - if (endSelectionMode) { - setState(() { - _isInSelectionMode = false; - }); - } + + return endSelectionMode; } Future forwardAsAttachments() async { @@ -953,22 +932,26 @@ 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 = []; 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) { @@ -985,32 +968,33 @@ class _MessageSourceScreenState extends ConsumerState } final composeFuture = futures.isEmpty ? null : Future.wait(futures); final composeData = ComposeData( - _selectedMessages, builder, ComposeAction.forward, - future: composeFuture); - await locator() - .push(Routes.mailCompose, arguments: composeData, fade: true); + _selectedMessages, + builder, + ComposeAction.forward, + future: composeFuture, + ); + unawaited(context.pushNamed(Routes.mailCompose, extra: composeData)); } - Future? addMessageAttachment(Message message, MessageBuilder builder) { + 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 { builder.addMessagePart(mime); } - return null; + + return Future.value(); } 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); @@ -1023,8 +1007,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); })); @@ -1032,37 +1016,43 @@ class _MessageSourceScreenState extends ConsumerState composeFuture = futures.isEmpty ? null : Future.wait(futures); } } + return composeFuture; } void move() { - final localizations = locator().localizations; - var account = locator().currentAccount!; + final localizations = context.text; + 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) { - 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) { // ok, all messages belong to one account: - account = - locator().getAccountFor(mailClients.first.account)!; + final singleAccount = ref.read( + findRealAccountByEmailProvider( + email: mailClients.first.account.email, + ), + ); + if (singleAccount != null) { + account = singleAccount; + } } } - final mailbox = account.isVirtual - ? null // //TODO set current mailbox, e.g. current: widget.messageSource.currentMailbox, - : _selectedMessages.first.mailClient.selectedMailbox; + LocalizedDialogHelper.showWidgetDialog( context, SingleChildScrollView( child: MailboxTree( account: account, onSelected: moveTo, - current: mailbox, ), ), title: localizations.multipleMoveTitle(_selectedMessages.length), @@ -1074,16 +1064,24 @@ class _MessageSourceScreenState extends ConsumerState setState(() { _isInSelectionMode = false; }); - locator().pop(); // alert + context.pop(); // alert final source = _sectionedMessageSource.messageSource; - final localizations = locator().localizations; - final account = locator().currentAccount!; + final localizations = context.text; + final account = widget.messageSource.account; if (account.isVirtual) { - await source.moveMessagesToFlag(_selectedMessages, mailbox.flags.first, - localizations.moveSuccess(mailbox.name)); + await source.moveMessagesToFlag( + localizations, + _selectedMessages, + mailbox.flags.first, + localizations.moveSuccess(mailbox.name), + ); } else { await source.moveMessages( - _selectedMessages, mailbox, localizations.moveSuccess(mailbox.name)); + localizations, + _selectedMessages, + mailbox, + localizations.moveSuccess(mailbox.name), + ); } } @@ -1106,17 +1104,18 @@ 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); - 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)); + } } } } @@ -1139,7 +1138,11 @@ 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; @@ -1147,11 +1150,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; @@ -1163,42 +1168,11 @@ 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; + final firstMessage = widget.messageSource.cache.first; + var expunge = + firstMessage?.mimeMessage.hasFlag(MessageFlags.deleted) ?? false; final confirmed = await LocalizedDialogHelper.showWidgetDialog( context, Column( @@ -1206,7 +1180,7 @@ class _MessageSourceScreenState extends ConsumerState children: [ Text(localizations.homeDeleteAllQuestion), CheckboxText( - initialValue: false, + initialValue: expunge, onChanged: (value) => expunge = value, text: localizations.homeDeleteAllScrubOption, ), @@ -1216,12 +1190,12 @@ class _MessageSourceScreenState extends ConsumerState actions: [ PlatformDialogActionText( text: localizations.actionCancel, - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => context.pop(false), ), PlatformDialogActionText( text: localizations.homeDeleteAllAction, isDestructiveAction: true, - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => context.pop(true), ), ], ); @@ -1243,18 +1217,23 @@ class _MessageSourceScreenState extends ConsumerState } }; } - locator() - .showTextSnackBar(localizations.homeDeleteAllSuccess, undo: undo); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.homeDeleteAllSuccess, + undo: undo, + ); } } } 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; @@ -1277,9 +1256,9 @@ class _CheckboxTextState extends State { title: Text(widget.text), value: _value, onChanged: (value) { - widget.onChanged(value == true); + widget.onChanged(value ?? false); setState(() { - _value = (value == true); + _value = value ?? false; }); }, ); @@ -1303,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/screens/all_screens.dart b/lib/screens/screens.dart similarity index 59% rename from lib/screens/all_screens.dart rename to lib/screens/screens.dart index 981864f..4842203 100644 --- a/lib/screens/all_screens.dart +++ b/lib/screens/screens.dart @@ -1,11 +1,16 @@ +export '../location/view.dart'; +export '../lock/view.dart'; export 'account_add_screen.dart'; export 'account_edit_screen.dart'; export 'account_server_details_screen.dart'; export 'compose_screen.dart'; -export 'location_screen.dart'; -export 'lock_screen.dart'; +export 'email_screen.dart'; +export 'mail_screen.dart'; +export 'mail_screen_for_default_account.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 5049265..4296d48 100644 --- a/lib/screens/sourcecode_screen.dart +++ b/lib/screens/sourcecode_screen.dart @@ -1,46 +1,43 @@ 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 { - final MimeMessage? mimeMessage; - const SourceCodeScreen({Key? key, required this.mimeMessage}) - : super(key: key); + const SourceCodeScreen({super.key, required this.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/splash_screen.dart b/lib/screens/splash_screen.dart index d9296fd..553a068 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,11 +1,36 @@ import 'dart:math'; -import 'package:enough_mail_app/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); +import '../localization/extension.dart'; + +/// Displays a splash screen +class SplashScreen extends StatefulWidget { + /// Creates a new [SplashScreen] + const SplashScreen({super.key}); + + static var _isShown = false; + + /// Is the splash screen shown? + static bool get isShown => _isShown; + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State { + @override + void initState() { + super.initState(); + SplashScreen._isShown = true; + } + + @override + void dispose() { + SplashScreen._isShown = false; + super.dispose(); + } @override Widget build(BuildContext context) { @@ -13,14 +38,16 @@ class SplashScreen extends StatelessWidget { final texts = [ localizations.splashLoading1, localizations.splashLoading2, - localizations.splashLoading3 + localizations.splashLoading3, ]; + final index = Random().nextInt(texts.length); final text = texts[index]; final timeOfDay = TimeOfDay.now(); final isNight = timeOfDay.hour >= 22 || timeOfDay.hour <= 6; final splashColor = isNight ? Colors.black87 : const Color(0xff99cc00); final textColor = isNight ? Colors.white : Colors.black87; + return PlatformScaffold( body: Container( color: splashColor, diff --git a/lib/screens/webview_screen.dart b/lib/screens/webview_screen.dart index 1836aad..fc3497f 100644 --- a/lib/screens/webview_screen.dart +++ b/lib/screens/webview_screen.dart @@ -1,31 +1,26 @@ -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; @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 801ea03..53a91f4 100644 --- a/lib/screens/welcome_screen.dart +++ b/lib/screens/welcome_screen.dart @@ -1,120 +1,120 @@ -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 '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'; -import '../l10n/app_localizations.g.dart'; -import '../locator.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../routes/routes.dart'; +import '../settings/theme/icon_service.dart'; +import '../widgets/legalese.dart'; +/// Displays a welcome screen class WelcomeScreen extends StatelessWidget { - const WelcomeScreen({Key? key}) : super(key: key); + /// Creates a [WelcomeScreen] + const WelcomeScreen({super.key}); @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); - }, - showDoneButton: true, - next: ButtonText(localizations.actionNext), - showNextButton: true, - skip: ButtonText(localizations.actionSkip), - showSkipButton: true, + child: SafeArea( + child: PlatformScaffold( + body: IntroductionScreen( + pages: pages, + done: Text(localizations.actionDone), + onDone: () { + context.goNamed(Routes.accountAdd); + }, + next: Text(localizations.actionNext), + skip: Text(localizations.actionSkip), + showSkipButton: true, + ), ), ), ); //Material App } - List _buildPages(AppLocalizations localizations) { - return [ - PageViewModel( - title: localizations.welcomePanel1Title, - body: localizations.welcomePanel1Text, - image: Image.asset( - 'assets/images/maily.png', - height: 200, - fit: BoxFit.cover, + List _buildPages( + BuildContext context, + AppLocalizations localizations, + ) => + [ + 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(context, 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(context, 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(context, 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(context, localizations), ), - footer: _buildFooter(localizations), - ), - ]; - } + ]; - Widget _buildFooter(AppLocalizations localizations) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Shimmer( - duration: const Duration(seconds: 4), - interval: const Duration(seconds: 6), - child: PlatformFilledButtonIcon( - icon: Icon(locator().email), - label: Center( - child: PlatformText(localizations.welcomeActionSignIn), + Widget _buildFooter(BuildContext context, 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(IconService.instance.email), + label: Center( + child: Text(localizations.welcomeActionSignIn), + ), + onPressed: () { + context.goNamed(Routes.accountAdd); + }, ), - onPressed: () { - locator() - .push(Routes.accountAdd, arguments: true); - }, ), ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), - child: Legalese(), - ), - ], - ); - } + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Legalese(), + ), + ], + ); } diff --git a/lib/services/app_service.dart b/lib/services/app_service.dart deleted file mode 100644 index 0e3ef09..0000000 --- a/lib/services/app_service.dart +++ /dev/null @@ -1,177 +0,0 @@ -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; - - var _ignoreBiometricsCheckAtNextResume = false; - var _ignoreBiometricsCheckAtNextResumeTS = DateTime.now(); - set ignoreBiometricsCheckAtNextResume(bool value) { - _ignoreBiometricsCheckAtNextResume = value; - if (value) { - _ignoreBiometricsCheckAtNextResumeTS = DateTime.now(); - } - } - - bool get isInBackground => appLifecycleState != AppLifecycleState.resumed; - Future Function(List sharedData)? onSharedData; - DateTime? _lastPausedTimeStamp; - - /// Handles when app life cycle has changed - Future didChangeAppLifecycleState( - AppLifecycleState state, - Settings settings, - ) async { - logger.d('didChangeAppLifecycleState: $state'); - appLifecycleState = state; - 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); - - return; - } - } - if (settings.lockTimePreference - .requiresAuthorization(_lastPausedTimeStamp)) { - 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); - } - - 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(); - break; - case AppLifecycleState.detached: - // TODO: Check if AppLifecycleState.detached needs to be handled - break; - case AppLifecycleState.hidden: - // TODO: Handle this case. - 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 cc8b4fd..0000000 --- a/lib/services/background_service.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'dart:convert'; -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 'package:flutter/foundation.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../locator.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); - }, (String taskId) { - BackgroundFetch.finish(taskId); - }); - await BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); - } - - static void 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(); - 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) { - notificationService.sendLocalNotificationForMail( - mimeMessage, - mailClient, - ); - } - } - } - - await mailClient.disconnect(); - } catch (e, s) { - if (kDebugMode) { - print( - 'Unable to process background operation for ${account.name}: $e $s'); - } - } - } -} diff --git a/lib/services/contact_service.dart b/lib/services/contact_service.dart deleted file mode 100644 index 1f999f2..0000000 --- a/lib/services/contact_service.dart +++ /dev/null @@ -1,64 +0,0 @@ -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'; - -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/date_service.dart b/lib/services/date_service.dart deleted file mode 100644 index 71a9f18..0000000 --- a/lib/services/date_service.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:enough_mail_app/locator.dart'; - -import 'i18n_service.dart'; - -enum DateSectionRange { - future, - tomorrow, - today, - yesterday, - thisWeek, - lastWeek, - thisMonth, - monthOfThisYear, - monthAndYear -} - -class DateService { - DateTime? _today; - late DateTime _tomorrow; - late DateTime _dayAfterTomorrow; - DateTime? _yesterday; - 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)); - _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) { - _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))); - } - } - _lastWeek = _thisWeek!.subtract(const Duration(days: 7)); - } - - DateSectionRange determineDateSection(DateTime localTime) { - if (_today == null || _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(_yesterday!)) { - return DateSectionRange.yesterday; - } 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; - } - } - return DateSectionRange.monthAndYear; - } -} diff --git a/lib/services/i18n_service.dart b/lib/services/i18n_service.dart deleted file mode 100644 index eb70304..0000000 --- a/lib/services/i18n_service.dart +++ /dev/null @@ -1,278 +0,0 @@ -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_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'; - -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; - Locale? get 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(); - if (countryCode == null) { - firstDayOfWeek = DateTime.monday; - } else { - firstDayOfWeek = - 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); - }); - } - - 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 { - if (useLongFormat) { - date = _dateTimeFormatLong.format(dateTime); - } else { - date = _dateTimeFormat.format(dateTime); - } - } - return date; - } - - String formatDate(DateTime? dateTime, {bool useLongFormat = false}) { - if (dateTime == null) { - return _localizations.dateUndefined; - } - - if (useLongFormat) { - return _dateFormatLong.format(dateTime); - } else { - return _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) { - return _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) { - return 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 { - final int day; - final String name; - - const WeekDay(this.day, this.name); -} diff --git a/lib/services/key_service.dart b/lib/services/key_service.dart deleted file mode 100644 index ef634ba..0000000 --- a/lib/services/key_service.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// 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; - -class KeyService { - KeyService(); - - Future init() async { - try { - final text = await rootBundle.loadString('assets/keys.txt'); - final lines = - text.contains('\r\n') ? text.split('\r\n') : text.split('\n'); - for (final line in lines) { - if (line.startsWith('#')) { - continue; - } - if (line.startsWith('giphy:')) { - _giphy = line.substring('giphy:'.length).trim(); - } else if (line.startsWith('oauth/')) { - final splitIndex = line.indexOf(':', 'oauth/'.length); - final key = line.substring('oauth/'.length, splitIndex); - final value = line.substring(splitIndex + 1); - final valueIndex = value.indexOf(':'); - if (valueIndex == -1) { - oauth[key] = OauthClientId(value, null); - } else { - 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.'); - } - } - } - - String? _giphy; - String? get giphy => _giphy; - bool get hasGiphy => (_giphy != null); - - final oauth = {}; - - bool hasOauthFor(String incomingHostname) { - return (oauth[incomingHostname] != null); - } -} diff --git a/lib/services/mail_service.dart b/lib/services/mail_service.dart deleted file mode 100644 index ebda47d..0000000 --- a/lib/services/mail_service.dart +++ /dev/null @@ -1,828 +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/widgets.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.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'; -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'; - -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), - _localizations.unifiedAccountName, - ); - 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( - 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.name, - ); - } - } - final accountWithErrors = _accountsWithErrors ?? []; - if (!accountWithErrors.contains(account)) { - accountWithErrors.add(account); - _accountsWithErrors ??= accountsWithErrors; - } - return ErrorMessageSource(account); - } - - 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, - 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); - } - 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; - if (state != null) { - state.account = newAccount; - state.accounts = accounts; - state.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, 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); - 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); - } - 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 { - 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 - } - // 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) { - 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); - } - if (state != null) { - state.messageSource = messageSource; - state.account = _currentAccount; - state.accounts = accounts; - } - } else if (state != null) { - state.accounts = accounts; - } - - 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); - final source = await getMessageSourceFor(account); - final connected = source is! ErrorMessageSource; - if (connected) { - final accountsWithErrors = _accountsWithErrors; - if (accountsWithErrors != null) { - accountsWithErrors.remove(account); - } - accountsWithoutErrors.add(account); - //TODO update unified account message source after connecting account - } - return connected; - } - - /// 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, - BuildContext context, { - bool updateContext = true, - }) 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); - if (updateContext) { - final state = MailServiceWidget.of(context); - if (state != null) { - state.messageSource = messageSource; - } - } - } - 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); - } - } - - @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 ec26b49..0000000 --- a/lib/services/navigation_service.dart +++ /dev/null @@ -1,96 +0,0 @@ -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'; - -class NavigationService { - final navigatorKey = GlobalKey(); - - 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); - 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) { - 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); - } - } - - // 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) { - // history.popUntil(routeName); - navigatorKey.currentState!.popUntil(ModalRoute.withName(routeName)); - _currentRouteName = routeName; - } - - void pop([Object? result]) { - // history.pop(); - navigatorKey.currentState!.pop(result); - _currentRouteName = null; - } -} - -class FadeRoute extends PageRouteBuilder { - final Widget page; - 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, - ), - ); -} diff --git a/lib/services/scaffold_messenger_service.dart b/lib/services/scaffold_messenger_service.dart deleted file mode 100644 index ddad366..0000000 --- a/lib/services/scaffold_messenger_service.dart +++ /dev/null @@ -1,58 +0,0 @@ -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 'package:flutter/material.dart'; - -class ScaffoldMessengerService { - final GlobalKey scaffoldMessengerKey = - GlobalKey(); - - final _statusBarStates = []; - CupertinoStatusBarState? _statusBarState; - set statusBarState(CupertinoStatusBarState state) { - final current = _statusBarState; - if (current != null) { - _statusBarStates.add(current); - } - _statusBarState = state; - } - - void popStatusBarState() { - if (_statusBarStates.isNotEmpty) { - _statusBarState = _statusBarStates.removeLast(); - } else { - _statusBarState = null; - } - } - - SnackBar _buildTextSnackBar(String text, {Function()? undo}) { - return SnackBar( - content: Text(text), - action: undo == null - ? null - : SnackBarAction( - label: locator().localizations.actionUndo, - onPressed: undo, - ), - ); - } - - void _showSnackBar(SnackBar snackBar) { - scaffoldMessengerKey.currentState?.showSnackBar(snackBar); - } - - void showTextSnackBar(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)); - } - } else { - _showSnackBar(_buildTextSnackBar(text, undo: undo)); - } - } -} diff --git a/lib/settings/model.g.dart b/lib/settings/model.g.dart index 0e6e76d..f9a485b 100644 --- a/lib/settings/model.g.dart +++ b/lib/settings/model.g.dart @@ -113,6 +113,7 @@ const _$ReadReceiptDisplaySettingEnumMap = { const _$LaunchModeEnumMap = { LaunchMode.platformDefault: 'platformDefault', LaunchMode.inAppWebView: 'inAppWebView', + LaunchMode.inAppBrowserView: 'inAppBrowserView', LaunchMode.externalApplication: 'externalApplication', LaunchMode.externalNonBrowserApplication: 'externalNonBrowserApplication', }; diff --git a/lib/settings/provider.dart b/lib/settings/provider.dart index dc24f58..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 '../locator.dart'; +import '../account/model.dart'; +import '../localization/extension.dart'; import '../logger.dart'; -import '../models/account.dart'; import '../models/compose_data.dart'; -import '../services/i18n_service.dart'; import 'model.dart'; import 'storage.dart'; @@ -47,32 +47,40 @@ 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( + BuildContext context, + RealAccount account, + ComposeAction composeAction, + String? languageCode, + ) { if (!state.signatureActions.contains(composeAction)) { return ''; } - return account.signatureHtml ?? 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/storage.dart b/lib/settings/storage.dart index 537362a..8ccdd33 100644 --- a/lib/settings/storage.dart +++ b/lib/settings/storage.dart @@ -8,11 +8,13 @@ import 'model.dart'; /// Allows to read and store settings class SettingsStorage { /// Creates a [SettingsStorage] - const SettingsStorage(); + const SettingsStorage({ + FlutterSecureStorage storage = const FlutterSecureStorage(), + }) : _storage = storage; static const String _keySettings = 'settings'; - final FlutterSecureStorage _storage = const FlutterSecureStorage(); + final FlutterSecureStorage _storage; /// Loads the settings Future load() async { @@ -24,6 +26,7 @@ class SettingsStorage { logger.d('error loading settings: $e'); } } + return const Settings(); } diff --git a/lib/services/icon_service.dart b/lib/settings/theme/icon_service.dart similarity index 94% rename from lib/services/icon_service.dart rename to lib/settings/theme/icon_service.dart index 42401ac..9dccb34 100644 --- a/lib/services/icon_service.dart +++ b/lib/settings/theme/icon_service.dart @@ -5,6 +5,13 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class IconService { + IconService._(); + + static final _instance = IconService._(); + + /// Returns the singleton instance + static IconService get instance => _instance; + static final _isCupertino = PlatformInfo.isCupertino; IconData get share => _isCupertino ? CupertinoIcons.share : Icons.share; @@ -116,6 +123,8 @@ class IconService { case MediaToptype.other: return Icons.attachment; + + // ignore: no_default_cases default: return Icons.attachment; } @@ -136,17 +145,18 @@ class IconService { } else if (mailbox.isJunk) { iconData = folderJunk; } + return iconData; } - static Widget buildNumericIcon(BuildContext context, int value, - {double? size}) { + static Widget buildNumericIcon( + BuildContext context, + int value, { + double? size, + }) { switch (value) { case 1: - return Icon( - Icons.looks_one_outlined, - size: size, - ); + return Icon(Icons.looks_one_outlined, size: size); case 2: return Icon(Icons.looks_two_outlined, size: size); case 3: @@ -158,7 +168,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/settings/theme/model.dart b/lib/settings/theme/model.dart index 71e9911..1fec872 100644 --- a/lib/settings/theme/model.dart +++ b/lib/settings/theme/model.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -145,6 +146,7 @@ Color _colorFromJson(Map json) { } //// The actually applied theme data +@immutable class ThemeSettingsData { /// Creates the theme data const ThemeSettingsData({ @@ -152,6 +154,7 @@ class ThemeSettingsData { required this.darkTheme, required this.lightTheme, required this.themeMode, + required this.cupertinoTheme, }); /// The current brightness @@ -165,4 +168,18 @@ class ThemeSettingsData { /// The (material) theme mode final ThemeMode themeMode; + + /// The cupertino theme data + final CupertinoThemeData cupertinoTheme; + + @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..5a9b7e2 100644 --- a/lib/settings/theme/provider.dart +++ b/lib/settings/theme/provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -5,30 +6,21 @@ 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), - ); - if (!isResumed) { - return state; - } + ref.watch(appIsResumedProvider); return _fromThemeSettings( themeSettings, + context: context, ); } @@ -38,31 +30,29 @@ class ThemeNotifier extends Notifier { }) { final mode = settings.getCurrentThemeMode(); final brightness = _resolveBrightness(mode, context); - final dark = _generateTheme(Brightness.dark, settings.colorSchemeSeed); - final light = _generateTheme(Brightness.light, settings.colorSchemeSeed); + final dark = + _generateMaterialTheme(Brightness.dark, settings.colorSchemeSeed); + final light = + _generateMaterialTheme(Brightness.light, settings.colorSchemeSeed); + final cupertino = + _generateCupertinoTheme(brightness, settings.colorSchemeSeed); return ThemeSettingsData( brightness: brightness, lightTheme: light, darkTheme: dark, themeMode: mode, + cupertinoTheme: cupertino, ); } /// The default light theme static final ThemeData defaultLightTheme = - _generateTheme(Brightness.light, Colors.green); + _generateMaterialTheme(Brightness.light, Colors.green); /// 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; + _generateMaterialTheme(Brightness.dark, Colors.green); static Brightness _resolveBrightness( ThemeMode mode, @@ -80,13 +70,7 @@ 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) => + static ThemeData _generateMaterialTheme(Brightness brightness, Color color) => color is MaterialColor ? ThemeData( brightness: brightness, @@ -99,20 +83,37 @@ class ThemeNotifier extends Notifier { 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) {} - } + static CupertinoThemeData _generateCupertinoTheme( + Brightness brightness, + Color color, + ) => + CupertinoThemeData( + brightness: brightness, + primaryColor: color, + ); + // CupertinoThemeData( + // brightness: brightness, + // primaryColor: color, + // primaryContrastingColor: brightness == Brightness.dark + // ? CupertinoColors.white + // : CupertinoColors.black, + // barBackgroundColor: CupertinoColors.systemBackground, + // scaffoldBackgroundColor: CupertinoColors.systemFill, + // textTheme: CupertinoTextThemeData( + // primaryColor: brightness == Brightness.dark + // ? CupertinoColors.white + // : CupertinoColors.black, + // ), + // applyThemeToAll: true, + // ); + // MaterialBasedCupertinoThemeData( + // materialTheme: _generateMaterialTheme(brightness, color), + // .copyWith( + // cupertinoOverrideTheme: CupertinoThemeData( + // brightness: brightness, + // primaryColor: color, + // applyThemeToAll: true, + // ), + // ), + // ); } diff --git a/lib/settings/theme/provider.g.dart b/lib/settings/theme/provider.g.dart new file mode 100644 index 0000000..1ac8f22 --- /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'9d68c18c5f97c868c95fb419748361c36e3edf12'; + +/// 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 diff --git a/lib/settings/view/settings_accounts_screen.dart b/lib/settings/view/settings_accounts_screen.dart index fe7958b..7d5458d 100644 --- a/lib/settings/view/settings_accounts_screen.dart +++ b/lib/settings/view/settings_accounts_screen.dart @@ -2,114 +2,127 @@ 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 '../../l10n/app_localizations.g.dart'; -import '../../l10n/extension.dart'; -import '../../locator.dart'; -import '../../models/account.dart'; -import '../../routes.dart'; +import '../../account/model.dart'; +import '../../account/provider.dart'; +import '../../localization/app_localizations.g.dart'; +import '../../localization/extension.dart'; +import '../../routes/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: account.hasError + ? Badge(child: Icon(CommonPlatformIcons.account)) + : Icon(CommonPlatformIcons.account), title: Text(account.name), + onTap: () => context.pushNamed( + Routes.accountEdit, + pathParameters: {Routes.pathParameterEmail: account.email}, + ), + ), + PlatformListTile( + leading: Icon(CommonPlatformIcons.add), + title: Text(localizations.drawerEntryAddAccount), + onTap: () => context.pushNamed(Routes.accountAdd), + ), + if (accounts.length > 1) + Padding( + padding: const EdgeInsets.all(8), + child: PlatformElevatedButton( + onPressed: () => reorderAccountsState.value = true, + child: Text(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_default_sender_screen.dart b/lib/settings/view/settings_default_sender_screen.dart index 80bcee6..1e3f0ad 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 '../../l10n/extension.dart'; -import '../../locator.dart'; -import '../../routes.dart'; +import '../../account/provider.dart'; +import '../../localization/extension.dart'; +import '../../routes/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 1b0eabf..06ed063 100644 --- a/lib/settings/view/settings_developer_mode_screen.dart +++ b/lib/settings/view/settings_developer_mode_screen.dart @@ -1,21 +1,21 @@ 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 '../../l10n/extension.dart'; -import '../../locator.dart'; -import '../../models/account.dart'; +import '../../localization/extension.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'; +/// A screen to configure the developer mode. class SettingsDeveloperModeScreen extends HookConsumerWidget { + /// Creates a new [SettingsDeveloperModeScreen]. const SettingsDeveloperModeScreen({super.key}); @override @@ -30,8 +30,7 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { final developerModeState = useState(isDeveloperModeEnabled); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.settingsDevelopment, content: SingleChildScrollView( child: SafeArea( @@ -71,7 +70,7 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { style: theme.textTheme.bodySmall, ), PlatformTextButton( - child: ButtonText(localizations.extensionsLearnMoreAction), + child: Text(localizations.extensionsLearnMoreAction), onPressed: () => launchUrl( Uri.parse( 'https://github.com/Enough-Software/enough_mail_app/wiki/Extensions', @@ -80,15 +79,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 +97,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, @@ -115,80 +115,81 @@ class SettingsDeveloperModeScreen extends HookConsumerWidget { title: localizations.extensionsManualAction, actions: [ PlatformTextButton( - child: ButtonText(localizations.actionCancel), - onPressed: () => navService.pop(false), + child: Text(localizations.actionCancel), + onPressed: () => context.pop(), ), PlatformTextButton( - child: ButtonText(localizations.actionOk), + child: Text(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'; - } else { - url = '$url/.maily.json'; - } + if (!url.endsWith('json')) { + usedUrl = url.endsWith('/') ? '$url.maily.json' : '$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 +203,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 +232,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 +251,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 +266,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 a2683fd..6818f01 100644 --- a/lib/settings/view/settings_feedback_screen.dart +++ b/lib/settings/view/settings_feedback_screen.dart @@ -8,11 +8,9 @@ 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 '../../locator.dart'; +import '../../localization/extension.dart'; +import '../../scaffold_messenger/service.dart'; import '../../screens/base.dart'; -import '../../services/scaffold_messenger_service.dart'; -import '../../widgets/button_text.dart'; class SettingsFeedbackScreen extends StatefulWidget { const SettingsFeedbackScreen({super.key}); @@ -34,18 +32,27 @@ class _SettingsFeedbackScreenState extends State { final packageInfo = await PackageInfo.fromPlatform(); var textualInfo = 'Maily v${packageInfo.version}+${packageInfo.buildNumber}\n' - 'Platform ${Platform.operatingSystem} ${Platform.operatingSystemVersion}\n'; - if (Platform.isAndroid || Platform.isIOS) { - final deviceInfoPlugin = DeviceInfoPlugin(); - if (Platform.isAndroid) { - final androidInfo = await deviceInfoPlugin.androidInfo; - textualInfo += - '${androidInfo.manufacturer}/${androidInfo.model} (${androidInfo.device})\nAndroid ${androidInfo.version.release} with API level ${androidInfo.version.sdkInt}'; - } else { - final iosInfo = await deviceInfoPlugin.iosInfo; - textualInfo += - '${iosInfo.localizedModel}\n${iosInfo.systemName}/${iosInfo.systemVersion}\n'; - } + 'Platform ' + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}\n'; + final deviceInfoPlugin = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final androidInfo = await deviceInfoPlugin.androidInfo; + textualInfo += '${androidInfo.manufacturer}/${androidInfo.model} ' + '(${androidInfo.device})\nAndroid ${androidInfo.version.release} ' + 'with API level ${androidInfo.version.sdkInt}'; + } else if (Platform.isIOS) { + final iosInfo = await deviceInfoPlugin.iosInfo; + textualInfo += '${iosInfo.localizedModel}\n' + '${iosInfo.systemName}/${iosInfo.systemVersion}\n'; + } else if (Platform.isWindows) { + final windowsInfo = await deviceInfoPlugin.windowsInfo; + textualInfo += '${windowsInfo.productName}\n${windowsInfo.majorVersion}.' + '${windowsInfo.minorVersion} ${windowsInfo.displayVersion}\n'; + } else if (Platform.isMacOS) { + final macOsInfo = await deviceInfoPlugin.macOsInfo; + textualInfo += '${macOsInfo.model}\n' + 'MacOS ${macOsInfo.majorVersion}.${macOsInfo.minorVersion} ' + '${macOsInfo.osRelease}\n'; } setState(() { info = textualInfo; @@ -57,8 +64,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 +75,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 +95,7 @@ class _SettingsFeedbackScreenState extends State { ), Padding( padding: const EdgeInsets.all(8), - child: Text(info!), + child: Text(info ?? ''), ), Padding( padding: const EdgeInsets.all(8), @@ -95,8 +103,10 @@ class _SettingsFeedbackScreenState extends State { icon: Icon(CommonPlatformIcons.copy), onPressed: () { Clipboard.setData(ClipboardData(text: info ?? '')); - locator().showTextSnackBar( - localizations.feedbackResultInfoCopied); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.feedbackResultInfoCopied, + ); }, ), ), @@ -104,8 +114,7 @@ class _SettingsFeedbackScreenState extends State { Padding( padding: const EdgeInsets.all(8), child: PlatformTextButton( - child: - ButtonText(localizations.feedbackActionSuggestFeature), + child: Text(localizations.feedbackActionSuggestFeature), onPressed: () async { await launcher .launchUrl(Uri.parse('https://maily.userecho.com/')); @@ -115,8 +124,7 @@ class _SettingsFeedbackScreenState extends State { Padding( padding: const EdgeInsets.all(8), child: PlatformTextButton( - child: - ButtonText(localizations.feedbackActionReportProblem), + child: Text(localizations.feedbackActionReportProblem), onPressed: () async { await launcher .launchUrl(Uri.parse('https://maily.userecho.com/')); @@ -126,11 +134,13 @@ class _SettingsFeedbackScreenState extends State { Padding( padding: const EdgeInsets.all(8), child: PlatformTextButton( - child: - ButtonText(localizations.feedbackActionHelpDeveloping), + child: Text(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 5003370..87627b4 100644 --- a/lib/settings/view/settings_folders_screen.dart +++ b/lib/settings/view/settings_folders_screen.dart @@ -4,20 +4,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../l10n/extension.dart'; -import '../../locator.dart'; +import '../../account/model.dart'; +import '../../account/provider.dart'; +import '../../localization/extension.dart'; +import '../../mail/provider.dart'; import '../../models/models.dart'; +import '../../scaffold_messenger/service.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'; -import '../../widgets/button_text.dart'; import '../../widgets/mailbox_selector.dart'; import '../model.dart'; import '../provider.dart'; +import '../theme/icon_service.dart'; class SettingsFoldersScreen extends ConsumerWidget { const SettingsFoldersScreen({super.key}); @@ -32,18 +31,19 @@ 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( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), 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, @@ -66,12 +66,12 @@ class SettingsFoldersScreen extends ConsumerWidget { const Divider(), PlatformTextButtonIcon( icon: Icon(CommonPlatformIcons.edit), - label: ButtonText(localizations.folderNamesEditAction), + label: Text(localizations.folderNamesEditAction), onPressed: () => _editFolderNames(context, settings, ref), ), ], const Divider( - height: 8.0, + height: 8, ), const FolderManagement(), ], @@ -82,7 +82,7 @@ class SettingsFoldersScreen extends ConsumerWidget { ); } - void _editFolderNames( + Future _editFolderNames( BuildContext context, Settings settings, WidgetRef ref, @@ -90,51 +90,51 @@ 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); - ref.read(settingsProvider.notifier).update(settings); + await ref.read(settingsProvider.notifier).update( + settings.copyWith(customFolderNames: customNames), + ); } } - 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); } } class CustomFolderNamesEditor extends HookConsumerWidget { - const CustomFolderNamesEditor({Key? key, required this.customNames}) - : super(key: key); + const CustomFolderNamesEditor({super.key, required this.customNames}); final List customNames; @override Widget build(BuildContext context, WidgetRef ref) { final localizations = context.text; - final iconService = locator(); + final iconService = IconService.instance; final inboxController = useTextEditingController(text: customNames[0]); final draftsController = useTextEditingController(text: customNames[1]); @@ -143,7 +143,7 @@ class CustomFolderNamesEditor extends HookConsumerWidget { final archiveController = useTextEditingController(text: customNames[4]); final junkController = useTextEditingController(text: customNames[5]); - //TODO support to save these values + // TODO(RV): support to save these values return SingleChildScrollView( child: SafeArea( @@ -210,23 +210,23 @@ class CustomFolderNamesEditor extends HookConsumerWidget { } } -class FolderManagement extends StatefulWidget { - const FolderManagement({Key? key}) : super(key: key); +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 +240,7 @@ class _FolderManagementState extends State { @override Widget build(BuildContext context) { final localizations = context.text; + return SingleChildScrollView( child: SafeArea( child: Column( @@ -251,7 +252,7 @@ class _FolderManagementState extends State { onChanged: (account) { setState(() { _mailbox = null; - _account = account!; + _account = account; }); }, ), @@ -286,53 +287,53 @@ class _FolderManagementState extends State { } } -class MailboxWidget extends StatelessWidget { +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; - const MailboxWidget( - {Key? key, - required this.mailbox, - required this.account, - required this.onMailboxAdded, - required this.onMailboxDeleted}) - : super(key: key); - @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), + label: Text(localizations.folderAddAction), ), if (mailbox != null) PlatformTextButtonIcon( - onPressed: () => _deleteFolder(context), + onPressed: () => _deleteFolder(context, ref), backgroundColor: Colors.red, style: TextButton.styleFrom(backgroundColor: Colors.red), icon: Icon( CommonPlatformIcons.delete, color: Colors.white, ), - label: ButtonText( + label: Text( localizations.folderDeleteAction, - style: Theme.of(context) - .textTheme - .labelLarge! - .copyWith(color: Colors.white), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Colors.white, + ), ), ), ], ); } - void _createFolder(context) async { + Future _createFolder(BuildContext context, WidgetRef ref) async { final localizations = context.text; final folderNameController = TextEditingController(); final result = await LocalizedDialogHelper.showWidgetDialog( @@ -350,43 +351,57 @@ 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, + ); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.folderAddResultSuccess, ); - 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()), + ); + } } } } - void _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!); - locator() - .showTextSnackBar(localizations.folderDeleteResultSuccess); + await ref + .read(mailClientSourceProvider(account: account).notifier) + .deleteMailbox(mailbox); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.folderDeleteResultSuccess, + ); onMailboxDeleted(); } on MailException catch (e) { if (context.mounted) { 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 11f3c74..51c3506 100644 --- a/lib/settings/view/settings_language_screen.dart +++ b/lib/settings/view/settings_language_screen.dart @@ -2,15 +2,13 @@ import 'package:collection/collection.dart' show IterableExtension; 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 '../../l10n/app_localizations.g.dart'; -import '../../l10n/extension.dart'; -import '../../locator.dart'; +import '../../localization/app_localizations.g.dart'; +import '../../localization/extension.dart'; import '../../screens/base.dart'; -import '../../services/i18n_service.dart'; import '../../util/localized_dialog_helper.dart'; -import '../../widgets/button_text.dart'; import '../provider.dart'; class SettingsLanguageScreen extends HookConsumerWidget { @@ -21,6 +19,7 @@ class SettingsLanguageScreen extends HookConsumerWidget { final displayNames = { 'de': 'deutsch', 'en': 'English', + 'es': 'español', }; final available = AppLocalizations.supportedLocales .map( @@ -28,18 +27,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; @@ -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: Text( + selectedLocalizations.actionCancel, ), - ]); + onPressed: () => context.pop(false), + ), + PlatformTextButton( + child: Text(selectedLocalizations.actionOk), + onPressed: () => 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 891d426..8cc6060 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'; @@ -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 0110b86..2042584 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'; @@ -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 ac16265..670fd00 100644 --- a/lib/settings/view/settings_screen.dart +++ b/lib/settings/view/settings_screen.dart @@ -1,22 +1,22 @@ import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; -import '../../l10n/extension.dart'; -import '../../locator.dart'; -import '../../routes.dart'; +import '../../localization/extension.dart'; +import '../../routes/routes.dart'; import '../../screens/base.dart'; -import '../../services/navigation_service.dart'; import '../../util/localized_dialog_helper.dart'; +/// Allows to personalize the app settings class SettingsScreen extends StatelessWidget { + /// Creates a new [SettingsScreen] const SettingsScreen({super.key}); @override Widget build(BuildContext context) { final localizations = context.text; - return Base.buildAppChrome( - context, + return BasePage( title: localizations.settingsTitle, content: SingleChildScrollView( child: SafeArea( @@ -28,73 +28,68 @@ 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); + }, + ), + PlatformListTile( + title: Text(localizations.settingsActionDesign), + onTap: () { + context.pushNamed(Routes.settingsDesign); }, ), - if (!PlatformInfo.isCupertino) - PlatformListTile( - title: Text(localizations.settingsActionDesign), - onTap: () { - locator().push(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( @@ -105,7 +100,7 @@ class SettingsScreen extends StatelessWidget { ), PlatformListTile( onTap: () { - locator().push(Routes.welcome); + context.pushNamed(Routes.welcome); }, title: Text(localizations.settingsActionWelcome), ), @@ -113,8 +108,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_security_screen.dart b/lib/settings/view/settings_security_screen.dart index 133414c..f59c8a8 100644 --- a/lib/settings/view/settings_security_screen.dart +++ b/lib/settings/view/settings_security_screen.dart @@ -4,10 +4,11 @@ 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 '../../locator.dart'; +import '../../app_lifecycle/provider.dart'; +import '../../localization/extension.dart'; +import '../../lock/provider.dart'; +import '../../lock/service.dart'; import '../../screens/base.dart'; -import '../../services/biometrics_service.dart'; import '../../util/localized_dialog_helper.dart'; import '../model.dart'; import '../provider.dart'; @@ -17,12 +18,11 @@ class SettingsSecurityScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - //final theme = Theme.of(context); final localizations = context.text; final settings = ref.watch(settingsProvider); final isBiometricsSupported = useState(null); useMemoized(() async { - final supported = await locator().isDeviceSupported(); + final supported = await BiometricsService.instance.isDeviceSupported(); isBiometricsSupported.value = supported; }); @@ -37,8 +37,7 @@ class SettingsSecurityScreen extends HookConsumerWidget { } } - return Base.buildAppChrome( - context, + return BasePage( title: localizations.securitySettingsTitle, content: SingleChildScrollView( child: SafeArea( @@ -52,7 +51,9 @@ class SettingsSecurityScreen extends HookConsumerWidget { vertical: 8, horizontal: 4, ), - child: Text(localizations.securitySettingsIntro), + child: Text( + localizations.securitySettingsIntro, + ), ), const Divider(), Row( @@ -113,12 +114,12 @@ class SettingsSecurityScreen extends HookConsumerWidget { ), ), const Divider(), - if (isBiometricsSupported.value == false) + if (!(isBiometricsSupported.value ?? false)) Padding( padding: const EdgeInsets.all(8), child: Text(localizations.securityUnlockNotAvailable), ) - else if (isBiometricsSupported.value ?? false == true) ...[ + else if (isBiometricsSupported.value ?? false) ...[ Row( children: [ Expanded( @@ -129,10 +130,18 @@ class SettingsSecurityScreen extends HookConsumerWidget { final String? reason = enableBiometricLock ? null : localizations.securityUnlockDisableReason; + ref + .read(appLifecycleProvider.notifier) + .ignoreNextInactivationCycle(); final didAuthenticate = - await locator() - .authenticate(reason: reason); + await BiometricsService.instance.authenticate( + localizations, + reason: reason, + ); if (didAuthenticate) { + if (enableBiometricLock && context.mounted) { + AppLock.ignoreNextSettingsChange = true; + } await ref.read(settingsProvider.notifier).update( settings.copyWith( enableBiometricLock: enableBiometricLock, diff --git a/lib/settings/view/settings_signature_screen.dart b/lib/settings/view/settings_signature_screen.dart index bbe3cd8..58dc644 100644 --- a/lib/settings/view/settings_signature_screen.dart +++ b/lib/settings/view/settings_signature_screen.dart @@ -1,17 +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 '../../l10n/extension.dart'; -import '../../locator.dart'; -import '../../models/account.dart'; +import '../../account/model.dart'; +import '../../account/provider.dart'; +import '../../localization/extension.dart'; import '../../models/compose_data.dart'; -import '../../routes.dart'; +import '../../routes/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,10 +34,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.signatureHtml != null, + (account) => account.getSignatureHtml(localizations.localeName) != null, ), ); String getActionName(ComposeAction action) { @@ -53,8 +51,7 @@ class SettingsSignatureScreen extends HookConsumerWidget { } } - return Base.buildAppChrome( - context, + return BasePage( title: localizations.signatureSettingsTitle, content: SingleChildScrollView( child: SafeArea( @@ -100,10 +97,9 @@ class SettingsSignatureScreen extends HookConsumerWidget { Text(localizations.signatureSettingsAccountInfo), PlatformTextButton( onPressed: () { - locator() - .push(Routes.settingsAccounts); + context.pushNamed(Routes.settingsAccounts); }, - child: ButtonText(localizations.settingsActionAccounts), + child: Text(localizations.settingsActionAccounts), ), ], ], diff --git a/lib/settings/view/settings_swipe_screen.dart b/lib/settings/view/settings_swipe_screen.dart index e365ec5..819ac1c 100644 --- a/lib/settings/view/settings_swipe_screen.dart +++ b/lib/settings/view/settings_swipe_screen.dart @@ -1,15 +1,13 @@ 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 '../../l10n/extension.dart'; -import '../../locator.dart'; +import '../../localization/extension.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'; class SettingsSwipeScreen extends ConsumerWidget { @@ -24,8 +22,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 +31,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, @@ -105,7 +106,7 @@ class _SwipeSetting extends HookConsumerWidget { ], ), onPressed: () { - locator().pop(action); + context.pop(action); }, ), ) @@ -120,6 +121,7 @@ class _SwipeSetting extends HookConsumerWidget { if (action == false) { return null; } + return action; } @@ -148,7 +150,7 @@ class _SwipeSetting extends HookConsumerWidget { PlatformTextButtonIcon( onPressed: onPressed, icon: const Icon(Icons.edit), - label: ButtonText(localizations.swipeSettingChangeAction), + label: Text(localizations.swipeSettingChangeAction), ), ], ); diff --git a/lib/settings/view/settings_theme_screen.dart b/lib/settings/view/settings_theme_screen.dart index e0da14f..591155a 100644 --- a/lib/settings/view/settings_theme_screen.dart +++ b/lib/settings/view/settings_theme_screen.dart @@ -4,15 +4,14 @@ 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'; 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 +41,7 @@ class SettingsThemeScreen extends HookConsumerWidget { themeSettings.copyWith(themeModeSetting: value), ); - return Base.buildAppChrome( - context, + return BasePage( title: localizations.designTitle, content: SingleChildScrollView( child: Material( @@ -53,8 +51,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, @@ -88,7 +88,7 @@ class SettingsThemeScreen extends HookConsumerWidget { Row( children: [ PlatformTextButton( - child: ButtonText( + child: Text( localizations.designThemeCustomStart( darkThemeStartTime.format(context), ), @@ -109,8 +109,11 @@ class SettingsThemeScreen extends HookConsumerWidget { }, ), PlatformTextButton( - child: ButtonText(localizations.designThemeCustomEnd( - darkThemeEndTime.format(context))), + child: Text( + localizations.designThemeCustomEnd( + darkThemeEndTime.format(context), + ), + ), onPressed: () async { final pickedTime = await showPlatformTimePicker( context: context, diff --git a/lib/models/shared_data.dart b/lib/share/model.dart similarity index 59% rename from lib/models/shared_data.dart rename to lib/share/model.dart index c3e2056..93c31d8 100644 --- a/lib/models/shared_data.dart +++ b/lib/share/model.dart @@ -4,78 +4,136 @@ import 'dart:typed_data'; import 'package:enough_html_editor/enough_html_editor.dart'; import 'package:enough_mail/enough_mail.dart'; -enum SharedDataAddState { added, notAdded } +/// State of a shared data item +enum SharedDataAddState { + /// The item was added + added, + /// The item was not added + notAdded, +} + +/// Result of adding a shared data item class SharedDataAddResult { + /// Creates a new [SharedDataAddResult] + const SharedDataAddResult(this.state, [this.details]); + + /// The item was added static const added = SharedDataAddResult(SharedDataAddState.added); + + /// The item was not added static const notAdded = SharedDataAddResult(SharedDataAddState.notAdded); + + /// The state of the item final SharedDataAddState state; - final dynamic details; - const SharedDataAddResult(this.state, [this.details]); + /// The details of the item + final dynamic details; } +/// Shared data item abstract class SharedData { - final MediaType mediaType; - + /// Creates a new [SharedData] SharedData(this.mediaType); + /// The media type of the item, e.g. `image/jpeg` + final MediaType mediaType; + + /// Adds the item to the message builder Future addToMessageBuilder(MessageBuilder builder); + + /// Adds the item to the editor Future addToEditor(HtmlEditorApi editorApi); } +/// Shared data item for a file class SharedFile extends SharedData { - final File file; + /// Creates a new [SharedFile] SharedFile(this.file, MediaType? mediaType) : super(mediaType ?? MediaType.guessFromFileName(file.path)); + /// The file + final File file; + @override Future addToMessageBuilder( - MessageBuilder builder) async { + MessageBuilder builder, + ) async { await builder.addFile(file, mediaType); + return SharedDataAddResult.added; } @override - Future addToEditor(HtmlEditorApi? editorApi) async { + Future addToEditor(HtmlEditorApi editorApi) async { if (mediaType.isImage) { - await editorApi! - .insertImageFile(file, mediaType.sub.mediaType.toString()); + await editorApi.insertImageFile( + file, + mediaType.sub.mediaType.toString(), + ); + return SharedDataAddResult.added; } + return SharedDataAddResult.notAdded; } } +/// Shared data item for a binary class SharedBinary extends SharedData { - final Uint8List? data; - final String? filename; + /// Creates a new [SharedBinary] SharedBinary(this.data, this.filename, MediaType mediaType) : super(mediaType); + /// The binary data + final Uint8List? data; + + /// The optional filename + final String? filename; + @override Future addToMessageBuilder( - MessageBuilder builder) async { - builder.addBinary(data!, mediaType, filename: filename); + MessageBuilder builder, + ) async { + final data = this.data; + if (data == null) { + return SharedDataAddResult.notAdded; + } + builder.addBinary(data, mediaType, filename: filename); + return SharedDataAddResult.added; } @override Future addToEditor(HtmlEditorApi editorApi) async { - if (mediaType.isImage) { + final data = this.data; + if (data != null && mediaType.isImage) { await editorApi.insertImageData( - data!, mediaType.sub.mediaType.toString()); + data, + mediaType.sub.mediaType.toString(), + ); + return SharedDataAddResult.added; } + return SharedDataAddResult.notAdded; } } +/// Shared data item for a text class SharedText extends SharedData { + /// Creates a new [SharedText] + SharedText( + this.text, + MediaType? mediaType, { + this.subject, + }) : super(mediaType ?? MediaType.textPlain); + + /// The text final String text; + + /// The optional subject final String? subject; - SharedText(this.text, MediaType? mediaType, {this.subject}) - : super(mediaType ?? MediaType.textPlain); @override Future addToMessageBuilder(MessageBuilder builder) { @@ -83,30 +141,36 @@ class SharedText extends SharedData { if (subject != null) { builder.subject = subject; } + return Future.value(SharedDataAddResult.added); } @override Future addToEditor(HtmlEditorApi editorApi) async { await editorApi.insertText(text); + return Future.value(SharedDataAddResult.added); } } +/// Shared data item for a mailto link class SharedMailto extends SharedData { - final Uri mailto; + /// Creates a new [SharedMailto] SharedMailto(this.mailto) : super(MediaType.fromSubtype(MediaSubtype.textHtml)); + /// The mailto link + final Uri mailto; + @override Future addToEditor(HtmlEditorApi editorApi) { - // TODO: implement addToEditor + // TODO(RV): implement addToEditor throw UnimplementedError(); } @override Future addToMessageBuilder(MessageBuilder builder) { - // TODO: implement addToMessageBuilder + // TODO(RV): implement addToMessageBuilder throw UnimplementedError(); } } diff --git a/lib/share/provider.dart b/lib/share/provider.dart new file mode 100644 index 0000000..07767ca --- /dev/null +++ b/lib/share/provider.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.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 '../logger.dart'; +import '../models/compose_data.dart'; +import '../routes/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'); + var _isFirstBuild = true; + + @override + Future build() async { + final isResumed = ref.watch(rawAppLifecycleStateProvider + .select((value) => value == AppLifecycleState.resumed)); + if (isResumed) { + if (Platform.isAndroid) { + final shared = await _platform.invokeMethod('getSharedData'); + logger.d('checkForShare: received data: $shared'); + if (shared != null) { + if (_isFirstBuild) { + _isFirstBuild = false; + await Future.delayed(const Duration(seconds: 2)); + } + 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..a11f915 --- /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'0580a0eb934b79968af40957fcdab5595c9831e2'; + +/// 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/util/date_helper.dart b/lib/util/date_helper.dart new file mode 100644 index 0000000..590d4df --- /dev/null +++ b/lib/util/date_helper.dart @@ -0,0 +1,95 @@ +/// 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, + + /// The date is in a different year + monthAndYear, +} + +/// Allows to determine the date section of a given date +class DateHelper { + /// Creates a new [DateHelper] + DateHelper(this.firstDayOfWeek) { + _setupDates(); + } + + /// The first weekday of the week + final int firstDayOfWeek; + + late DateTime _today; + late DateTime _tomorrow; + late DateTime _dayAfterTomorrow; + late DateTime _yesterday; + late DateTime _thisWeek; + late DateTime _lastWeek; + + void _setupDates() { + 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)); + if (_today.weekday == firstDayOfWeek) { + _thisWeek = _today; + } else if (_yesterday.weekday == firstDayOfWeek) { + _thisWeek = _yesterday; + } else { + _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)); + } + + /// Determines the date section of the given [localTime] + DateSectionRange determineDateSection( + DateTime localTime, + ) { + if (_today.weekday != DateTime.now().weekday) { + _setupDates(); + } + if (localTime.isAfter(_today)) { + return localTime.isBefore(_tomorrow) + ? DateSectionRange.today + : localTime.isBefore(_dayAfterTomorrow) + ? DateSectionRange.tomorrow + : DateSectionRange.future; + } + if (localTime.isAfter(_yesterday)) { + return DateSectionRange.yesterday; + } else if (localTime.isAfter(_thisWeek)) { + return DateSectionRange.thisWeek; + } else if (localTime.isAfter(_lastWeek)) { + return DateSectionRange.lastWeek; + } else if (localTime.year == _today.year) { + return localTime.month == _today.month + ? DateSectionRange.thisMonth + : DateSectionRange.monthOfThisYear; + } + + return DateSectionRange.monthAndYear; + } +} diff --git a/lib/util/datetime.dart b/lib/util/datetime.dart index 3df2596..53fc479 100644 --- a/lib/util/datetime.dart +++ b/lib/util/datetime.dart @@ -1,11 +1,8 @@ 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/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/http_helper.dart b/lib/util/http_helper.dart index d02a373..98f1799 100644 --- a/lib/util/http_helper.dart +++ b/lib/util/http_helper.dart @@ -1,72 +1,9 @@ -import 'dart:async'; import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; -import 'package:http/http.dart' as http; -class HttpHelper { - HttpHelper._(); +import 'package:http/http.dart'; - static Future httpGet(String url) async { - try { - final client = HttpClient(); - final request = await client.getUrl(Uri.parse(url)); - final response = await request.close(); - - if (response.statusCode != 200) { - return HttpResult(response.statusCode); - } - final data = await _readHttpResponse(response); - return HttpResult(response.statusCode, data); - } catch (e) { - return HttpResult(400); - } - } - - static Future httpPost(String url, - {Map? headers, Object? body, Encoding? encoding}) async { - try { - final response = await http.post(Uri.parse(url), - headers: headers, body: body, encoding: encoding); - - if (response.statusCode != 200) { - return HttpResult(response.statusCode); - } - return HttpResult(response.statusCode, response.bodyBytes); - } catch (e) { - return HttpResult(400); - } - } - - static Future _readHttpResponse(HttpClientResponse response) { - final completer = Completer(); - final contents = BytesBuilder(); - response.listen((data) { - if (data is Uint8List) { - contents.add(data); - } else { - contents.add(Uint8List.fromList(data)); - } - }, onDone: () => completer.complete(contents.takeBytes())); - return completer.future; - } -} - -class HttpResult { - final int statusCode; - String? _text; - String? get text { - var t = _text; - if (t == null) { - final d = data; - if (d != null) { - t = utf8.decode(d); - _text = t; - } - } - return t; - } - - final Uint8List? data; - HttpResult(this.statusCode, [this.data]); +/// Extension methods for [Response] +extension HttpResponseExtension on Response { + /// Retrieves the UTF8 decoded text + String? get text => utf8.decode(bodyBytes); } diff --git a/lib/util/indexed_cache.dart b/lib/util/indexed_cache.dart index 6dbcb56..00c1652 100644 --- a/lib/util/indexed_cache.dart +++ b/lib/util/indexed_cache.dart @@ -2,18 +2,23 @@ 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; final _entries = []; final _indices = []; + /// Retrieves the first non-null entry of this cache if available. + /// + /// This operation is independent of an index. + T? get first => _entries.firstWhereOrNull((element) => element != null); + /// Inserts the [value] at the [index] and changes /// the source indices of subsequent entries void insert(int index, T value) { @@ -41,6 +46,7 @@ class IndexedCache { if (removed != null) { _indices.remove(index); } + return removed; } @@ -51,6 +57,7 @@ class IndexedCache { return false; } removeAt(index); + return true; } @@ -61,6 +68,7 @@ class IndexedCache { if (index == -1) { return null; } + return removeAt(index); } @@ -74,7 +82,7 @@ class IndexedCache { T? operator [](int index) => index < _entries.length ? _entries[index] : null; /// Set the value for the given [index]. - operator []=(int index, T value) { + void operator []=(int index, T value) { if (_entries.length > index) { final existing = _entries[index]; _entries[index] = value; @@ -103,7 +111,9 @@ class IndexedCache { /// Triggers the [action] for any elements that fit the [test] void forEachWhere( - bool Function(T element) test, void Function(T element) action) { + bool Function(T element) test, + void Function(T element) action, + ) { _entries .where((value) => value != null && test(value)) .forEach((element) => action(element as T)); diff --git a/lib/util/localized_dialog_helper.dart b/lib/util/localized_dialog_helper.dart index e566bf0..95c9364 100644 --- a/lib/util/localized_dialog_helper.dart +++ b/lib/util/localized_dialog_helper.dart @@ -3,8 +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 '../widgets/button_text.dart'; +import '../localization/extension.dart'; import '../widgets/legalese.dart'; /// Helps to display localized dialogs @@ -25,24 +24,25 @@ class LocalizedDialogHelper { applicationLegalese: localizations.aboutApplicationLegalese, children: [ TextButton( - child: ButtonText(localizations.feedbackActionSuggestFeature), + child: Text(localizations.feedbackActionSuggestFeature), onPressed: () async { await launcher .launchUrl(Uri.parse('https://maily.userecho.com/')); }, ), TextButton( - child: ButtonText(localizations.feedbackActionReportProblem), + child: Text(localizations.feedbackActionReportProblem), onPressed: () async { await launcher .launchUrl(Uri.parse('https://maily.userecho.com/')); }, ), TextButton( - child: ButtonText(localizations.feedbackActionHelpDeveloping), + 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(), @@ -54,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/modal_bottom_sheet_helper.dart b/lib/util/modal_bottom_sheet_helper.dart index eeeaee2..c791e04 100644 --- a/lib/util/modal_bottom_sheet_helper.dart +++ b/lib/util/modal_bottom_sheet_helper.dart @@ -1,26 +1,33 @@ -import 'package:enough_mail_app/screens/base.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import '../screens/base.dart'; + +/// Helps to show a modal bottom sheet class ModelBottomSheetHelper { ModelBottomSheetHelper._(); - static Future showModalBottomSheet( - BuildContext context, String title, Widget child, - {List? appBarActions, bool useScrollView = true}) async { + /// Shows a modal bottom sheet + static Future showModalBottomSheet( + BuildContext context, + String title, + Widget child, { + List? appBarActions, + bool useScrollView = true, + }) async { appBarActions ??= [ DensePlatformIconButton( icon: Icon(CommonPlatformIcons.ok), - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => context.pop(true), ), ]; final bottomSheetContent = SafeArea( bottom: false, child: Padding( - padding: const EdgeInsets.only(top: 32.0), - child: Base.buildAppChrome( - context, + padding: const EdgeInsets.only(top: 32), + child: BasePage( title: title, includeDrawer: false, appBarActions: appBarActions, @@ -35,24 +42,20 @@ class ModelBottomSheetHelper { ), ); - dynamic result; - if (PlatformInfo.isCupertino) { - result = await showCupertinoModalBottomSheet( - context: context, - builder: (context) => bottomSheetContent, - elevation: 8.0, - expand: true, - isDismissible: true, - ); - } else { - result = await showMaterialModalBottomSheet( - context: context, - builder: (context) => bottomSheetContent, - elevation: 8.0, - expand: true, - backgroundColor: Colors.transparent, - ); - } - return (result == true); + return 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, + ); } } diff --git a/lib/util/string_helper.dart b/lib/util/string_helper.dart index f3a019b..b264aca 100644 --- a/lib/util/string_helper.dart +++ b/lib/util/string_helper.dart @@ -1,7 +1,10 @@ import 'dart:math'; +/// Helper methods for strings. class StringHelper { StringHelper._(); + + /// Returns the largest common sequence of the given texts. static String? largestCommonSequence(List texts) { if (texts.isEmpty) { return null; @@ -11,18 +14,22 @@ class StringHelper { return text; } for (var i = 1; i < texts.length; i++) { - text = largestCommonSequenceOf(text!, texts[i]); + text = largestCommonSequenceOf(text ?? '', texts[i]); if (text == null) { return null; } } + return text; } + /// Returns the largest common sequence of the given texts. static String? largestCommonSequenceOf(String first, String second) { // 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; + // problem: the longest sequence between first and second is not + //necessarily the longest sequence between all + String shorter; + String longer; if (first.length <= second.length) { shorter = first; longer = second; @@ -72,14 +79,17 @@ class StringHelper { longest = sequence; } } + return String.fromCharCodes( - shorterRunes, longest.startIndex, longest.startIndex + longest.length); + shorterRunes, + longest.startIndex, + longest.startIndex + longest.length, + ); } } class _StringSequence { + _StringSequence(this.startIndex, this.length); final int startIndex; final int length; - - _StringSequence(this.startIndex, this.length); } diff --git a/lib/util/validator.dart b/lib/util/validator.dart index c910e98..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); + + return atIndex > 0 && dotIndex > atIndex && dotIndex < value.length - 2; } } diff --git a/lib/widgets/account_provider_selector.dart b/lib/widgets/account_hoster_selector.dart similarity index 54% rename from lib/widgets/account_provider_selector.dart rename to lib/widgets/account_hoster_selector.dart index 9e21729..8f706d6 100644 --- a/lib/widgets/account_provider_selector.dart +++ b/lib/widgets/account_hoster_selector.dart @@ -1,30 +1,34 @@ -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/locator.dart'; -import 'package:enough_mail_app/services/providers.dart'; import 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; -class AccountProviderSelector extends StatelessWidget { - final void Function(Provider? provider) onSelected; - const AccountProviderSelector({Key? key, required this.onSelected}) - : super(key: key); +import '../hoster/service.dart'; +import '../localization/extension.dart'; + +/// Allows to select a mail hoster +class MailHosterSelector extends StatelessWidget { + /// Creates a [MailHosterSelector] + const MailHosterSelector({super.key, required this.onSelected}); + + /// Called when a mail hoster has been selected + final void Function(MailHoster? hoster) onSelected; @override Widget build(BuildContext context) { final localizations = context.text; - final providers = locator().providers; + final hosters = MailHosterService.instance.hosters; return ListView.separated( itemBuilder: (context, index) { if (index == 0) { return Center( child: PlatformTextButton( - child: PlatformText(localizations.accountProviderCustom), + child: Text(localizations.accountProviderCustom), onPressed: () => onSelected(null), ), ); } - final provider = providers[index - 1]; + final provider = hosters[index - 1]; + return Center( child: provider.buildSignInButton( context, @@ -33,7 +37,7 @@ class AccountProviderSelector extends StatelessWidget { ); }, separatorBuilder: (context, index) => const Divider(), - itemCount: providers.length + 1, + itemCount: hosters.length + 1, ); } } diff --git a/lib/widgets/account_selector.dart b/lib/widgets/account_selector.dart index cc5c6f7..f4a8895 100644 --- a/lib/widgets/account_selector.dart +++ b/lib/widgets/account_selector.dart @@ -1,29 +1,28 @@ -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 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../locator.dart'; +import '../account/model.dart'; +import '../account/provider.dart'; -class AccountSelector extends StatelessWidget { - final RealAccount? account; - final bool excludeAccountsWithErrors; - final void Function(RealAccount? account) onChanged; +class AccountSelector extends ConsumerWidget { 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) { - 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 88b93f6..44bff53 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -1,39 +1,35 @@ -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_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:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../l10n/app_localizations.g.dart'; -import '../routes.dart'; +import '../account/model.dart'; +import '../account/provider.dart'; +import '../extensions/extension_action_tile.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../routes/routes.dart'; +import '../settings/theme/icon_service.dart'; +import '../util/localized_dialog_helper.dart'; +import 'mailbox_tree.dart'; -class AppDrawer extends StatelessWidget { - const AppDrawer({Key? key}) : super(key: key); +/// Displays the base navigation drawer with all accounts +class AppDrawer extends ConsumerWidget { + /// Creates a new [AppDrawer] + const AppDrawer({super.key}); @override - Widget build(BuildContext context) { - final mailService = locator(); + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(allAccountsProvider); 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 iconService = IconService.instance; + final currentAccount = ref.watch(currentAccountProvider); + final hasAccountsWithErrors = ref.watch(hasAccountWithErrorProvider); + return PlatformDrawer( child: SafeArea( child: Column( @@ -41,9 +37,13 @@ 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), + context, + currentAccount, + accounts, + theme, + ), ), ), Expanded( @@ -54,12 +54,12 @@ class AppDrawer extends StatelessWidget { children: [ _buildAccountSelection( context, - mailService, accounts, currentAccount, localizations, + hasAccountsWithErrors: hasAccountsWithErrors, ), - _buildFolderTree(currentAccount), + _buildFolderTree(context, currentAccount), if (currentAccount is RealAccount) ExtensionActionTile.buildSideMenuForAccount( context, @@ -84,11 +84,10 @@ class AppDrawer extends StatelessWidget { leading: Icon(iconService.settings), title: Text(localizations.drawerEntrySettings), onTap: () { - final navService = locator(); - navService.push(Routes.settings); + context.pushNamed(Routes.settings); }, ), - ) + ), ], ), ), @@ -96,36 +95,43 @@ class AppDrawer extends StatelessWidget { } Widget _buildAccountHeader( + BuildContext context, Account? currentAccount, List accounts, 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; final accountName = Text( 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: () { - final NavigationService navService = locator(); if (currentAccount is UnifiedAccount) { - navService.push(Routes.settingsAccounts, fade: true); + context.pushNamed(Routes.settingsAccounts); } else { - navService.push(Routes.accountEdit, - arguments: currentAccount, fade: true); + context.pushNamed( + Routes.accountEdit, + pathParameters: { + Routes.pathParameterEmail: currentAccount.email, + }, + ); } }, title: avatarAccount == null @@ -134,9 +140,9 @@ class AppDrawer extends StatelessWidget { children: [ CircleAvatar( backgroundColor: theme.secondaryHeaderColor, - backgroundImage: NetworkImage( - avatarAccount.imageUrlGravatar!, - ), + backgroundImage: avatarImageUrl == null + ? null + : NetworkImage(avatarImageUrl), radius: 30, ), const Padding( @@ -152,16 +158,20 @@ class AppDrawer extends StatelessWidget { Text( userName, style: const TextStyle( - fontStyle: FontStyle.italic, fontSize: 14), + fontStyle: FontStyle.italic, + fontSize: 14, + ), ), Text( currentAccount is UnifiedAccount ? currentAccount.accounts .map((a) => a.name) .join(', ') - : (currentAccount as RealAccount).email, + : currentAccount.email, style: const TextStyle( - fontStyle: FontStyle.italic, fontSize: 14), + fontStyle: FontStyle.italic, + fontSize: 14, + ), ), ], ), @@ -172,95 +182,149 @@ 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), - ], + BuildContext context, + List accounts, + Account? currentAccount, + AppLocalizations localizations, { + required bool hasAccountsWithErrors, + }) => + accounts.length > 1 + ? ExpansionTile( + leading: hasAccountsWithErrors ? const Badge() : null, + title: Text( + localizations.drawerAccountsSectionTitle(accounts.length), + ), + children: [ + for (final account in accounts) + _SelectableAccountTile( + account: account, + currentAccount: currentAccount, + ), + _buildAddAccountTile(context, localizations), + ], + ) + : _buildAddAccountTile(context, localizations); + + Widget _buildAddAccountTile( + BuildContext context, + AppLocalizations localizations, + ) => + PlatformListTile( + leading: const Icon(Icons.add), + title: Text(localizations.drawerEntryAddAccount), + onTap: () { + if (!useAppDrawerAsRoot) { + context.pop(); + } + context.pushNamed(Routes.accountAdd); + }, ); - } else { - return _buildAddAccountTile(localizations); + + Widget _buildFolderTree( + BuildContext context, + Account? account, + ) { + if (account == null) { + return const SizedBox.shrink(); } - } - 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); - }, + return MailboxTree( + account: account, + onSelected: (mailbox) => _navigateToMailbox(context, account, mailbox), + isReselectPossible: true, ); } - Widget _buildFolderTree(Account? account) { - if (account == null) { - return Container(); + void _navigateToMailbox( + BuildContext context, + Account account, + Mailbox mailbox, + ) { + if (!useAppDrawerAsRoot) { + while (context.canPop()) { + context.pop(); + } + } + if (mailbox.isInbox) { + context.goNamed( + Routes.mailForAccount, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, + ); + } else { + context.pushNamed( + Routes.mailForMailbox, + pathParameters: { + Routes.pathParameterEmail: account.email, + Routes.pathParameterEncodedMailboxPath: mailbox.encodedPath, + }, + ); } - return MailboxTree(account: account, onSelected: _navigateToMailbox); } +} - void _navigateToMailbox(Mailbox mailbox) async { - final mailService = locator(); - final account = mailService.currentAccount!; - final messageSourceFuture = - mailService.getMessageSourceFor(account, mailbox: mailbox); - locator().push( - Routes.messageSourceFuture, - arguments: messageSourceFuture, - replace: !Platform.isIOS, - fade: true, +class _SelectableAccountTile extends StatelessWidget { + const _SelectableAccountTile({ + required this.account, + required this.currentAccount, + }); + + final Account account; + final Account? currentAccount; + + @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 (!useAppDrawerAsRoot) { + context.pop(); + } + if (hasError) { + context.pushNamed( + Routes.accountEdit, + pathParameters: { + Routes.pathParameterEmail: account.email, + }, + ); + } else { + context.goNamed( + Routes.mailForAccount, + 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_chip.dart b/lib/widgets/attachment_chip.dart index cd9176c..b008982 100644 --- a/lib/widgets/attachment_chip.dart +++ b/lib/widgets/attachment_chip.dart @@ -1,23 +1,20 @@ 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 'package:go_router/go_router.dart'; -import 'button_text.dart'; +import '../localization/extension.dart'; +import '../logger.dart'; +import '../models/message.dart'; +import '../routes/routes.dart'; +import '../screens/media_screen.dart'; +import '../settings/theme/icon_service.dart'; +import '../util/localized_dialog_helper.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; @@ -33,45 +30,61 @@ class _AttachmentChipState extends State { final _height = 72.0; @override - void initState() { + void didChangeDependencies() { + super.didChangeDependencies(); 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) { + try { + _mediaProvider = + MimeMediaProviderFactory.fromMime(mimeMessage, mimePart); + } catch (e, s) { + _mediaProvider = MimeMediaProviderFactory.fromError( + title: context.text.errorTitle, + text: context.text.attachmentDecodeError(e.toString()), + ); + logger.e( + 'Unable to decode mime-part with headers ${mimePart.headers}: $e', + error: e, + stackTrace: s, + ); + } } - super.initState(); } @override Widget build(BuildContext context) { final mediaType = widget.info.contentType?.mediaType; final name = widget.info.fileName; - if (_mediaProvider == null) { - final fallbackIcon = locator().getForMediaType(mediaType); + final mediaProvider = _mediaProvider; + if (mediaProvider == null) { + final fallbackIcon = IconService.instance.getForMediaType(mediaType); + 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!, + mediaProvider: mediaProvider, width: _width, height: _height, showInteractiveDelegate: _showAttachment, fallbackBuilder: _buildFallbackPreview, interactiveBuilder: _buildInteractiveMedia, interactiveFallbackBuilder: _buildInteractiveFallback, + useHeroAnimation: false, ), ), ); @@ -79,81 +92,84 @@ class _AttachmentChipState extends State { } Widget _buildFallbackPreview(BuildContext context, MediaProvider provider) { - final fallbackIcon = locator() + final fallbackIcon = IconService.instance .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.0), - 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.0), - 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) { @@ -163,20 +179,33 @@ 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, ); - _showAttachment(media); + 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', + ); + if (context.mounted) { + await LocalizedDialogHelper.showTextDialog( + context, + context.text.errorTitle, + context.text.attachmentDownloadError(e.message ?? e.toString()), + ); } } finally { if (mounted) { @@ -188,33 +217,41 @@ 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 context.pushNamed( + Routes.mailDetails, + extra: message, + ); } } - return locator() - .push(Routes.interactiveMedia, arguments: media); + + return context.pushNamed( + Routes.interactiveMedia, + extra: media, + ); } Widget _buildInteractiveFallback( - BuildContext context, MediaProvider mediaProvider) { - final sizeText = locator().formatMemory(mediaProvider.size); + BuildContext context, + MediaProvider mediaProvider, + ) { + final sizeText = context.formatMemory(mediaProvider.size); final localizations = context.text; - final iconData = locator() + final iconData = IconService.instance .getForMediaType(MediaType.fromText(mediaProvider.mediaType)); 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,13 +260,15 @@ class _AttachmentChipState extends State { ), if (sizeText != null) Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(sizeText), ), PlatformTextButton( - child: ButtonText(localizations.attachmentActionOpen), - onPressed: () => InteractiveMediaScreen.share(mediaProvider), - ) + child: Text(localizations.attachmentActionOpen), + onPressed: () => InteractiveMediaScreen.share( + mediaProvider, + ), + ), ], ), ), @@ -237,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( @@ -245,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 42a0ac6..596c1f2 100644 --- a/lib/widgets/attachment_compose_bar.dart +++ b/lib/widgets/attachment_compose_bar.dart @@ -4,32 +4,46 @@ 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 'package:http/http.dart' as http; -import '../l10n/app_localizations.g.dart'; -import '../l10n/extension.dart'; -import '../locator.dart'; +import '../app_lifecycle/provider.dart'; +import '../keys/service.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.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'; -import '../util/http_helper.dart'; +import '../routes/routes.dart'; +import '../settings/theme/icon_service.dart'; import '../util/localized_dialog_helper.dart'; import 'ical_composer.dart'; import 'icon_text.dart'; -class AttachmentMediaProviderFactory { +class _AttachmentMediaProviderFactory { static MediaProvider fromAttachmentInfo(AttachmentInfo info) => - MemoryMediaProvider(info.name!, info.mediaType.text, info.data!); + MemoryMediaProvider( + info.name ?? '', + info.mediaType.text, + info.data ?? Uint8List(0), + ); } +/// Allows to add attachments to a [ComposeData] class AttachmentComposeBar extends StatefulWidget { - const AttachmentComposeBar( - {super.key, required this.composeData, this.isDownloading = false}); + /// Creates a new [AttachmentComposeBar] + const AttachmentComposeBar({ + super.key, + required this.composeData, + this.isDownloading = false, + }); + + /// The associated [ComposeData] final ComposeData composeData; + + /// Set to true if the attachments are currently downloading final bool isDownloading; @override @@ -46,31 +60,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); @@ -80,16 +84,23 @@ class _AttachmentComposeBarState extends State { } } -class AddAttachmentPopupButton extends StatelessWidget { - const AddAttachmentPopupButton( - {super.key, required this.composeData, required this.update}); +class AddAttachmentPopupButton extends ConsumerWidget { + const AddAttachmentPopupButton({ + super.key, + required this.composeData, + required this.update, + }); final ComposeData composeData; final Function() update; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + void ignoreNextResume() => ref + .read(appLifecycleProvider.notifier) + .ignoreNextInactivationCycle(timeout: const Duration(seconds: 120)); + final localizations = context.text; - final iconService = locator(); + final iconService = IconService.instance; const brightness = Brightness.light; // TODO(RV): implement brightness access // themeService.brightness(context); @@ -137,7 +148,7 @@ class AddAttachmentPopupButton extends StatelessWidget { brightness: brightness, ), ), - if (locator().hasGiphy) + if (KeyService.instance.hasGiphy) PlatformPopupMenuItem( value: 5, child: IconText( @@ -159,32 +170,51 @@ class AddAttachmentPopupButton extends StatelessWidget { var changed = false; switch (value) { case 0: // any file - changed = await addAttachmentFile(); + ignoreNextResume(); + changed = await _addAttachmentFile(); break; case 1: // photo file - changed = await addAttachmentFile(fileType: FileType.image); + ignoreNextResume(); + changed = await _addAttachmentFile( + fileType: FileType.image, + ); break; case 2: // video file - changed = await addAttachmentFile(fileType: FileType.video); + ignoreNextResume(); + changed = await _addAttachmentFile( + fileType: FileType.video, + ); break; case 3: // audio file - changed = await addAttachmentFile(fileType: FileType.audio); + ignoreNextResume(); + 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, localizations); + if (context.mounted) { + changed = + await addAttachmentAppointment(context, ref, localizations); + } break; } if (changed) { @@ -194,24 +224,32 @@ class AddAttachmentPopupButton extends StatelessWidget { ); } - Future addAttachmentFile({FileType fileType = FileType.any}) async { + Future _addAttachmentFile({ + FileType fileType = FileType.any, + }) async { final result = await FilePicker.platform .pickFiles(type: fileType, allowMultiple: true, withData: true); if (result == null) { return false; } for (final file in result.files) { - final lastDotIndex = file.path!.lastIndexOf('.'); + final path = file.path; + final bytes = file.bytes; + if (path == null || bytes == null) { + continue; + } + final lastDotIndex = path.lastIndexOf('.'); MediaType mediaType; - if (lastDotIndex == -1 || lastDotIndex == file.path!.length - 1) { + if (lastDotIndex == -1 || lastDotIndex == path.length - 1) { mediaType = MediaType.fromSubtype(MediaSubtype.applicationOctetStream); } else { - final ext = file.path!.substring(lastDotIndex + 1); + final ext = path.substring(lastDotIndex + 1); mediaType = MediaType.guessFromFileExtension(ext); } composeData.messageBuilder - .addBinary(file.bytes!, mediaType, filename: file.name); + .addBinary(bytes, mediaType, filename: file.name); } + return true; } @@ -219,12 +257,14 @@ class AddAttachmentPopupButton extends StatelessWidget { BuildContext context, AppLocalizations localizations, ) async { - final giphy = locator().giphy; + final giphy = KeyService.instance.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; } @@ -234,7 +274,7 @@ class AddAttachmentPopupButton extends StatelessWidget { // searchLabelText: searchSticker // ? localizations.attachTypeStickerSearch // : localizations.attachTypeGifSearch, - lang: locator().locale!.languageCode, + lang: localizations.localeName, keepState: true, showPreview: true, // sticker: searchSticker, @@ -244,24 +284,30 @@ class AddAttachmentPopupButton extends StatelessWidget { if (gif == null || contentUrl == null) { return false; } - final result = await HttpHelper.httpGet(contentUrl); - final data = result.data; - if (data == null) { + final response = await http.get(Uri.parse(contentUrl)); + if (response.statusCode != 200) { return false; } + final data = response.bodyBytes; 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 + // 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, @@ -271,10 +317,11 @@ class AddAttachmentPopupButton extends StatelessWidget { filename: 'invite.ics', ), ); - attachmentBuilder.contentType!.setParameter('method', 'REQUEST'); + attachmentBuilder.contentType?.setParameter('method', 'REQUEST'); final finalizer = _AppointmentFinalizer(appointment, attachmentBuilder); composeData.addFinalizer(finalizer.finalize); } + return (appointment != null); } } @@ -285,82 +332,125 @@ class _AppointmentFinalizer { final PartBuilder attachmentBuilder; void finalize(MessageBuilder messageBuilder) { - final event = appointment.event!; - if (messageBuilder.from?.isNotEmpty == true) { - final organizer = messageBuilder.from!.first; + final event = appointment.event; + if (event == null) { + return; + } + void addAttendee({ + required String email, + required String? name, + bool rsvp = true, + ParticipantStatus? participantStatus, + }) { + final attendeeProperty = AttendeeProperty.create( + attendeeEmail: email, + commonName: name, + rsvp: rsvp, + participantStatus: participantStatus, + ); + if (attendeeProperty != null) { + event.addAttendee(attendeeProperty); + } + } + + final from = messageBuilder.from; + if (from != null && from.isNotEmpty) { + final organizer = from.first; event.organizer = OrganizerProperty.create( email: organizer.email, commonName: organizer.personalName, ); - event.addAttendee(AttendeeProperty.create( - attendeeEmail: organizer.email, - commonName: organizer.personalName, + addAttendee( + email: organizer.email, + name: organizer.personalName, + rsvp: false, participantStatus: ParticipantStatus.accepted, - )!); + ); } final recipients = []; - if (messageBuilder.to != null) { - recipients.addAll(messageBuilder.to!); - } - if (messageBuilder.cc != null) { - recipients.addAll(messageBuilder.cc!); + void addRecipients(List? addresses) { + if (addresses != null) { + recipients.addAll(addresses); + } } + + addRecipients(messageBuilder.to); + addRecipients(messageBuilder.cc); for (final mailAddress in recipients) { - event.addAttendee(AttendeeProperty.create( - attendeeEmail: mailAddress.email, - commonName: mailAddress.personalName, - rsvp: true, - )!); + addAttendee(email: mailAddress.email, name: mailAddress.personalName); } attachmentBuilder.text = appointment.toString(); } } -class ComposeAttachment extends StatelessWidget { - const ComposeAttachment( - {super.key, required this.attachment, required this.onRemove}); +class _ComposeAttachment extends ConsumerWidget { + const _ComposeAttachment({ + 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( borderRadius: BorderRadius.circular(8), child: PreviewMediaWidget( mediaProvider: - AttachmentMediaProviderFactory.fromAttachmentInfo(attachment), + _AttachmentMediaProviderFactory.fromAttachmentInfo(attachment), width: 60, height: 60, showInteractiveDelegate: (interactiveMedia) async { - if (attachment.mediaType.sub == MediaSubtype.messageRfc822) { - final mime = MimeMessage.parseFromData(attachment.data!); - final message = Message.embedded(mime, Message.of(context)!); - return locator() - .push(Routes.mailDetails, arguments: message); + final attachmentData = attachment.data; + if (attachment.mediaType.sub == MediaSubtype.messageRfc822 && + parentMessage != null && + attachmentData != null) { + final mime = MimeMessage.parseFromData(attachmentData); + final message = Message.embedded(mime, parentMessage); + + return context.pushNamed( + Routes.mailDetails, + extra: 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 attachmentText = attachment.part.text; + if (attachmentText != null && + (attachment.mediaType.sub == MediaSubtype.applicationIcs || + attachment.mediaType.sub == MediaSubtype.textCalendar)) { + final appointment = VComponent.parse(attachmentText) as VCalendar; + final update = await IcalComposer.createOrEditAppointment( + context, + ref, + appointment: appointment, + ); if (update != null) { attachment.part.text = update.toString(); } - return; + + return Future.value(); } - return locator() - .push(Routes.interactiveMedia, arguments: interactiveMedia); + return context.pushNamed( + Routes.interactiveMedia, + extra: 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 deleted file mode 100644 index be1f9b2..0000000 --- a/lib/widgets/button_text.dart +++ /dev/null @@ -1,26 +0,0 @@ -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); - - @override - Widget build(BuildContext context) { - var text = data; - if (Platform.isAndroid) { - text = text!.toUpperCase(); - } - return Text( - text!, - style: style, - ); - } -} diff --git a/lib/widgets/cupertino_status_bar.dart b/lib/widgets/cupertino_status_bar.dart index 33a93ad..ff535b9 100644 --- a/lib/widgets/cupertino_status_bar.dart +++ b/lib/widgets/cupertino_status_bar.dart @@ -1,23 +1,21 @@ 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 '../locator.dart'; +import '../localization/extension.dart'; +import '../scaffold_messenger/service.dart'; /// Status bar for cupertino. /// /// 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 +23,12 @@ class CupertinoStatusBar extends StatefulWidget { @override CupertinoStatusBarState createState() => CupertinoStatusBarState(); - static Widget? createInfo(String? text) { - return (text == null) - ? null - : Text( - text, - style: _statusTextStyle, - ); - } + static Widget? createInfo(String? text) => (text == null) + ? null + : Text( + text, + style: _statusTextStyle, + ); } class CupertinoStatusBarState extends State { @@ -43,13 +39,13 @@ class CupertinoStatusBarState extends State { @override void initState() { super.initState(); - locator().statusBarState = this; + ScaffoldMessengerService.instance.statusBarState = this; } @override void dispose() { super.dispose(); - locator().popStatusBarState(); + ScaffoldMessengerService.instance.popStatusBarState(); } @override @@ -76,20 +72,20 @@ class CupertinoStatusBarState extends State { child: _status, ) : widget.info ?? Container(); + return CupertinoBar( blurBackground: true, backgroundOpacity: 0.8, 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,19 +111,19 @@ 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, + context.text.actionUndo, style: CupertinoStatusBar._statusTextStyle, ), onPressed: () { diff --git a/lib/widgets/editor_extensions.dart b/lib/widgets/editor_extensions.dart index 85335a7..c8beddb 100644 --- a/lib/widgets/editor_extensions.dart +++ b/lib/widgets/editor_extensions.dart @@ -1,50 +1,51 @@ 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 'package:enough_platform_widgets/enough_platform_widgets.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; -import '../locator.dart'; +import '../localization/extension.dart'; +import '../util/localized_dialog_helper.dart'; +/// A button to open the art extension dialog. class EditorArtExtensionButton extends StatelessWidget { - const EditorArtExtensionButton({Key? key, required this.editorApi}) - : super(key: key); + /// Creates a new [EditorArtExtensionButton]. + const EditorArtExtensionButton({super.key, required this.editorApi}); + + /// The editor API. final HtmlEditorApi editorApi; @override - Widget build(BuildContext context) { - return PlatformIconButton( - icon: const Icon(CommunityMaterialIcons.format_font), - onPressed: () => showArtExtensionDialog(context, editorApi), - ); - } + Widget build(BuildContext context) => PlatformIconButton( + icon: const Icon(CommunityMaterialIcons.format_font), + onPressed: () => showArtExtensionDialog(context, editorApi), + ); + /// Shows the art extension dialog. static void showArtExtensionDialog( - BuildContext context, HtmlEditorApi editorApi) { + BuildContext context, + HtmlEditorApi editorApi, + ) { //final localizations = context.text; LocalizedDialogHelper.showWidgetDialog( context, - EditorArtExtensionWidget(editorApi: editorApi), + _EditorArtExtensionWidget(editorApi: editorApi), defaultActions: DialogActions.cancel, ); } } -class EditorArtExtensionWidget extends StatefulWidget { +class _EditorArtExtensionWidget extends StatefulWidget { + const _EditorArtExtensionWidget({required this.editorApi}); final HtmlEditorApi editorApi; - const EditorArtExtensionWidget({Key? key, required this.editorApi}) - : super(key: key); @override - State createState() => + State<_EditorArtExtensionWidget> createState() => _EditorArtExtensionWidgetState(); } -class _EditorArtExtensionWidgetState extends State { +class _EditorArtExtensionWidgetState extends State<_EditorArtExtensionWidget> { final _inputController = TextEditingController(); final _textsByUnicodeFont = {}; @@ -53,7 +54,7 @@ class _EditorArtExtensionWidgetState extends State { super.initState(); widget.editorApi.getSelectedText().then((value) { _updateTexts(value); - _inputController.text = value!; + _inputController.text = value ?? ''; }); } @@ -73,25 +74,30 @@ class _EditorArtExtensionWidgetState extends State { UnicodeFont.fraktur: localizations.fontFraktur, UnicodeFont.frakturBold: localizations.fontFrakturBold, UnicodeFont.monospace: localizations.fontMonospace, + // cSpell: disable UnicodeFont.fullwidth: localizations.fontFullwidth, UnicodeFont.doublestruck: localizations.fontDoublestruck, + // cSpell: enable UnicodeFont.capitalized: localizations.fontCapitalized, UnicodeFont.circled: localizations.fontCircled, UnicodeFont.parenthesized: localizations.fontParenthesized, UnicodeFont.underlinedSingle: localizations.fontUnderlinedSingle, UnicodeFont.underlinedDouble: localizations.fontUnderlinedDouble, + // cSpell: disable UnicodeFont.strikethroughSingle: localizations.fontStrikethroughSingle, + // cSpell: enable }; final captionStyle = Theme.of(context).textTheme.bodySmall; + return SingleChildScrollView( child: Column( 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, @@ -105,15 +111,16 @@ class _EditorArtExtensionWidgetState extends State { style: captionStyle, ), PlatformTextButton( - child: ButtonText(_textsByUnicodeFont[unicodeFont] ?? - localizations.editorArtWaitingForInputHint), + child: Text( + _textsByUnicodeFont[unicodeFont] ?? + localizations.editorArtWaitingForInputHint, + ), onPressed: () { final text = _textsByUnicodeFont[unicodeFont]; if (text != null && text.isNotEmpty) { widget.editorApi.insertText(text); } - final navService = locator(); - navService.pop(); + context.pop(); }, ), const Divider(), @@ -127,7 +134,7 @@ class _EditorArtExtensionWidgetState extends State { for (final unicodeFont in UnicodeFont.values) { if (unicodeFont != UnicodeFont.normal) { _textsByUnicodeFont[unicodeFont] = - UnicodeFontConverter.encode(input!, unicodeFont); + UnicodeFontConverter.encode(input ?? 'hello world', unicodeFont); } } setState(() {}); 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 deleted file mode 100644 index ed5ab3e..0000000 --- a/lib/widgets/expanding_wrap.dart +++ /dev/null @@ -1,1028 +0,0 @@ -// Based on wrap.dart of Flutter, https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/rendering/wrap.dart -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:math' as math; -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -class ExpansionWrap2 extends MultiChildRenderObjectWidget { - /// Creates a wrap layout. - /// - /// By default, the wrap layout is horizontal and both the children and the - /// runs are aligned to the start. - /// - /// The [textDirection] argument defaults to the ambient [Directionality], if - /// any. If there is no ambient directionality, and a text direction is going - /// to be necessary to decide which direction to lay the children in or to - /// disambiguate `start` or `end` values for the main or cross axis - /// directions, the [textDirection] must not be null. - ExpansionWrap2({ - Key? key, - this.direction = Axis.horizontal, - this.alignment = WrapAlignment.start, - this.spacing = 0.0, - this.runAlignment = WrapAlignment.start, - this.runSpacing = 0.0, - this.crossAxisAlignment = WrapCrossAlignment.start, - this.textDirection, - this.verticalDirection = VerticalDirection.down, - this.clipBehavior = Clip.none, - List children = const [], - this.maxRuns, - required this.overflow, - }) : super(key: key, children: [...children, overflow]); - - final Widget overflow; - - /// The direction to use as the main axis. - /// - /// For example, if [direction] is [Axis.horizontal], the default, the - /// children are placed adjacent to one another in a horizontal run until the - /// available horizontal space is consumed, at which point a subsequent - /// children are placed in a new run vertically adjacent to the previous run. - final Axis direction; - - /// How the children within a run should be placed in the main axis. - /// - /// For example, if [alignment] is [WrapAlignment.center], the children in - /// each run are grouped together in the center of their run in the main axis. - /// - /// Defaults to [WrapAlignment.start]. - /// - /// See also: - /// - /// * [runAlignment], which controls how the runs are placed relative to each - /// other in the cross axis. - /// * [crossAxisAlignment], which controls how the children within each run - /// are placed relative to each other in the cross axis. - final WrapAlignment alignment; - - /// How much space to place between children in a run in the main axis. - /// - /// For example, if [spacing] is 10.0, the children will be spaced at least - /// 10.0 logical pixels apart in the main axis. - /// - /// If there is additional free space in a run (e.g., because the wrap has a - /// minimum size that is not filled or because some runs are longer than - /// others), the additional free space will be allocated according to the - /// [alignment]. - /// - /// Defaults to 0.0. - final double spacing; - - /// How the runs themselves should be placed in the cross axis. - /// - /// For example, if [runAlignment] is [WrapAlignment.center], the runs are - /// grouped together in the center of the overall [Wrap] in the cross axis. - /// - /// Defaults to [WrapAlignment.start]. - /// - /// See also: - /// - /// * [alignment], which controls how the children within each run are placed - /// relative to each other in the main axis. - /// * [crossAxisAlignment], which controls how the children within each run - /// are placed relative to each other in the cross axis. - final WrapAlignment runAlignment; - - /// How much space to place between the runs themselves in the cross axis. - /// - /// For example, if [runSpacing] is 10.0, the runs will be spaced at least - /// 10.0 logical pixels apart in the cross axis. - /// - /// If there is additional free space in the overall [Wrap] (e.g., because - /// the wrap has a minimum size that is not filled), the additional free space - /// will be allocated according to the [runAlignment]. - /// - /// Defaults to 0.0. - final double runSpacing; - - /// How the children within a run should be aligned relative to each other in - /// the cross axis. - /// - /// For example, if this is set to [WrapCrossAlignment.end], and the - /// [direction] is [Axis.horizontal], then the children within each - /// run will have their bottom edges aligned to the bottom edge of the run. - /// - /// Defaults to [WrapCrossAlignment.start]. - /// - /// See also: - /// - /// * [alignment], which controls how the children within each run are placed - /// relative to each other in the main axis. - /// * [runAlignment], which controls how the runs are placed relative to each - /// other in the cross axis. - final WrapCrossAlignment crossAxisAlignment; - - /// Determines the order to lay children out horizontally and how to interpret - /// `start` and `end` in the horizontal direction. - /// - /// Defaults to the ambient [Directionality]. - /// - /// If the [direction] is [Axis.horizontal], this controls order in which the - /// children are positioned (left-to-right or right-to-left), and the meaning - /// of the [alignment] property's [WrapAlignment.start] and - /// [WrapAlignment.end] values. - /// - /// If the [direction] is [Axis.horizontal], and either the - /// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or - /// there's more than one child, then the [textDirection] (or the ambient - /// [Directionality]) must not be null. - /// - /// If the [direction] is [Axis.vertical], this controls the order in which - /// runs are positioned, the meaning of the [runAlignment] property's - /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the - /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and - /// [WrapCrossAlignment.end] values. - /// - /// If the [direction] is [Axis.vertical], and either the - /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the - /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or - /// [WrapCrossAlignment.end], or there's more than one child, then the - /// [textDirection] (or the ambient [Directionality]) must not be null. - final TextDirection? textDirection; - - /// Determines the order to lay children out vertically and how to interpret - /// `start` and `end` in the vertical direction. - /// - /// If the [direction] is [Axis.vertical], this controls which order children - /// are painted in (down or up), the meaning of the [alignment] property's - /// [WrapAlignment.start] and [WrapAlignment.end] values. - /// - /// If the [direction] is [Axis.vertical], and either the [alignment] - /// is either [WrapAlignment.start] or [WrapAlignment.end], or there's - /// more than one child, then the [verticalDirection] must not be null. - /// - /// If the [direction] is [Axis.horizontal], this controls the order in which - /// runs are positioned, the meaning of the [runAlignment] property's - /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the - /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and - /// [WrapCrossAlignment.end] values. - /// - /// If the [direction] is [Axis.horizontal], and either the - /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the - /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or - /// [WrapCrossAlignment.end], or there's more than one child, then the - /// [verticalDirection] must not be null. - final VerticalDirection verticalDirection; - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// Defaults to [Clip.none]. - final Clip clipBehavior; - - final int? maxRuns; - - @override - RenderExpansionWrap createRenderObject(BuildContext context) { - return RenderExpansionWrap( - direction: direction, - alignment: alignment, - spacing: spacing, - runAlignment: runAlignment, - runSpacing: runSpacing, - crossAxisAlignment: crossAxisAlignment, - textDirection: textDirection ?? Directionality.maybeOf(context), - verticalDirection: verticalDirection, - clipBehavior: clipBehavior, - maxRuns: maxRuns, - ); - } - - @override - void updateRenderObject( - BuildContext context, RenderExpansionWrap renderObject) { - renderObject - ..direction = direction - ..alignment = alignment - ..spacing = spacing - ..runAlignment = runAlignment - ..runSpacing = runSpacing - ..crossAxisAlignment = crossAxisAlignment - ..textDirection = textDirection ?? Directionality.maybeOf(context) - ..verticalDirection = verticalDirection - ..clipBehavior = clipBehavior - ..maxRuns = maxRuns; - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(IntProperty('maxRuns', maxRuns)); - properties.add(EnumProperty('direction', direction)); - properties.add(EnumProperty('alignment', alignment)); - properties.add(DoubleProperty('spacing', spacing)); - properties.add(EnumProperty('runAlignment', runAlignment)); - properties.add(DoubleProperty('runSpacing', runSpacing)); - properties.add(DoubleProperty('crossAxisAlignment', runSpacing)); - properties.add(EnumProperty('textDirection', textDirection, - defaultValue: null)); - properties.add(EnumProperty( - 'verticalDirection', verticalDirection, - defaultValue: VerticalDirection.down)); - } -} - -class _RunMetrics { - _RunMetrics(this.mainAxisExtent, this.crossAxisExtent, this.childCount); - final double mainAxisExtent; - final double crossAxisExtent; - final int childCount; -} - -/// Parent data for use with [RenderExpansionWrap]. -/// -/// Contains offset for each child and the run index. -class ExpansionWrapParentData extends ContainerBoxParentData { - int _runIndex = -1; -} - -/// Displays its children in multiple horizontal or vertical runs with the maximum number of runs limited. -/// -/// A [ExpansionRenderWrap] lays out each child and attempts to place the child adjacent -/// to the previous child in the main axis, given by [direction], leaving -/// [spacing] space in between. If there is not enough space to fit the child, -/// [ExpansionRenderWrap] creates a new _run_ adjacent to the existing children in the -/// cross axis untol the maximum number of runs is reached. -/// -/// After all the children have been allocated to runs, the children within the -/// runs are positioned according to the [alignment] in the main axis and -/// according to the [crossAxisAlignment] in the cross axis. -/// -/// The runs themselves are then positioned in the cross axis according to the -/// [runSpacing] and [runAlignment]. -class RenderExpansionWrap extends RenderBox - with - ContainerRenderObjectMixin, - RenderBoxContainerDefaultsMixin { - /// Creates a wrap render object. - /// - /// By default, the wrap layout is horizontal and both the children and the - /// runs are aligned to the start. - RenderExpansionWrap({ - List? children, - Axis direction = Axis.horizontal, - WrapAlignment alignment = WrapAlignment.start, - double spacing = 0.0, - WrapAlignment runAlignment = WrapAlignment.start, - double runSpacing = 0.0, - WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.start, - TextDirection? textDirection, - VerticalDirection verticalDirection = VerticalDirection.down, - Clip clipBehavior = Clip.none, - int? maxRuns, - }) : _direction = direction, - _alignment = alignment, - _spacing = spacing, - _runAlignment = runAlignment, - _runSpacing = runSpacing, - _crossAxisAlignment = crossAxisAlignment, - _textDirection = textDirection, - _verticalDirection = verticalDirection, - _clipBehavior = clipBehavior, - _maxRuns = maxRuns { - addAll(children); - } - - int? _maxRuns; - int? get maxRuns => _maxRuns; - set maxRuns(int? value) { - if (_maxRuns == value) return; - _maxRuns = value; - markNeedsLayout(); - } - - /// The direction to use as the main axis. - /// - /// For example, if [direction] is [Axis.horizontal], the default, the - /// children are placed adjacent to one another in a horizontal run until the - /// available horizontal space is consumed, at which point a subsequent - /// children are placed in a new run vertically adjacent to the previous run. - Axis get direction => _direction; - Axis _direction; - set direction(Axis value) { - if (_direction == value) return; - _direction = value; - markNeedsLayout(); - } - - /// How the children within a run should be placed in the main axis. - /// - /// For example, if [alignment] is [WrapAlignment.center], the children in - /// each run are grouped together in the center of their run in the main axis. - /// - /// Defaults to [WrapAlignment.start]. - /// - /// See also: - /// - /// * [runAlignment], which controls how the runs are placed relative to each - /// other in the cross axis. - /// * [crossAxisAlignment], which controls how the children within each run - /// are placed relative to each other in the cross axis. - WrapAlignment get alignment => _alignment; - WrapAlignment _alignment; - set alignment(WrapAlignment value) { - if (_alignment == value) return; - _alignment = value; - markNeedsLayout(); - } - - /// How much space to place between children in a run in the main axis. - /// - /// For example, if [spacing] is 10.0, the children will be spaced at least - /// 10.0 logical pixels apart in the main axis. - /// - /// If there is additional free space in a run (e.g., because the wrap has a - /// minimum size that is not filled or because some runs are longer than - /// others), the additional free space will be allocated according to the - /// [alignment]. - /// - /// Defaults to 0.0. - double get spacing => _spacing; - double _spacing; - set spacing(double value) { - if (_spacing == value) return; - _spacing = value; - markNeedsLayout(); - } - - /// How the runs themselves should be placed in the cross axis. - /// - /// For example, if [runAlignment] is [WrapAlignment.center], the runs are - /// grouped together in the center of the overall [RenderWrap] in the cross - /// axis. - /// - /// Defaults to [WrapAlignment.start]. - /// - /// See also: - /// - /// * [alignment], which controls how the children within each run are placed - /// relative to each other in the main axis. - /// * [crossAxisAlignment], which controls how the children within each run - /// are placed relative to each other in the cross axis. - WrapAlignment get runAlignment => _runAlignment; - WrapAlignment _runAlignment; - set runAlignment(WrapAlignment value) { - if (_runAlignment == value) return; - _runAlignment = value; - markNeedsLayout(); - } - - /// How much space to place between the runs themselves in the cross axis. - /// - /// For example, if [runSpacing] is 10.0, the runs will be spaced at least - /// 10.0 logical pixels apart in the cross axis. - /// - /// If there is additional free space in the overall [RenderWrap] (e.g., - /// because the wrap has a minimum size that is not filled), the additional - /// free space will be allocated according to the [runAlignment]. - /// - /// Defaults to 0.0. - double get runSpacing => _runSpacing; - double _runSpacing; - set runSpacing(double value) { - if (_runSpacing == value) return; - _runSpacing = value; - markNeedsLayout(); - } - - /// How the children within a run should be aligned relative to each other in - /// the cross axis. - /// - /// For example, if this is set to [WrapCrossAlignment.end], and the - /// [direction] is [Axis.horizontal], then the children within each - /// run will have their bottom edges aligned to the bottom edge of the run. - /// - /// Defaults to [WrapCrossAlignment.start]. - /// - /// See also: - /// - /// * [alignment], which controls how the children within each run are placed - /// relative to each other in the main axis. - /// * [runAlignment], which controls how the runs are placed relative to each - /// other in the cross axis. - WrapCrossAlignment get crossAxisAlignment => _crossAxisAlignment; - WrapCrossAlignment _crossAxisAlignment; - set crossAxisAlignment(WrapCrossAlignment value) { - if (_crossAxisAlignment == value) return; - _crossAxisAlignment = value; - markNeedsLayout(); - } - - /// Determines the order to lay children out horizontally and how to interpret - /// `start` and `end` in the horizontal direction. - /// - /// If the [direction] is [Axis.horizontal], this controls the order in which - /// children are positioned (left-to-right or right-to-left), and the meaning - /// of the [alignment] property's [WrapAlignment.start] and - /// [WrapAlignment.end] values. - /// - /// If the [direction] is [Axis.horizontal], and either the - /// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or - /// there's more than one child, then the [textDirection] must not be null. - /// - /// If the [direction] is [Axis.vertical], this controls the order in - /// which runs are positioned, the meaning of the [runAlignment] property's - /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the - /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and - /// [WrapCrossAlignment.end] values. - /// - /// If the [direction] is [Axis.vertical], and either the - /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the - /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or - /// [WrapCrossAlignment.end], or there's more than one child, then the - /// [textDirection] must not be null. - TextDirection? get textDirection => _textDirection; - TextDirection? _textDirection; - set textDirection(TextDirection? value) { - if (_textDirection != value) { - _textDirection = value; - markNeedsLayout(); - } - } - - /// Determines the order to lay children out vertically and how to interpret - /// `start` and `end` in the vertical direction. - /// - /// If the [direction] is [Axis.vertical], this controls which order children - /// are painted in (down or up), the meaning of the [alignment] property's - /// [WrapAlignment.start] and [WrapAlignment.end] values. - /// - /// If the [direction] is [Axis.vertical], and either the [alignment] - /// is either [WrapAlignment.start] or [WrapAlignment.end], or there's - /// more than one child, then the [verticalDirection] must not be null. - /// - /// If the [direction] is [Axis.horizontal], this controls the order in which - /// runs are positioned, the meaning of the [runAlignment] property's - /// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the - /// [crossAxisAlignment] property's [WrapCrossAlignment.start] and - /// [WrapCrossAlignment.end] values. - /// - /// If the [direction] is [Axis.horizontal], and either the - /// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the - /// [crossAxisAlignment] is either [WrapCrossAlignment.start] or - /// [WrapCrossAlignment.end], or there's more than one child, then the - /// [verticalDirection] must not be null. - VerticalDirection get verticalDirection => _verticalDirection; - VerticalDirection _verticalDirection; - set verticalDirection(VerticalDirection value) { - if (_verticalDirection != value) { - _verticalDirection = value; - markNeedsLayout(); - } - } - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// Defaults to [Clip.none], and must not be null. - Clip get clipBehavior => _clipBehavior; - Clip _clipBehavior = Clip.none; - set clipBehavior(Clip value) { - if (value != _clipBehavior) { - _clipBehavior = value; - markNeedsPaint(); - markNeedsSemanticsUpdate(); - } - } - - bool get _debugHasNecessaryDirections { - if (firstChild != null && lastChild != firstChild) { - // i.e. there's more than one child - switch (direction) { - case Axis.horizontal: - assert(textDirection != null, - 'Horizontal $runtimeType with multiple children has a null textDirection, so the layout order is undefined.'); - break; - case Axis.vertical: - break; - } - } - if (alignment == WrapAlignment.start || alignment == WrapAlignment.end) { - switch (direction) { - case Axis.horizontal: - assert(textDirection != null, - 'Horizontal $runtimeType with alignment $alignment has a null textDirection, so the alignment cannot be resolved.'); - break; - case Axis.vertical: - break; - } - } - if (runAlignment == WrapAlignment.start || - runAlignment == WrapAlignment.end) { - switch (direction) { - case Axis.horizontal: - break; - case Axis.vertical: - assert(textDirection != null, - 'Vertical $runtimeType with runAlignment $runAlignment has a null textDirection, so the alignment cannot be resolved.'); - break; - } - } - if (crossAxisAlignment == WrapCrossAlignment.start || - crossAxisAlignment == WrapCrossAlignment.end) { - switch (direction) { - case Axis.horizontal: - break; - case Axis.vertical: - assert(textDirection != null, - 'Vertical $runtimeType with crossAxisAlignment $crossAxisAlignment has a null textDirection, so the alignment cannot be resolved.'); - break; - } - } - return true; - } - - @override - void setupParentData(RenderBox child) { - if (child.parentData is! ExpansionWrapParentData) { - child.parentData = ExpansionWrapParentData(); - } - } - - @override - double computeMinIntrinsicWidth(double height) { - switch (direction) { - case Axis.horizontal: - double width = 0.0; - RenderBox? child = firstChild; - while (child != null) { - width = math.max(width, child.getMinIntrinsicWidth(double.infinity)); - child = childAfter(child); - } - return width; - case Axis.vertical: - return computeDryLayout(BoxConstraints(maxHeight: height)).width; - } - } - - @override - double computeMaxIntrinsicWidth(double height) { - switch (direction) { - case Axis.horizontal: - double width = 0.0; - RenderBox? child = firstChild; - while (child != null) { - width += child.getMaxIntrinsicWidth(double.infinity); - child = childAfter(child); - } - return width; - case Axis.vertical: - return computeDryLayout(BoxConstraints(maxHeight: height)).width; - } - } - - @override - double computeMinIntrinsicHeight(double width) { - switch (direction) { - case Axis.horizontal: - return computeDryLayout(BoxConstraints(maxWidth: width)).height; - case Axis.vertical: - double height = 0.0; - RenderBox? child = firstChild; - while (child != null) { - height = - math.max(height, child.getMinIntrinsicHeight(double.infinity)); - child = childAfter(child); - } - return height; - } - } - - @override - double computeMaxIntrinsicHeight(double width) { - switch (direction) { - case Axis.horizontal: - return computeDryLayout(BoxConstraints(maxWidth: width)).height; - case Axis.vertical: - double height = 0.0; - RenderBox? child = firstChild; - while (child != null) { - height += child.getMaxIntrinsicHeight(double.infinity); - child = childAfter(child); - } - return height; - } - } - - @override - double? computeDistanceToActualBaseline(TextBaseline baseline) { - return defaultComputeDistanceToHighestActualBaseline(baseline); - } - - double _getMainAxisExtent(Size childSize) { - switch (direction) { - case Axis.horizontal: - return childSize.width; - case Axis.vertical: - return childSize.height; - } - } - - double _getCrossAxisExtent(Size childSize) { - switch (direction) { - case Axis.horizontal: - return childSize.height; - case Axis.vertical: - return childSize.width; - } - } - - Offset _getOffset(double mainAxisOffset, double crossAxisOffset) { - switch (direction) { - case Axis.horizontal: - return Offset(mainAxisOffset, crossAxisOffset); - case Axis.vertical: - return Offset(crossAxisOffset, mainAxisOffset); - } - } - - double _getChildCrossAxisOffset(bool flipCrossAxis, double runCrossAxisExtent, - double childCrossAxisExtent) { - final double freeSpace = runCrossAxisExtent - childCrossAxisExtent; - switch (crossAxisAlignment) { - case WrapCrossAlignment.start: - return flipCrossAxis ? freeSpace : 0.0; - case WrapCrossAlignment.end: - return flipCrossAxis ? 0.0 : freeSpace; - case WrapCrossAlignment.center: - return freeSpace / 2.0; - } - } - - @override - Size computeDryLayout(BoxConstraints constraints) { - return _computeDryLayout(constraints); - } - - Size _computeDryLayout(BoxConstraints constraints, - [ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild]) { - final BoxConstraints childConstraints; - double mainAxisLimit = 0.0; - switch (direction) { - case Axis.horizontal: - childConstraints = BoxConstraints(maxWidth: constraints.maxWidth); - mainAxisLimit = constraints.maxWidth; - break; - case Axis.vertical: - childConstraints = BoxConstraints(maxHeight: constraints.maxHeight); - mainAxisLimit = constraints.maxHeight; - break; - } - - double mainAxisExtent = 0.0; - double crossAxisExtent = 0.0; - double runMainAxisExtent = 0.0; - double runCrossAxisExtent = 0.0; - int childCount = 0; - RenderBox overflow = lastChild!; - final overflowSize = layoutChild(overflow, childConstraints); - final double overflowMainAxisExtent = _getMainAxisExtent(overflowSize); - // final double overflowCrossAxisExtent = _getCrossAxisExtent(overflowSize); - int numberOfRuns = 1; - final runsMax = _maxRuns ?? 1000; - RenderBox? child = firstChild; - while (child != null) { - RenderBox? nextChild = childAfter(child); - if (nextChild == null) { - // the last child is the overflow widget, abort: - break; - } - final Size childSize = layoutChild(child, childConstraints); - final double childMainAxisExtent = _getMainAxisExtent(childSize); - final double childCrossAxisExtent = _getCrossAxisExtent(childSize); - // There must be at least one child before we move on to the next run. - if (childCount > 0 && - // for runs below the maximum number just check if the child fits: - ((numberOfRuns < runsMax && - runMainAxisExtent + childMainAxisExtent + spacing > - mainAxisLimit) || - // for the last run check if the child AND the overflow fits: - (numberOfRuns == runsMax && - runMainAxisExtent + - childMainAxisExtent + - spacing + - overflowMainAxisExtent > - mainAxisLimit))) { - if (numberOfRuns == runsMax) { - runMainAxisExtent += spacing + overflowMainAxisExtent; - } - mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); - crossAxisExtent += runCrossAxisExtent + runSpacing; - runMainAxisExtent = 0.0; - runCrossAxisExtent = 0.0; - childCount = 0; - // numberOfRuns++; - // if (numberOfRuns > runsMax) { - // // stop and display overflow: - // break; - // } - } - runMainAxisExtent += childMainAxisExtent; - runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent); - if (childCount > 0) runMainAxisExtent += spacing; - childCount += 1; - child = childAfter(child); - } - crossAxisExtent += runCrossAxisExtent; - mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); - - switch (direction) { - case Axis.horizontal: - return constraints.constrain(Size(mainAxisExtent, crossAxisExtent)); - case Axis.vertical: - return constraints.constrain(Size(crossAxisExtent, mainAxisExtent)); - } - } - - @override - void performLayout() { - final BoxConstraints constraints = this.constraints; - assert(_debugHasNecessaryDirections); - RenderBox? child = firstChild; - RenderBox overflow = lastChild!; - if (child == null || child == overflow) { - size = constraints.smallest; - return; - } - final BoxConstraints childConstraints; - double mainAxisLimit = 0.0; - bool flipMainAxis = false; - bool flipCrossAxis = false; - switch (direction) { - case Axis.horizontal: - childConstraints = BoxConstraints(maxWidth: constraints.maxWidth); - mainAxisLimit = constraints.maxWidth; - if (textDirection == TextDirection.rtl) flipMainAxis = true; - if (verticalDirection == VerticalDirection.up) flipCrossAxis = true; - break; - case Axis.vertical: - childConstraints = BoxConstraints(maxHeight: constraints.maxHeight); - mainAxisLimit = constraints.maxHeight; - if (verticalDirection == VerticalDirection.up) flipMainAxis = true; - if (textDirection == TextDirection.rtl) flipCrossAxis = true; - break; - } - overflow.layout(childConstraints, parentUsesSize: true); - final double overflowMainAxisExtent = _getMainAxisExtent(overflow.size); - //final double overflowCrossAxisExtent = _getCrossAxisExtent(overflow.size); - int numberOfRuns = 1; - final runsMax = _maxRuns ?? 1000; - 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; - int childCount = 0; - while (child != null) { - RenderBox? nextChild = childAfter(child); - if (nextChild == null) { - // the last child is the overflow widget, abort: - break; - } - child.layout(childConstraints, parentUsesSize: true); - final double childMainAxisExtent = _getMainAxisExtent(child.size); - final double childCrossAxisExtent = _getCrossAxisExtent(child.size); - // is a new run required this this child? - if (childCount > 0 && - // for runs below the maximum number just check if the child fits: - ((numberOfRuns < runsMax && - runMainAxisExtent + childMainAxisExtent + spacing > - mainAxisLimit) || - // for the last run check if the child AND the overflow fits: - (numberOfRuns == runsMax && - runMainAxisExtent + - childMainAxisExtent + - spacing + - overflowMainAxisExtent > - mainAxisLimit))) { - // create a new run: - crossAxisExtent += runCrossAxisExtent; - if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing; - // add overflow to run: - if (numberOfRuns == runsMax) { - runMainAxisExtent += spacing + overflowMainAxisExtent; - runMetrics.add(_RunMetrics( - runMainAxisExtent, runCrossAxisExtent, childCount + 1)); - final ExpansionWrapParentData overflowParentData = - overflow.parentData! as ExpansionWrapParentData; - overflowParentData._runIndex = runMetrics.length; - } else { - runMetrics.add( - _RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount)); - } - mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); - runMainAxisExtent = 0.0; - runCrossAxisExtent = 0.0; - childCount = 0; - // if (numberOfRuns == runsMax) { - // break; - // } - numberOfRuns++; - } - - // add the child to the current run: - runMainAxisExtent += childMainAxisExtent; - if (childCount > 0) runMainAxisExtent += spacing; - runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent); - childCount += 1; - final ExpansionWrapParentData childParentData = - child.parentData! as ExpansionWrapParentData; - childParentData._runIndex = runMetrics.length; - child = childParentData.nextSibling; - } - if (childCount > 0) { - mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent); - crossAxisExtent += runCrossAxisExtent; - if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing; - runMetrics - .add(_RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount)); - } - - final int runCount = runMetrics.length; - assert(runCount > 0); - - double containerMainAxisExtent = 0.0; - double containerCrossAxisExtent = 0.0; - - switch (direction) { - case Axis.horizontal: - size = constraints.constrain(Size(mainAxisExtent, crossAxisExtent)); - containerMainAxisExtent = size.width; - containerCrossAxisExtent = size.height; - break; - case Axis.vertical: - size = constraints.constrain(Size(crossAxisExtent, mainAxisExtent)); - containerMainAxisExtent = size.height; - containerCrossAxisExtent = size.width; - break; - } - - final double crossAxisFreeSpace = - math.max(0.0, containerCrossAxisExtent - crossAxisExtent); - double runLeadingSpace = 0.0; - double runBetweenSpace = 0.0; - switch (runAlignment) { - case WrapAlignment.start: - break; - case WrapAlignment.end: - runLeadingSpace = crossAxisFreeSpace; - break; - case WrapAlignment.center: - runLeadingSpace = crossAxisFreeSpace / 2.0; - break; - case WrapAlignment.spaceBetween: - runBetweenSpace = - runCount > 1 ? crossAxisFreeSpace / (runCount - 1) : 0.0; - break; - case WrapAlignment.spaceAround: - runBetweenSpace = crossAxisFreeSpace / runCount; - runLeadingSpace = runBetweenSpace / 2.0; - break; - case WrapAlignment.spaceEvenly: - runBetweenSpace = crossAxisFreeSpace / (runCount + 1); - runLeadingSpace = runBetweenSpace; - break; - } - - runBetweenSpace += runSpacing; - double crossAxisOffset = flipCrossAxis - ? containerCrossAxisExtent - runLeadingSpace - : runLeadingSpace; - - child = firstChild; - for (int i = 0; i < runCount; ++i) { - final _RunMetrics metrics = runMetrics[i]; - final double runMainAxisExtent = metrics.mainAxisExtent; - final double runCrossAxisExtent = metrics.crossAxisExtent; - final int childCount = metrics.childCount; - - final double mainAxisFreeSpace = - math.max(0.0, containerMainAxisExtent - runMainAxisExtent); - double childLeadingSpace = 0.0; - double childBetweenSpace = 0.0; - - switch (alignment) { - case WrapAlignment.start: - break; - case WrapAlignment.end: - childLeadingSpace = mainAxisFreeSpace; - break; - case WrapAlignment.center: - childLeadingSpace = mainAxisFreeSpace / 2.0; - break; - case WrapAlignment.spaceBetween: - childBetweenSpace = - childCount > 1 ? mainAxisFreeSpace / (childCount - 1) : 0.0; - break; - case WrapAlignment.spaceAround: - childBetweenSpace = mainAxisFreeSpace / childCount; - childLeadingSpace = childBetweenSpace / 2.0; - break; - case WrapAlignment.spaceEvenly: - childBetweenSpace = mainAxisFreeSpace / (childCount + 1); - childLeadingSpace = childBetweenSpace; - break; - } - - childBetweenSpace += spacing; - double childMainPosition = flipMainAxis - ? containerMainAxisExtent - childLeadingSpace - : childLeadingSpace; - - if (flipCrossAxis) crossAxisOffset -= runCrossAxisExtent; - - while (child != null) { - final ExpansionWrapParentData childParentData = - child.parentData! as ExpansionWrapParentData; - if (childParentData._runIndex != i) break; - final double childMainAxisExtent = _getMainAxisExtent(child.size); - final double childCrossAxisExtent = _getCrossAxisExtent(child.size); - final double childCrossAxisOffset = _getChildCrossAxisOffset( - flipCrossAxis, runCrossAxisExtent, childCrossAxisExtent); - if (flipMainAxis) childMainPosition -= childMainAxisExtent; - childParentData.offset = _getOffset( - childMainPosition, crossAxisOffset + childCrossAxisOffset); - if (flipMainAxis) { - childMainPosition -= childBetweenSpace; - } else { - childMainPosition += childMainAxisExtent + childBetweenSpace; - } - child = childParentData.nextSibling; - } - - if (flipCrossAxis) { - crossAxisOffset -= runBetweenSpace; - } else { - crossAxisOffset += runCrossAxisExtent + runBetweenSpace; - } - } - } - - @override - bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); - } - - @override - void paint(PaintingContext context, Offset offset) { - // TODO(ianh): move the debug flex overflow paint logic somewhere common so - // it can be reused here - // if (_hasVisualOverflow && clipBehavior != Clip.none) { - // _clipRectLayer.layer = context.pushClipRect( - // needsCompositing, - // offset, - // Offset.zero & size, - // defaultPaint, - // clipBehavior: clipBehavior, - // oldLayer: _clipRectLayer.layer, - // ); - // } else { - // _clipRectLayer.layer = null; - //defaultPaint(context, offset); - // } - final runMax = maxRuns ?? 1000; - var child = firstChild; - while (child != null) { - final ExpansionWrapParentData childParentData = - child.parentData! as ExpansionWrapParentData; - final runIndex = childParentData._runIndex; - if (runIndex < runMax && runIndex >= 0) { - context.paintChild(child, childParentData.offset + offset); - } - child = childParentData.nextSibling; - } - } - - // final LayerHandle _clipRectLayer = - // LayerHandle(); - - // @override - // void dispose() { - // _clipRectLayer.layer = null; - // super.dispose(); - // } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(EnumProperty('direction', direction)); - properties.add(EnumProperty('alignment', alignment)); - properties.add(DoubleProperty('spacing', spacing)); - properties.add(EnumProperty('runAlignment', runAlignment)); - properties.add(DoubleProperty('runSpacing', runSpacing)); - properties.add(DoubleProperty('crossAxisAlignment', runSpacing)); - properties.add(EnumProperty('textDirection', textDirection, - defaultValue: null)); - properties.add(EnumProperty( - 'verticalDirection', verticalDirection, - defaultValue: VerticalDirection.down)); - } -} - -class LayerHandle { - ClipRectLayer? layer; -} diff --git a/lib/widgets/expansion_wrap.dart b/lib/widgets/expansion_wrap.dart index 6fd4481..ae58b44 100644 --- a/lib/widgets/expansion_wrap.dart +++ b/lib/widgets/expansion_wrap.dart @@ -1,11 +1,24 @@ +// ignore_for_file: avoid_setters_without_getters + import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; -enum ExpansionWrapIndicatorPosition { inline, border } +/// The position of the indicator. +enum ExpansionWrapIndicatorPosition { + /// The indicator is placed at the end of the last visible line. + inline, + + /// The indicator is placed at the end of the last visible line, but + /// aligned to the right border of the widget. + border, +} +/// A widget that displays its children in a wrap layout and adds an indicator +/// at the end if the children do not fit in the available space. class ExpansionWrap extends RenderObjectWidget { + /// Creates a new [ExpansionWrap] widget. const ExpansionWrap({ - Key? key, + super.key, required this.children, required this.expandIndicator, required this.maxRuns, @@ -14,52 +27,64 @@ class ExpansionWrap extends RenderObjectWidget { this.spacing = 0.0, this.runSpacing = 0.0, this.indicatorPosition = ExpansionWrapIndicatorPosition.border, - }) : super(key: key); + }); + /// The children to display. final List children; + + /// The widget to display when the children are compressed. final Widget expandIndicator; + + /// The widget to display when the children are expanded. final Widget compressIndicator; + + /// The maximum number of lines to display. final int? maxRuns; + + /// The spacing between the children. final double spacing; + + /// The spacing between the lines. final double runSpacing; + + /// Whether the children are expanded. final bool isExpanded; + + /// The position of the indicator. final ExpansionWrapIndicatorPosition indicatorPosition; @override - RenderObjectElement createElement() { - // print('widget.createElement'); - return ExpansionWrapElement(this); - } + RenderObjectElement createElement() => _ExpansionWrapElement(this); @override - RenderObject createRenderObject(BuildContext context) { - // print('widget.createRenderObject'); - return RenderExpansionWrap( - maxRuns: maxRuns, - spacing: spacing, - runSpacing: runSpacing, - isExpanded: isExpanded, - indicatorPosition: indicatorPosition, - ); - } + RenderObject createRenderObject(BuildContext context) => RenderExpansionWrap( + maxRuns: maxRuns, + spacing: spacing, + runSpacing: runSpacing, + isExpanded: isExpanded, + indicatorPosition: indicatorPosition, + ); @override void updateRenderObject( - BuildContext context, covariant RenderExpansionWrap renderObject) { + BuildContext context, + covariant RenderExpansionWrap renderObject, + ) { // print('widget.updateRenderObject'); super.updateRenderObject(context, renderObject); - renderObject.maxRuns = maxRuns; - renderObject.spacing = spacing; - renderObject.runSpacing = runSpacing; - renderObject.isExpanded = isExpanded; - renderObject.indicatorPosition = indicatorPosition; + renderObject + ..maxRuns = maxRuns + ..spacing = spacing + ..runSpacing = runSpacing + ..isExpanded = isExpanded + ..indicatorPosition = indicatorPosition; } } -class ExpansionWrapElement extends RenderObjectElement { +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; @@ -95,6 +120,7 @@ class ExpansionWrapElement extends RenderObjectElement { return children[slot]; } } + return null; } @@ -175,7 +201,7 @@ class ExpansionWrapElement extends RenderObjectElement { for (var i = 0; i < widgets.length; i++) { _updateChild(widgets[i], i); } - //TODO remove other children widgets? + // TODO(RV): remove other children widgets? } void _updateRenderObject(RenderBox? child, int? slot) { @@ -218,7 +244,26 @@ 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 { + /// Creates a new [RenderExpansionWrap] widget. RenderExpansionWrap({ required int? maxRuns, required double spacing, @@ -284,18 +329,19 @@ class RenderExpansionWrap extends RenderBox { } List? _wrapChildren; + + /// Adds a child to the end of the children list. void addWrapChild(RenderBox child) { adoptChild(child); _wrapChildren ??= []; - _wrapChildren!.add(child); + _wrapChildren?.add(child); } + /// Removes all children from the list. void clearWrapChildren() { final children = _wrapChildren; if (children != null) { - for (final child in children) { - dropChild(child); - } + children.forEach(dropChild); _wrapChildren = null; } } @@ -334,7 +380,7 @@ class RenderExpansionWrap extends RenderBox { @override void detach() { - // print('dettach'); + // print('detach'); super.detach(); for (final RenderBox child in _allChildren) { child.detach(); @@ -367,6 +413,7 @@ class RenderExpansionWrap extends RenderBox { addDiagnostic(child, 'child $i'); } } + return value; } @@ -382,6 +429,7 @@ class RenderExpansionWrap extends RenderBox { min = minIntrinsic; } } + return min; } @@ -396,6 +444,7 @@ class RenderExpansionWrap extends RenderBox { max += child.getMaxIntrinsicWidth(height); addSpacing = true; } + return max; } @@ -408,25 +457,31 @@ class RenderExpansionWrap extends RenderBox { min = minIntrinsic; } } + return min; } @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; - return parentData.offset.dy + first.getDistanceToActualBaseline(baseline)!; + final first = _wrapChildren?.first; + final parentData = first?.parentData; + if (first != null && parentData is BoxParentData) { + return parentData.offset.dy + + (first.getDistanceToActualBaseline(baseline) ?? 0); + } + + return 0; } static Size _layoutBox(RenderBox? box, BoxConstraints constraints) { if (box == null) return Size.zero; box.layout(constraints, parentUsesSize: true); + return box.size; } @@ -439,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; @@ -451,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; @@ -464,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; @@ -481,8 +532,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.toWrapParentData() + .._isVisible = currentRun <= maxRuns; if (currentRunNumberOfChildren > 0 && ((currentRunWidth + childSize.width > availableWidth) || (currentRun == maxRuns && @@ -509,14 +560,15 @@ class RenderExpansionWrap extends RenderBox { if (indicator != null) { // this is the last visible run, add indicator: final indicatorParentData = - indicator.parentData as _WrapParentData; - indicatorParentData._isVisible = true; + indicator.parentData.toWrapParentData().._isVisible = true; final dx = _indicatorPosition == ExpansionWrapIndicatorPosition.border ? availableWidth - indicatorWith : currentRunWidth + spacing; - indicatorParentData.offset = Offset(dx, - currentRunY + (currentRunHeight - indicatorSize.height) / 2); + indicatorParentData.offset = Offset( + dx, + currentRunY + (currentRunHeight - indicatorSize.height) / 2, + ); } crossAxisMaxInCompressedState = currentRunY + currentRunHeight + runSpacing; @@ -540,33 +592,32 @@ class RenderExpansionWrap extends RenderBox { } if (expanded && currentRun >= originalMaxRuns && indicator != null) { // add compress indicator at the end: - final indicatorParentData = indicator.parentData as _WrapParentData; - indicatorParentData._isVisible = true; + final indicatorParentData = indicator.parentData.toWrapParentData() + .._isVisible = true; final dx = _indicatorPosition == ExpansionWrapIndicatorPosition.border ? availableWidth - indicatorWith : currentRunWidth + spacing; indicatorParentData.offset = Offset( - dx, currentRunY + (currentRunHeight - indicatorSize.height) / 2); + dx, + currentRunY + (currentRunHeight - indicatorSize.height) / 2, + ); } if (!expanded && currentRun <= originalMaxRuns && indicator != null) { - final indicatorParentData = indicator.parentData as _WrapParentData; - indicatorParentData._isVisible = false; - } - if (crossAxisMaxInCompressedState != null) { - size = constraints - .constrain(Size(maxRunWidth, crossAxisMaxInCompressedState)); - } else { - size = constraints - .constrain(Size(maxRunWidth, currentRunY + currentRunHeight)); + indicator.parentData.isVisible = false; } + size = crossAxisMaxInCompressedState != null + ? constraints + .constrain(Size(maxRunWidth, crossAxisMaxInCompressedState)) + : constraints + .constrain(Size(maxRunWidth, currentRunY + currentRunHeight)); } @override void paint(PaintingContext context, Offset offset) { void doPaint(RenderBox? child) { if (child != null) { - final parentData = child.parentData as _WrapParentData; - if (parentData._isVisible) { + final parentData = child.parentData; + if (parentData is _WrapParentData && parentData._isVisible) { context.paintChild(child, parentData.offset + offset); } } @@ -586,18 +637,21 @@ 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 bool isHit = parentData._isVisible && + final parentData = child.parentData; + final bool isHit = parentData is _WrapParentData && + parentData._isVisible && result.addWithPaintOffset( offset: parentData.offset, position: position, hitTest: (BoxHitTestResult result, Offset transformed) { assert(transformed == position - parentData.offset); + return child.hitTest(result, position: transformed); }, ); if (isHit) return true; } + return false; } } diff --git a/lib/widgets/ical_composer.dart b/lib/widgets/ical_composer.dart index 12d23be..816db32 100644 --- a/lib/widgets/ical_composer.dart +++ b/lib/widgets/ical_composer.dart @@ -1,31 +1,37 @@ 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 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../l10n/app_localizations.g.dart'; -import '../locator.dart'; +import '../account/provider.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../logger.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); +/// A widget to compose an iCalendar appointment. +class IcalComposer extends StatefulHookConsumerWidget { + /// Creates a new [IcalComposer]. + const IcalComposer({super.key, required this.appointment}); + + /// The appointment to edit. final VCalendar appointment; @override - State createState() => _IcalComposerState(); + ConsumerState createState() => _IcalComposerState(); - static Future createOrEditAppointment(BuildContext context, - {VCalendar? appointment}) async { + /// Creates a new appointment or edits an existing one. + 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; - } - if (account is! RealAccount) { + // final iconService = IconService.instance; + final account = ref.read(currentRealAccountProvider); + if (account == null) { + logger.e('Unable to determine current real account'); + return null; } final now = DateTime.now(); @@ -37,21 +43,22 @@ class IcalComposer extends StatefulWidget { end: end, organizerEmail: account.email, ); - final result = await ModelBottomSheetHelper.showModalBottomSheet( + final result = await ModelBottomSheetHelper.showModalBottomSheet( context, editAppointment.summary ?? localizations.composeAppointmentTitle, IcalComposer(appointment: editAppointment), ); - if (result) { + if (result ?? false) { _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(); @@ -88,27 +95,27 @@ class _IcalComposerState extends State { } void apply() { - final ev = _event; - ev.summary = _summaryController.text; - ev.description = _descriptionController.text.isNotEmpty - ? _descriptionController.text - : null; - ev.location = - _locationController.text.isNotEmpty ? _locationController.text : null; + _event + ..summary = _summaryController.text + ..description = _descriptionController.text.isNotEmpty + ? _descriptionController.text + : null + ..location = + _locationController.text.isNotEmpty ? _locationController.text : null; } @override Widget build(BuildContext context) { final localizations = context.text; - // final i18nService = locator(); final end = _event.end; - final start = _event.start!; - final isAllday = _event.isAllDayEvent ?? false; + final start = _event.start ?? DateTime.now(); + final isAllDay = _event.isAllDayEvent ?? false; final recurrenceRule = _event.recurrenceRule; 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,15 +143,17 @@ class _IcalComposerState extends State { cupertinoAlignLabelOnTop: true, ), Padding( - padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0), - child: Text(localizations.icalendarLabelStart, - style: theme.textTheme.bodySmall), + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), + child: Text( + localizations.icalendarLabelStart, + style: theme.textTheme.bodySmall, + ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: DateTimePicker( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: _DateTimePicker( dateTime: start, - onlyDate: isAllday, + onlyDate: isAllDay, onChanged: (dateTime) { if (end != null) { final diff = end.difference(start); @@ -156,15 +165,17 @@ class _IcalComposerState extends State { }, ), ), - if (!isAllday) ...[ + if (!isAllDay) ...[ Padding( - padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 0.0), - child: Text(localizations.icalendarLabelEnd, - style: theme.textTheme.bodySmall), + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), + child: Text( + localizations.icalendarLabelEnd, + style: theme.textTheme.bodySmall, + ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: DateTimePicker( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: _DateTimePicker( dateTime: end, onChanged: (dateTime) { setState(() { @@ -175,19 +186,21 @@ class _IcalComposerState extends State { ), ], PlatformCheckboxListTile( - value: isAllday, + value: isAllDay, title: Text(localizations.composeAppointmentLabelAllDayEvent), onChanged: (value) { - if (value == null || value == false) { - _event.duration = null; - _event.start = _previousStart; - _event.end = _previousEnd; - } else { + if (value ?? false) { _previousStart = start; _previousEnd = end; - _event.end = null; - _event.start = DateTime(start.year, start.month, start.day); - _event.duration = IsoDuration(days: 1); + _event + ..end = null + ..start = DateTime(start.year, start.month, start.day) + ..duration = IsoDuration(days: 1); + } else { + _event + ..duration = null + ..start = _previousStart + ..end = _previousEnd; } setState(() { _event.isAllDayEvent = value; @@ -210,8 +223,11 @@ class _IcalComposerState extends State { style: theme.textTheme.bodySmall, ), onTap: () async { - final result = await RecurrenceComposer.createOrEditRecurrence( - context, recurrenceRule, start); + final result = await _RecurrenceComposer.createOrEditRecurrence( + context, + recurrenceRule, + start, + ); setState(() { _event.recurrenceRule = result; }); @@ -293,30 +309,29 @@ extension _ExtensionRecurrenceFrequency on RecurrenceFrequency { } } -class DateTimePicker extends StatelessWidget { - final DateTime? dateTime; - final void Function(DateTime newDateTime) onChanged; - final bool onlyDate; - const DateTimePicker({ - Key? key, +class _DateTimePicker extends StatelessWidget { + const _DateTimePicker({ 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) { - final i18nService = locator(); final localizations = context.text; final dt = dateTime; + return Row( children: [ // set date button: PlatformTextButton( - child: PlatformText( + child: Text( dt == null ? localizations.composeAppointmentLabelDay - : i18nService.formatDate(dt.toLocal(), useLongFormat: true), + : context.formatDate(dt.toLocal(), useLongFormat: true), ), onPressed: () async { FocusScope.of(context).unfocus(); @@ -340,11 +355,12 @@ class DateTimePicker extends StatelessWidget { if (!onlyDate) // set time button: PlatformTextButton( - child: PlatformText( + child: Text( dt == null ? localizations.composeAppointmentLabelTime - : i18nService.formatTimeOfDay( - TimeOfDay.fromDateTime(dt.toLocal()), context), + : context.formatTimeOfDay( + TimeOfDay.fromDateTime(dt.toLocal()), + ), ), onPressed: () async { FocusScope.of(context).unfocus(); @@ -367,39 +383,41 @@ class DateTimePicker extends StatelessWidget { } } -class RecurrenceComposer extends StatefulWidget { +class _RecurrenceComposer extends StatefulWidget { + const _RecurrenceComposer({ + 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(); + State<_RecurrenceComposer> createState() => _RecurrenceComposerState(); - static Future createOrEditRecurrence(BuildContext context, - Recurrence? recurrenceRule, DateTime startDate) async { + static Future createOrEditRecurrence( + BuildContext context, + Recurrence? recurrenceRule, + DateTime startDate, + ) async { final localizations = context.text; - // final iconService = locator(); + // final iconService = IconService.instance; - final result = await ModelBottomSheetHelper.showModalBottomSheet( + final result = await ModelBottomSheetHelper.showModalBottomSheet( context, localizations.composeAppointmentLabelRepeat, - RecurrenceComposer( + _RecurrenceComposer( recurrenceRule: recurrenceRule, startDate: startDate, ), ); - if (result) { - return _RecurrenceComposerState._currentState._recurrenceRule; - } else { - return recurrenceRule; - } + return result ?? false + ? _RecurrenceComposerState._currentState._recurrenceRule + : recurrenceRule; } } -class _RecurrenceComposerState extends State { +class _RecurrenceComposerState extends State<_RecurrenceComposer> { static late _RecurrenceComposerState _currentState; Recurrence? _recurrenceRule; _RepeatFrequency _repeatFrequency = _RepeatFrequency.never; @@ -418,20 +436,22 @@ 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.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), + localizations.composeAppointmentRecurrenceFrequencyLabel, + ), ), PlatformDropdownButton<_RepeatFrequency>( items: _RepeatFrequency.values @@ -446,10 +466,11 @@ class _RecurrenceComposerState extends State { _repeatFrequency = _RepeatFrequency.never; _recurrenceRule = null; }); + return; } DateTime? until; - final duration = freq.recurrenceFrequency!.recommendedUntil; + final duration = freq.recurrenceFrequency?.recommendedUntil; if (duration == null) { _recommendationDate = null; } else { @@ -464,12 +485,14 @@ class _RecurrenceComposerState extends State { copyUntil: false, ) : Recurrence( - freq.recurrenceFrequency!, + freq.recurrenceFrequency ?? RecurrenceFrequency.daily, until: until, ); if (newRule.frequency == RecurrenceFrequency.monthly) { - final monthly = DayOfMonthSelector.updateMonthlyRecurrence( - newRule, widget.startDate); + final monthly = _DayOfMonthSelector.updateMonthlyRecurrence( + newRule, + widget.startDate, + ); if (monthly != null) { newRule = monthly; } @@ -487,9 +510,10 @@ class _RecurrenceComposerState extends State { Row( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text( - localizations.composeAppointmentRecurrenceIntervalLabel), + localizations.composeAppointmentRecurrenceIntervalLabel, + ), ), PlatformDropdownButton( items: List.generate( @@ -510,20 +534,18 @@ 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), ), - WeekDaySelector( + _WeekDaySelector( recurrence: rule, startDate: widget.startDate, onChanged: (rules) { Recurrence value; - if (rules == null) { - value = rule.copyWith(copyByRules: false); - } else { - value = rule.copyWith(byWeekDay: rules); - } + value = rules == null + ? rule.copyWith(copyByRules: false) + : rule.copyWith(byWeekDay: rules); setState(() { _recurrenceRule = value; }); @@ -531,11 +553,11 @@ 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), ), - DayOfMonthSelector( + _DayOfMonthSelector( recurrence: rule, startDate: widget.startDate, onChanged: (value) { @@ -547,18 +569,24 @@ 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( + context.formatIsoDuration( + rule.frequency.recommendedUntil ?? IsoDuration(), + ), + ) + : context.formatDate( + rule.until, + useLongFormat: true, + ), + ), onTap: () async { - final until = await UntilComposer.createOrEditUntil( + final until = await _UntilComposer.createOrEditUntil( context, widget.startDate, rule.until, @@ -574,7 +602,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, @@ -587,37 +615,36 @@ class _RecurrenceComposerState extends State { } } -class WeekDaySelector extends StatefulWidget { - final Recurrence recurrence; - final DateTime startDate; - final void Function(List? rules) onChanged; - const WeekDaySelector({ - Key? key, +class _WeekDaySelector extends StatefulWidget { + const _WeekDaySelector({ 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(); + State<_WeekDaySelector> createState() => _WeekDaySelectorState(); } -class _WeekDaySelectorState extends State { +class _WeekDaySelectorState extends State<_WeekDaySelector> { late List _weekdays; final _selectedDays = [false, false, false, false, false, false, false]; @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) { - int firstDayOfWeek = i18nService.firstDayOfWeek; + final int firstDayOfWeek = context.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,40 +686,39 @@ class _WeekDaySelectorState extends State { } @override - Widget build(BuildContext context) { - return FittedBox( - child: PlatformToggleButtons( - isSelected: _selectedDays, - onPressed: _toggle, - children: _weekdays - .map((day) => Padding( - padding: const EdgeInsets.all(8.0), - child: Text(day.name), - )) - .toList(), - ), - ); - } + 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(), + ), + ); } enum _DayOfMonthOption { dayOfMonth, dayInNumberedWeek } -class DayOfMonthSelector extends StatefulWidget { +class _DayOfMonthSelector extends StatefulWidget { + const _DayOfMonthSelector({ + required this.recurrence, + required this.startDate, + required this.onChanged, + }); final Recurrence recurrence; final DateTime startDate; final void Function(Recurrence recurrence) onChanged; - const DayOfMonthSelector( - {Key? key, - required this.recurrence, - required this.startDate, - required this.onChanged}) - : super(key: key); @override - State createState() => _DayOfMonthSelectorState(); + State<_DayOfMonthSelector> createState() => _DayOfMonthSelectorState(); static Recurrence? updateMonthlyRecurrence( - Recurrence recurrence, DateTime startDate) { + Recurrence recurrence, + DateTime startDate, + ) { if (recurrence.hasByMonthDay || recurrence.hasByWeekDay) { return null; } @@ -705,11 +731,12 @@ class DayOfMonthSelector extends StatefulWidget { week = -((daysInMonth - day) / 7).ceil(); } final rule = ByDayRule(weekday, week: week); + return recurrence.copyWith(byWeekDay: [rule], copyByRules: false); } } -class _DayOfMonthSelectorState extends State { +class _DayOfMonthSelectorState extends State<_DayOfMonthSelector> { late _DayOfMonthOption _option; ByDayRule? _byDayRule; WeekDay? _currentWeekday; @@ -718,20 +745,22 @@ class _DayOfMonthSelectorState extends State { @override void initState() { super.initState(); - _weekdays = locator().formatWeekDays(); + _weekdays = context.formatWeekDays(); if (widget.recurrence.hasByMonthDay) { _option = _DayOfMonthOption.dayOfMonth; } else { var recurrence = widget.recurrence; if (!widget.recurrence.hasByWeekDay) { - recurrence = DayOfMonthSelector.updateMonthlyRecurrence( - recurrence, widget.startDate) ?? + recurrence = _DayOfMonthSelector.updateMonthlyRecurrence( + recurrence, + widget.startDate, + ) ?? recurrence; } _option = _DayOfMonthOption.dayInNumberedWeek; - final rule = recurrence.byWeekDay!.first; + final rule = recurrence.byWeekDay?.first; _byDayRule = rule; - _currentWeekday = _weekdays.firstWhere((wd) => wd.day == rule.weekday); + _currentWeekday = _weekdays.firstWhere((wd) => wd.day == rule?.weekday); } } @@ -739,6 +768,7 @@ class _DayOfMonthSelectorState extends State { Widget build(BuildContext context) { final localizations = context.text; final rule = _byDayRule; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -746,14 +776,24 @@ class _DayOfMonthSelectorState extends State { groupValue: _option, value: _DayOfMonthOption.dayOfMonth, title: Text( - localizations.composeAppointmentRecurrenceMonthlyOnDayOfMonth( - widget.startDate.day)), + localizations.composeAppointmentRecurrenceMonthlyOnDayOfMonth( + widget.startDate.day, + ), + ), onChanged: (value) { + if (value == null) { + return; + } + setState(() { - _option = value!; + _option = value; }); - widget.onChanged(widget.recurrence.copyWith( - byMonthDay: [widget.startDate.day], copyByRules: false)); + widget.onChanged( + widget.recurrence.copyWith( + byMonthDay: [widget.startDate.day], + copyByRules: false, + ), + ); }, ), PlatformRadioListTile<_DayOfMonthOption>( @@ -762,24 +802,31 @@ class _DayOfMonthSelectorState extends State { title: Text(localizations.composeAppointmentRecurrenceMonthlyOnWeekDay), onChanged: (value) { + if (value == null) { + return; + } if (_byDayRule == null) { - final recurrence = DayOfMonthSelector.updateMonthlyRecurrence( - widget.recurrence.copyWith(copyByRules: false), - widget.startDate)!; - final rule = recurrence.byWeekDay!.first; + final recurrence = _DayOfMonthSelector.updateMonthlyRecurrence( + widget.recurrence.copyWith(copyByRules: false), + widget.startDate, + ) ?? + widget.recurrence.copyWith( + byWeekDay: [ByDayRule(widget.startDate.weekday)], + ); + final rule = recurrence.byWeekDay?.first; _byDayRule = rule; _currentWeekday = - _weekdays.firstWhere((wd) => wd.day == rule.weekday); + _weekdays.firstWhere((wd) => wd.day == rule?.weekday); widget.onChanged(recurrence); } setState(() { - _option = value!; + _option = value; }); }, ), 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( @@ -792,12 +839,14 @@ class _DayOfMonthSelectorState extends State { DropdownMenuItem( value: 2, child: Text( - localizations.composeAppointmentRecurrenceSecond), + localizations.composeAppointmentRecurrenceSecond, + ), ), DropdownMenuItem( value: 3, - child: - Text(localizations.composeAppointmentRecurrenceThird), + child: Text( + localizations.composeAppointmentRecurrenceThird, + ), ), DropdownMenuItem( value: -1, @@ -807,7 +856,8 @@ class _DayOfMonthSelectorState extends State { DropdownMenuItem( value: -2, child: Text( - localizations.composeAppointmentRecurrenceSecondLast), + localizations.composeAppointmentRecurrenceSecondLast, + ), ), ], value: rule.week, @@ -820,7 +870,7 @@ class _DayOfMonthSelectorState extends State { }, ), const Padding( - padding: EdgeInsets.all(8.0), + padding: EdgeInsets.all(8), ), PlatformDropdownButton( items: _weekdays @@ -831,7 +881,10 @@ class _DayOfMonthSelectorState extends State { .toList(), value: _currentWeekday, onChanged: (value) { - final newRule = ByDayRule(value!.day, week: rule.week); + if (value == null) { + return; + } + final newRule = ByDayRule(value.day, week: rule.week); _byDayRule = newRule; final recurrence = widget.recurrence.copyWith(byWeekDay: [newRule]); @@ -847,36 +900,43 @@ class _DayOfMonthSelectorState extends State { } } -class UntilComposer extends StatefulWidget { +class _UntilComposer extends StatefulWidget { + const _UntilComposer({ + 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(); + State<_UntilComposer> createState() => _UntilComposerState(); - static Future createOrEditUntil(BuildContext context, - DateTime start, DateTime? until, IsoDuration? recommendation) async { + static Future createOrEditUntil( + BuildContext context, + DateTime start, + DateTime? until, + IsoDuration? recommendation, + ) async { final localizations = context.text; - // final iconService = locator(); - final result = await ModelBottomSheetHelper.showModalBottomSheet( + // final iconService = IconService.instance; + final result = await ModelBottomSheetHelper.showModalBottomSheet( context, localizations.composeAppointmentRecurrenceUntilLabel, - UntilComposer(start: start, until: until, recommendation: recommendation), + _UntilComposer( + start: start, + until: until, + recommendation: recommendation, + ), ); - if (result) { - return _UntilComposerState._currentState._until; - } else { - return until; - } + return (result ?? false) ? _UntilComposerState._currentState._until : until; } } -class _UntilComposerState extends State { +class _UntilComposerState extends State<_UntilComposer> { static late _UntilComposerState _currentState; late _UntilOption _option; DateTime? _recommendationDate; @@ -905,8 +965,9 @@ class _UntilComposerState extends State { // final i18nService = locator(); 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: [ @@ -918,17 +979,24 @@ 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.0, 16.0, 8.0, 0.0), - child: Text(localizations.composeAppointmentRecurrenceUntilLabel, - style: theme.textTheme.bodySmall), + padding: const EdgeInsets.fromLTRB(8, 16, 8, 0), + child: Text( + localizations.composeAppointmentRecurrenceUntilLabel, + style: theme.textTheme.bodySmall, + ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: DateTimePicker( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: _DateTimePicker( dateTime: _until, onlyDate: true, onChanged: (dateTime) { @@ -967,14 +1035,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 b0812e4..1a0afbb 100644 --- a/lib/widgets/ical_interactive_media.dart +++ b/lib/widgets/ical_interactive_media.dart @@ -3,29 +3,31 @@ 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 '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'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; -import '../l10n/app_localizations.g.dart'; +import '../account/model.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../models/message.dart'; +import '../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({ + 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,63 +75,74 @@ 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), ); } + return const Center(child: PlatformProgressIndicator()); } 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; - var end = event.end; - var start = event.start; + 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.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), + child: Text(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), + child: Text(localizations.actionDecline), onPressed: () => _changeParticipantStatus( - ParticipantStatus.declined, localizations), + ParticipantStatus.declined, + localizations, + ), ), ], ) 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) ?? ''), + _participantStatus?.localization(localizations) ?? '', + ), ), PlatformTextButton( - child: PlatformText( - localizations.icalendarActionChangeParticipantStatus), + child: Text( + localizations.icalendarActionChangeParticipantStatus, + ), onPressed: () => _queryParticipantStatus(localizations), ), ], @@ -137,131 +150,144 @@ class _IcalInteractiveMediaState extends State { Table( columnWidths: const { 0: IntrinsicColumnWidth(), - 1: FlexColumnWidth() + 1: FlexColumnWidth(), }, 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), - ) + text: + event.summary ?? localizations.icalendarNoSummaryInfo, + ), + ), ]), if (start != null) 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, - useLongFormat: true), + context.formatDateTime( + start.toLocal(), + alwaysUseAbsoluteFormat: true, + useLongFormat: true, + ), ), - ) + ), ], ), if (end != null) 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, - useLongFormat: true), + context.formatDateTime( + end.toLocal(), + alwaysUseAbsoluteFormat: true, + useLongFormat: true, + ), ), ), ], ) - else if (event.duration != null) + else if (duration != null) 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!)), - ) + context.formatIsoDuration(duration), + ), + ), ], ), if (recurrenceRule != null) 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), - child: Text(recurrenceRule.toHumanReadableText( - languageCode: localizations.localeName, - )), - ) + padding: const EdgeInsets.all(8), + child: Text( + recurrenceRule.toHumanReadableText( + languageCode: localizations.localeName, + ), + ), + ), ], ), - if (event.description != null) + if (description != null) 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!, + text: description, ), ), ], ), - if (event.location != null) + if (location != null) TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), - child: Text(localizations.icalendarLabelLocation), + padding: const EdgeInsets.all(8), + child: Text( + localizations.icalendarLabelLocation, + ), ), Padding( - padding: const EdgeInsets.all(8.0), - child: TextWithLinks(text: event.location!), - ) + padding: const EdgeInsets.all(8), + child: TextWithLinks( + text: location, + ), + ), ], ), - if (event.microsoftTeamsMeetingUrl != null) + if (microsoftTeamsMeetingUrl != null) 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!), - ) + text: microsoftTeamsMeetingUrl, + ), + ), ], ), if (attendees.isNotEmpty) TableRow( children: [ Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Text(localizations.icalendarLabelParticipants), ), Column( @@ -272,50 +298,52 @@ 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; - 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) Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: icon, ), - address != null - ? MailAddressChip(mailAddress: address) - : Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (name != null) - Text( - name, - style: textStyle, - ), - Padding( - padding: - const EdgeInsets.symmetric( - vertical: 4.0), - 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(), @@ -326,7 +354,7 @@ class _IcalInteractiveMediaState extends State { ), if (!isReply) PlatformElevatedButton( - child: PlatformText(localizations.icalendarExportAction), + child: Text(localizations.icalendarExportAction), onPressed: () => _exportToNativeCalendar(_calendar), ), ], @@ -340,6 +368,7 @@ class _IcalInteractiveMediaState extends State { if (kDebugMode) { print('Warning: no calendar to export.'); } + return; } try { @@ -352,61 +381,88 @@ 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 { - 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', ); - locator() - .showTextSnackBar(status.localization(localizations)); + ScaffoldMessengerService.instance + .showTextSnackBar(localizations, status.localization(localizations)); } catch (e, s) { if (kDebugMode) { print('Unable to send status update: $e $s'); } - LocalizedDialogHelper.showTextDialog(context, localizations.errorTitle, - localizations.icalendarParticipantStatusSentFailure(e.toString())); + if (context.mounted) { + 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, - 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: Text(localizations.actionAccept), + onPressed: () => context.pop(ParticipantStatus.accepted), + ), + PlatformTextButton( + child: Text(localizations.icalendarAcceptTentatively), + onPressed: () => context.pop(ParticipantStatus.tentative), + ), + PlatformTextButton( + child: Text(localizations.actionDecline), + onPressed: () => context.pop(ParticipantStatus.declined), + ), + PlatformTextButton( + child: Text(localizations.actionCancel), + onPressed: () => context.pop(), + ), + ], + ); if (status != null && status != _participantStatus) { - _changeParticipantStatus(status, localizations); + 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) { @@ -426,16 +482,20 @@ 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), ); } } 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 b64ca37..755b8b9 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( @@ -28,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/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..7af3076 100644 --- a/lib/widgets/legalese.dart +++ b/lib/widgets/legalese.dart @@ -1,13 +1,14 @@ -import 'package:enough_mail_app/l10n/extension.dart'; -import 'package:enough_mail_app/widgets/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 = '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) { @@ -24,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/mail_address_chip.dart b/lib/widgets/mail_address_chip.dart index 61aa7da..0818c50 100644 --- a/lib/widgets/mail_address_chip.dart +++ b/lib/widgets/mail_address_chip.dart @@ -1,36 +1,35 @@ +import 'dart:async'; + 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 '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 '../locator.dart'; +import '../localization/extension.dart'; +import '../models/compose_data.dart'; +import '../routes/routes.dart'; +import '../scaffold_messenger/service.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! + 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( @@ -60,27 +59,36 @@ class MailAddressChip extends StatelessWidget { case _AddressAction.none: break; case _AddressAction.copy: - Clipboard.setData(ClipboardData(text: mailAddress.email)); - locator() - .showTextSnackBar(localizations.feedbackResultInfoCopied); + await Clipboard.setData(ClipboardData(text: mailAddress.email)); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.feedbackResultInfoCopied, + ); break; case _AddressAction.compose: final messageBuilder = MessageBuilder()..to = [mailAddress]; final composeData = ComposeData(null, messageBuilder, ComposeAction.newMessage); - 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); - 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 0a30da0..e52a1b7 100644 --- a/lib/widgets/mailbox_selector.dart +++ b/lib/widgets/mailbox_selector.dart @@ -1,41 +1,57 @@ 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 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../locator.dart'; +import '../account/model.dart'; +import '../mail/provider.dart'; -class MailboxSelector extends StatelessWidget { - final Account account; - final bool showRoot; - final Mailbox? mailbox; - final void Function(Mailbox? mailbox) onChanged; +class MailboxSelector extends ConsumerWidget { 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) { - 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 c014eed..311d01c 100644 --- a/lib/widgets/mailbox_tree.dart +++ b/lib/widgets/mailbox_tree.dart @@ -1,59 +1,114 @@ 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 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../locator.dart'; +import '../account/model.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../mail/model.dart'; +import '../mail/provider.dart'; +import '../settings/model.dart'; +import '../settings/provider.dart'; +import '../settings/theme/icon_service.dart'; -class MailboxTree extends StatelessWidget { +/// Displays a tree of mailboxes +class MailboxTree extends ConsumerWidget { + /// Creates a new [MailboxTree] + const MailboxTree({ + super.key, + required this.account, + required this.onSelected, + this.isReselectPossible = false, + }); + + /// The associated account final Account account; + + /// Callback when a mailbox is selected final void Function(Mailbox mailbox) onSelected; - final Mailbox? current; - const MailboxTree( - {Key? key, required this.account, required this.onSelected, this.current}) - : super(key: key); + /// Set to true if the user should be able to reselect the current mailbox + final bool isReselectPossible; @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)); + final currentMailbox = ref.watch(currentMailboxProvider); + final settings = ref.watch(settingsProvider); + final localizations = context.text; + + 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( + localizations, + settings, + element, + 0, + currentMailbox, + ), + ], + ); + }, ); } - Widget buildMailboxElement(TreeElement element, final int level) { - final mailbox = element.value!; + Widget _buildMailboxElement( + AppLocalizations localizations, + Settings settings, + TreeElement element, + final int level, + Mailbox? current, + ) { + 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), + child: Text(mailbox.localizedName(localizations, settings)), ); - if (element.children == null) { - final isCurrent = (mailbox == current); - final iconData = locator().getForMailbox(mailbox); + final children = element.children; + if (children == null) { + final isCurrent = + mailbox == current || (current == null && mailbox.isInbox); + final iconData = IconService.instance.getForMailbox(mailbox); + return SelectablePlatformListTile( leading: Icon(iconData), title: title, - onTap: isCurrent ? null : () => onSelected(mailbox), + onTap: + isCurrent && !isReselectPossible ? null : () => onSelected(mailbox), 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( + localizations, + settings, + childElement, + level + 1, + current, + ), ], ), ); diff --git a/lib/widgets/menu_with_badge.dart b/lib/widgets/menu_with_badge.dart index b01fb57..e292bf6 100644 --- a/lib/widgets/menu_with_badge.dart +++ b/lib/widgets/menu_with_badge.dart @@ -1,47 +1,39 @@ -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 '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({ - Key? key, + super.key, this.badgeContent, this.iOSText, - }) : super(key: key); + }); final Widget? badgeContent; final String? iOSText; @override - Widget build(BuildContext context) { - return DensePlatformIconButton( - icon: badges.Badge( - badgeContent: badgeContent, - child: _buildIndicator(context), - ), - onPressed: () { - if (Platform.isIOS) { - // go back - locator().pop(); - } else { - Scaffold.of(context).openDrawer(); - } - }, - ); - } + Widget build(BuildContext context) => DensePlatformIconButton( + icon: badges.Badge( + badgeContent: badgeContent, + child: _buildIndicator(context), + ), + onPressed: () { + if (PlatformInfo.isCupertino) { + // go back + context.pop(); + } else { + Scaffold.of(context).openDrawer(); + } + }, + ); Widget _buildIndicator(BuildContext context) { - if (Platform.isIOS) { + if (PlatformInfo.isCupertino) { 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 4521897..3e0f6b1 100644 --- a/lib/widgets/message_actions.dart +++ b/lib/widgets/message_actions.dart @@ -1,26 +1,24 @@ +import 'package:collection/collection.dart'; 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 '../l10n/extension.dart'; -import '../locator.dart'; +import '../account/model.dart'; +import '../contact/provider.dart'; +import '../localization/extension.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/mail_service.dart'; -import '../services/navigation_service.dart'; -import '../services/notification_service.dart'; -import '../services/scaffold_messenger_service.dart'; +import '../notification/service.dart'; +import '../routes/routes.dart'; +import '../scaffold_messenger/service.dart'; import '../settings/model.dart'; import '../settings/provider.dart'; +import '../settings/theme/icon_service.dart'; import '../util/localized_dialog_helper.dart'; import '../util/validator.dart'; -import 'button_text.dart'; import 'icon_text.dart'; import 'mailbox_tree.dart'; import 'recipient_input_field.dart'; @@ -42,38 +40,42 @@ enum _OverflowMenuChoice { addNotification, } +/// Displays actions for a single message. class MessageActions extends HookConsumerWidget { + /// Creates a [MessageActions] widget. const MessageActions({super.key, required this.message}); + + /// The message to display actions for. final Message message; @override Widget build(BuildContext context, WidgetRef ref) { final localizations = context.text; final attachments = message.attachments; - final iconService = locator(); + final iconService = IconService.instance; 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,13 +87,13 @@ 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); + _redirectMessage(context, ref); break; case _OverflowMenuChoice.addNotification: _addNotification(); @@ -121,25 +123,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, @@ -179,8 +181,10 @@ class MessageActions extends HookConsumerWidget { child: IconText( icon: Icon(iconService.messageActionForwardAttachments), label: Text( - localizations.messageActionForwardAttachments( - attachments.length)), + localizations.messageActionForwardAttachments( + attachments.length, + ), + ), ), ), if (message.source.isTrash) @@ -217,7 +221,8 @@ class MessageActions extends HookConsumerWidget { value: _OverflowMenuChoice.flag, child: IconText( icon: Icon( - iconService.getMessageIsFlagged(message.isFlagged)), + iconService.getMessageIsFlagged(message.isFlagged), + ), label: Text( message.isFlagged ? localizations.messageStatusFlagged @@ -304,28 +309,49 @@ 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}) { - final account = message.mailClient.account; + void _reply(BuildContext context, WidgetRef ref, {all = false}) { + final account = message.account; + final mime = message.mimeMessage; + final recipientAddresses = mime.recipientAddresses; + bool matchesRecipients(RealAccount account) { + final aliases = [ + account.email, + ...account.aliases.map((alias) => alias.email), + ]; + for (final email in aliases) { + if (recipientAddresses.contains(email)) { + return true; + } + } + + return false; + } + + final realAccount = account is RealAccount + ? account + : account is UnifiedAccount + ? account.accounts.firstWhereOrNull(matchesRecipients) + : null; final builder = MessageBuilder.prepareReplyToMessage( - message.mimeMessage, - account.fromAddress, - aliases: account.aliases, - handlePlusAliases: account.supportsPlusAliases, + mime, + realAccount?.fromAddress ?? account.fromAddress, + aliases: realAccount?.aliases, + handlePlusAliases: realAccount?.supportsPlusAliases ?? false, replyAll: all, ); - _navigateToCompose(ref, message, builder, ComposeAction.answer); + _navigateToCompose(context, 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); + Future _redirectMessage(BuildContext context, WidgetRef ref) async { + final account = message.account; + if (account is RealAccount) { + account.contactManager ??= + await ref.read(contactsLoaderProvider(account: account).future); } if (!context.mounted) { @@ -343,11 +369,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, @@ -359,16 +388,16 @@ class MessageActions extends HookConsumerWidget { title: localizations.redirectTitle, actions: [ TextButton( - child: ButtonText(localizations.actionCancel), - onPressed: () => Navigator.of(context).pop(false), + child: Text(localizations.actionCancel), + onPressed: () => context.pop(false), ), TextButton( - child: ButtonText(localizations.messageActionRedirect), + child: Text(localizations.messageActionRedirect), onPressed: () { if (Validator.validateEmail(textEditingController.text)) { recipients.add(MailAddress(null, textEditingController.text)); } - Navigator.of(context).pop(true); + context.pop(true); }, ), ], @@ -378,19 +407,27 @@ 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); - locator() - .showTextSnackBar(localizations.resultRedirectedSuccess); + await message.source.getMimeSource(message)?.sendMessage( + mime, + recipients: recipients, + appendToSent: false, + ); + ScaffoldMessengerService.instance.showTextSnackBar( + localizations, + localizations.resultRedirectedSuccess, + ); } on MailException catch (e, s) { if (kDebugMode) { print('message could not get redirected: $e $s'); @@ -408,23 +445,26 @@ class MessageActions extends HookConsumerWidget { } } - Future _delete() async { - locator().pop(); + Future _delete(BuildContext context) async { + final localizations = context.text; + context.pop(); await message.source.deleteMessages( + localizations, [message], - locator().localizations.resultDeleted, + localizations.resultDeleted, ); } void _move(BuildContext context) { - final localizations = locator().localizations; + final localizations = context.text; LocalizedDialogHelper.showWidgetDialog( context, SingleChildScrollView( child: MailboxTree( account: message.account, - onSelected: _moveTo, - current: message.mailClient.selectedMailbox, + onSelected: (mailbox) => _moveTo(context, mailbox), + // TODO(RV): retrieve the current selected mailbox in a different way + // current: message.mailClient.selectedMailbox, ), ), title: localizations.moveTitle, @@ -432,49 +472,62 @@ 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( + localizations, message, mailbox, localizations.moveSuccess(mailbox.name), ); } - Future _moveJunk() async { + Future _moveJunk(BuildContext context) async { final source = message.source; if (source.isJunk) { - await source.markAsNotJunk(message); + await source.markAsNotJunk(context.text, message); } else { - locator().cancelNotificationForMailMessage(message); - await source.markAsJunk(message); + NotificationService.instance.cancelNotificationForMessage(message); + await source.markAsJunk(context.text, message); + } + if (context.mounted) { + context.pop(); } - locator().pop(); } - Future _moveToInbox() async { + Future _moveToInbox(BuildContext context) async { final source = message.source; - await source.moveMessageToFlag(message, MailboxFlag.inbox, - locator().localizations.resultMovedToInbox); - locator().pop(); + final localizations = context.text; + await source.moveMessageToFlag( + localizations, + message, + MailboxFlag.inbox, + localizations.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); + await source.moveToInbox(context.text, message); } else { - locator().cancelNotificationForMailMessage(message); - await source.archive(message); + NotificationService.instance.cancelNotificationForMessage(message); + await source.archive(context.text, message); + } + if (context.mounted) { + context.pop(); } - locator().pop(); } - void _forward(WidgetRef ref) { - final from = message.mailClient.account.fromAddress; + void _forward(BuildContext context, WidgetRef ref) { + final from = message.account.fromAddress; final builder = MessageBuilder.prepareForwardMessage( message.mimeMessage, from: from, @@ -483,6 +536,7 @@ class MessageActions extends HookConsumerWidget { ); final composeFuture = _addAttachments(message, builder); _navigateToCompose( + context, ref, message, builder, @@ -491,24 +545,25 @@ class MessageActions extends HookConsumerWidget { ); } - Future _forwardAsAttachment(WidgetRef ref) async { + Future _forwardAsAttachment(BuildContext context, 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); } _navigateToCompose( + context, ref, message, builder, @@ -517,16 +572,18 @@ class MessageActions extends HookConsumerWidget { ); } - Future _forwardAttachments(WidgetRef ref) async { + Future _forwardAttachments(BuildContext context, 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( + context, ref, message, builder, @@ -537,17 +594,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,15 +612,20 @@ 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); } } + return composeFuture; } @@ -580,6 +642,7 @@ class MessageActions extends HookConsumerWidget { } void _navigateToCompose( + BuildContext context, WidgetRef ref, Message? message, MessageBuilder builder, @@ -614,11 +677,10 @@ class MessageActions extends HookConsumerWidget { future: composeFuture, composeMode: mode, ); - locator() - .push(Routes.mailCompose, arguments: data, replace: true); + context.pushReplacementNamed(Routes.mailCompose, extra: data); } void _addNotification() { - locator().sendLocalNotificationForMailMessage(message); + NotificationService.instance.sendLocalNotificationForMailMessage(message); } } diff --git a/lib/widgets/message_overview_content.dart b/lib/widgets/message_overview_content.dart index 765c27d..fbad5ff 100644 --- a/lib/widgets/message_overview_content.dart +++ b/lib/widgets/message_overview_content.dart @@ -1,37 +1,42 @@ 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 'package:flutter/material.dart'; -import '../l10n/app_localizations.g.dart'; -import '../locator.dart'; +import '../localization/app_localizations.g.dart'; +import '../localization/extension.dart'; +import '../models/message.dart'; +import '../settings/theme/icon_service.dart'; +/// Displays the content of a message in the message overview. class MessageOverviewContent extends StatelessWidget { - final Message message; - final bool isSentMessage; - + /// Creates a new [MessageOverviewContent] widget. const MessageOverviewContent({ - Key? key, + super.key, required this.message, required this.isSentMessage, - }) : super(key: key); + }); + + /// The message to display. + final Message message; + + /// Whether the message is a sent message. + final bool isSentMessage; @override Widget build(BuildContext context) { final msg = message; final mime = msg.mimeMessage; final localizations = context.text; + final threadSequence = mime.threadSequence; final threadLength = - mime.threadSequence != null ? mime.threadSequence!.toList().length : 0; + threadSequence != null ? threadSequence.toList().length : 0; 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(horizontal: 0, vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 4), color: msg.isFlagged ? theme.colorScheme.secondary : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -42,14 +47,15 @@ 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, softWrap: false, style: TextStyle( - fontWeight: - msg.isSeen ? FontWeight.normal : FontWeight.bold), + fontWeight: + msg.isSeen ? FontWeight.normal : FontWeight.bold, + ), ), ), ), @@ -60,7 +66,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) @@ -70,8 +76,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.0), + IconService.buildNumericIcon( + context, + threadLength, + size: 12, + ), ], ), ), @@ -81,8 +90,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, + ), ), ], ), @@ -90,22 +100,19 @@ class MessageOverviewContent extends StatelessWidget { } String _getSenderOrRecipients( - MimeMessage mime, AppLocalizations localizations) { + MimeMessage mime, + AppLocalizations localizations, + ) { if (isSentMessage) { return mime.recipients .map((r) => r.hasPersonalName ? r.personalName : r.email) .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 - ? from!.email - : localizations.emailSenderUnknown; + ? from?.personalName ?? '' + : from?.email ?? localizations.emailSenderUnknown; } } diff --git a/lib/widgets/message_stack.dart b/lib/widgets/message_stack.dart index 2b7bf28..8a2935b 100644 --- a/lib/widgets/message_stack.dart +++ b/lib/widgets/message_stack.dart @@ -1,21 +1,26 @@ 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'; -enum DragAction { noted, later, delete, reply } +import '../localization/extension.dart'; +import '../models/message.dart'; +import '../models/message_source.dart'; +import '../scaffold_messenger/service.dart'; +import 'mail_address_chip.dart'; + +enum _DragAction { noted, later, delete, reply } +/// A stack of messages that can be processed. class MessageStack extends StatefulWidget { - const MessageStack({Key? key, required this.messageSource}) : super(key: key); + /// Creates a new [MessageStack] widget. + const MessageStack({super.key, required this.messageSource}); + + /// The message source from which the messages are taken. + final MessageSource messageSource; - final MessageSource? messageSource; @override State createState() => _MessageStackState(); } @@ -25,12 +30,10 @@ class _MessageStackState extends State { int _currentMessageIndex = 0; Message? _currentMessage; double? _currentAngle; - final List _nextMessages = []; + 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() { @@ -53,7 +56,7 @@ class _MessageStackState extends State { _currentAngle = _nextAngles.first; _nextMessages.removeAt(0); _nextAngles.removeAt(0); - if (widget.messageSource!.size > _currentMessageIndex + 3) { + if (widget.messageSource.size > _currentMessageIndex + 3) { // _nextMessages.add( // widget.messageSource!.getMessageAt(_currentMessageIndex + 3)); _nextAngles.add(createAngle()); @@ -63,9 +66,10 @@ class _MessageStackState extends State { } void moveToPreviousMessage() { - if (_currentMessage != null && _currentMessageIndex > 0) { + final currentMessage = _currentMessage; + if (currentMessage != null && _currentMessageIndex > 0) { setState(() { - _nextMessages.insert(0, _currentMessage); + _nextMessages.insert(0, currentMessage); _nextAngles.insert(0, createAngle()); _currentMessageIndex--; // _currentMessage = @@ -77,9 +81,10 @@ class _MessageStackState extends State { @override Widget build(BuildContext context) { final quickReplies = ['OK', 'Thank you!', '👍', '😊']; - final dateTime = _currentMessage!.mimeMessage.decodeDate(); - final dayName = - dateTime == null ? '' : locator().formatDay(dateTime); + final dateTime = _currentMessage?.mimeMessage.decodeDate(); + final dayName = dateTime == null ? '' : context.formatDay(dateTime); + final currentMessage = _currentMessage; + return Stack( alignment: Alignment.center, fit: StackFit.expand, @@ -98,7 +103,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, @@ -108,8 +113,8 @@ class _MessageStackState extends State { // right: delete Align( alignment: Alignment.centerRight, - child: MessageDragTarget( - action: DragAction.delete, + child: _MessageDragTarget( + action: _DragAction.delete, onComplete: acceptDragOperation, width: 100, height: 200, @@ -118,8 +123,8 @@ class _MessageStackState extends State { // top: noted (read) Align( alignment: Alignment.topCenter, - child: MessageDragTarget( - action: DragAction.noted, + child: _MessageDragTarget( + action: _DragAction.noted, onComplete: acceptDragOperation, width: 200, height: 100, @@ -128,8 +133,8 @@ class _MessageStackState extends State { // left: later Align( alignment: Alignment.centerLeft, - child: MessageDragTarget( - action: DragAction.later, + child: _MessageDragTarget( + action: _DragAction.later, onComplete: acceptDragOperation, width: 100, height: 200, @@ -141,12 +146,15 @@ class _MessageStackState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - ...quickReplies.map((reply) => MessageDragTarget( + ...quickReplies.map( + (reply) => _MessageDragTarget( data: reply, - action: DragAction.reply, + action: _DragAction.reply, onComplete: acceptDragOperation, width: 50, - height: 100)) + height: 100, + ), + ), ], ), ), @@ -154,7 +162,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: @@ -163,12 +171,12 @@ class _MessageStackState extends State { ), ), // center: first / current message - if (_currentMessage != null) + if (currentMessage != null) Padding( padding: const EdgeInsets.all(30), - child: MessageDraggable( - message: _currentMessage, - angle: _currentAngle, + child: _MessageDraggable( + message: currentMessage, + angle: _currentAngle ?? 0, ), ) else @@ -180,75 +188,86 @@ 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; late Future Function() undo; switch (action) { - case DragAction.noted: + 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; - case DragAction.later: + case _DragAction.later: // nothing to do, just move on? break; - case DragAction.delete: - //TODO remove from message source - await message.mailClient - .flagMessage(message.mimeMessage, isDeleted: true); + case _DragAction.delete: + // TODO(RV): remove from message source + 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(RV): add re-integration into message source break; - case DragAction.reply: - //TODO implement quick reply + case _DragAction.reply: + // TODO(RV): implement quick reply snack = 'replied with $data'; 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(RV): allow undo when marking as deleted + ScaffoldMessengerService.instance.showTextSnackBar( + context.text, + snack, + undo: () async { + // bring back message: + setState(() { + _currentMessage = message; + _currentMessageIndex = message.sourceIndex; + }); + await undo(); + }, + ); + } } } } -class MessageDragTarget extends StatefulWidget { - const MessageDragTarget( - {Key? key, - required this.action, - required this.onComplete, - this.data, - this.width, - this.height}) - : super(key: key); - final DragAction action; +class _MessageDragTarget extends StatefulWidget { + const _MessageDragTarget({ + 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; + final Function(Message message, _DragAction action, {Object? data}) + onComplete; final double? width; final double? height; @override - State createState() => _MessageDragTargetState(); + State<_MessageDragTarget> createState() => _MessageDragTargetState(); } -class _MessageDragTargetState extends State { - double? width; - double? height; +class _MessageDragTargetState extends State<_MessageDragTarget> { + late double width; + late double height; Color? color; late String text; @@ -261,19 +280,19 @@ class _MessageDragTargetState extends State { width = widget.width ?? 100; height = widget.height ?? 100; switch (widget.action) { - case DragAction.noted: + case _DragAction.noted: color = Colors.green[300]; text = 'Noted'; break; - case DragAction.later: + case _DragAction.later: color = Colors.yellow[300]; text = 'Later'; break; - case DragAction.delete: + case _DragAction.delete: color = Colors.red[300]; text = 'Delete'; break; - case DragAction.reply: + case _DragAction.reply: color = Colors.yellow[300]; text = widget.data?.toString() ?? 'Reply'; break; @@ -285,10 +304,11 @@ class _MessageDragTargetState extends State { } void startAccepting() { - if (width == _originalWidth) { + final originalWidth = _originalWidth; + if (originalWidth != null && width == originalWidth) { setState(() { - width = _originalWidth! * 1.2; - height = _originalHeight! * 1.2; + width = originalWidth * 1.2; + height = (_originalHeight ?? originalWidth) * 1.2; color = Color.lerp(_originalColor, Colors.black, 0.3); }); } @@ -296,22 +316,20 @@ class _MessageDragTargetState extends State { void endAccepting() { setState(() { - width = _originalWidth; - height = _originalHeight; + width = _originalWidth ?? 100; + height = _originalHeight ?? 100; color = _originalColor; }); } @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, - borderRadius: BorderRadius.circular(width! / 5), + borderRadius: BorderRadius.circular(width / 5), ), width: width, height: height, @@ -320,88 +338,90 @@ 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(); -class MessageDraggable extends StatefulWidget { - final Message? message; - final double? angle; + return true; + }, + onAccept: (data) async { + endAccepting(); + widget.onComplete(data, widget.action, data: widget.data); + }, + onLeave: (data) => endAccepting(), + ); +} - const MessageDraggable({Key? key, this.message, this.angle}) - : super(key: key); +class _MessageDraggable extends StatefulWidget { + const _MessageDraggable({ + required this.message, + required this.angle, + }); + final Message message; + final double angle; @override - State createState() => _MessageDraggableState(); + State<_MessageDraggable> createState() => _MessageDraggableState(); } -class _MessageDraggableState extends State +class _MessageDraggableState extends State<_MessageDraggable> 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 { - final Message? message; - final double? angle; - const MessageCard({Key? key, required this.message, this.angle}) - : super(key: key); + const MessageCard({super.key, required this.message, required this.angle}); + final Message message; + final double angle; @override State createState() => _MessageCardState(); @@ -410,13 +430,13 @@ class MessageCard extends StatefulWidget { class _MessageCardState extends State { @override void initState() { - widget.message!.addListener(_update); + widget.message.addListener(_update); super.initState(); } @override void dispose() { - widget.message!.removeListener(_update); + widget.message.removeListener(_update); super.dispose(); } @@ -425,35 +445,32 @@ 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; + final mime = widget.message.mimeMessage; + return Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ - Text(mime.decodeSubject()!), + Text(mime.decodeSubject() ?? ''), Row( - mainAxisAlignment: MainAxisAlignment.start, children: [ const Text('From '), - for (final address in mime.from!) + for (final address in mime.from ?? const []) MailAddressChip(mailAddress: address), ], ), @@ -461,7 +478,7 @@ class _MessageCardState extends State { Wrap( children: [ const Text('To '), - for (final address in mime.to!) + for (final address in mime.to ?? const []) MailAddressChip(mailAddress: address), ], ), @@ -469,7 +486,7 @@ class _MessageCardState extends State { Wrap( children: [ const Text('CC '), - for (final address in mime.cc!) + for (final address in mime.cc ?? const []) MailAddressChip(mailAddress: address), ], ), @@ -479,34 +496,35 @@ class _MessageCardState extends State { } Widget buildContent() { - //TODO do not download or display the content + // TODO(RV): do not download or display the content // when the widget is not exposed, unless the content is there already - if (!widget.message!.mimeMessage.isDownloaded) { + 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); } 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(() {}); } @@ -515,6 +533,7 @@ class _MessageCardState extends State { print('unable to download message contents: $e'); } } + return message; } @@ -534,7 +553,7 @@ class _MessageCardState extends State { // }, // onPageFinished: (url) { // print('finished loading page'); - // //TODO inject JS to query size? + // TODO(RV): inject JS to query size? // }, // ); // } @@ -544,12 +563,14 @@ 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); } - //TODO add other content, attachments, etc + // TODO(RV): add other content, attachments, etc + return Text( - 'Unsupported content: ${widget.message?.mimeMessage.mediaType.text}'); + 'Unsupported content: ${widget.message.mimeMessage.mediaType.text}', + ); } } diff --git a/lib/widgets/new_mail_message_button.dart b/lib/widgets/new_mail_message_button.dart new file mode 100644 index 0000000..be1b048 --- /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/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.pushNamed( + Routes.mailCompose, + extra: ComposeData( + null, + MessageBuilder(), + ComposeAction.newMessage, + ), + ), + tooltip: context.text.homeFabTooltip, + elevation: 2, + child: const Icon(Icons.add), + ); +} diff --git a/lib/widgets/password_field.dart b/lib/widgets/password_field.dart index ab6aa82..8a3765e 100644 --- a/lib/widgets/password_field.dart +++ b/lib/widgets/password_field.dart @@ -4,19 +4,21 @@ 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); + this.onSubmitted, + }); final TextEditingController? controller; final String? labelText; final String? hintText; - final void Function(String text)? onChanged; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; final bool autofocus; final bool cupertinoShowLabel; @@ -28,33 +30,32 @@ class _PasswordFieldState extends State { bool _obscureText = true; @override - Widget build(BuildContext context) { - return DecoratedPlatformTextField( - controller: widget.controller, - obscureText: _obscureText, - onChanged: widget.onChanged, - autofocus: widget.autofocus, - cupertinoShowLabel: widget.cupertinoShowLabel, - decoration: InputDecoration( - hintText: widget.hintText, - labelText: widget.labelText, - suffixIcon: PlatformIconButton( - icon: Icon(_obscureText ? Icons.lock_open : Icons.lock), - onPressed: () { - setState( - () => _obscureText = !_obscureText, - ); - }, - cupertino: (context, platform) => CupertinoIconButtonData( - padding: const EdgeInsets.fromLTRB(0, 0, 5, 2), - icon: Icon( - _obscureText ? Icons.lock_open : Icons.lock, - color: CupertinoColors.secondaryLabel, - size: 20.0, + Widget build(BuildContext context) => DecoratedPlatformTextField( + controller: widget.controller, + obscureText: _obscureText, + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + autofocus: widget.autofocus, + cupertinoShowLabel: widget.cupertinoShowLabel, + decoration: InputDecoration( + hintText: widget.hintText, + labelText: widget.labelText, + suffixIcon: PlatformIconButton( + icon: Icon(_obscureText ? Icons.lock_open : Icons.lock), + onPressed: () { + setState( + () => _obscureText = !_obscureText, + ); + }, + cupertino: (context, platform) => CupertinoIconButtonData( + padding: const EdgeInsets.fromLTRB(0, 0, 5, 2), + icon: Icon( + _obscureText ? Icons.lock_open : Icons.lock, + color: CupertinoColors.secondaryLabel, + size: 20, + ), ), ), ), - ), - ); - } + ); } diff --git a/lib/widgets/recipient_input_field.dart b/lib/widgets/recipient_input_field.dart index e23454d..b88f81e 100644 --- a/lib/widgets/recipient_input_field.dart +++ b/lib/widgets/recipient_input_field.dart @@ -1,17 +1,22 @@ 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 'package:hooks_riverpod/hooks_riverpod.dart'; -class RecipientInputField extends StatefulWidget { +import '../app_lifecycle/provider.dart'; +import '../contact/model.dart'; +import '../localization/extension.dart'; +import '../logger.dart'; +import '../util/validator.dart'; +import 'icon_text.dart'; + +/// Allows to enter recipients for a message +class RecipientInputField extends StatefulHookConsumerWidget { + /// Creates a new [RecipientInputField] const RecipientInputField({ - Key? key, + super.key, this.labelText, this.hintText, this.controller, @@ -19,25 +24,41 @@ class RecipientInputField extends StatefulWidget { this.autofocus = false, required this.addresses, required this.contactManager, - }) : super(key: key); + }); + /// Optional label text final String? labelText; + + /// Optional hint text final String? hintText; + + /// Optional controller final TextEditingController? controller; + + /// Optional additional suffix icon final Widget? additionalSuffixIcon; + + /// Should the field be focused on first build? + /// + /// Defaults to `false` final bool autofocus; + + /// The list of addresses final List addresses; + + /// The optional contact manager final ContactManager? contactManager; @override - State createState() => _RecipientInputFieldState(); + ConsumerState createState() => + _RecipientInputFieldState(); } enum _AddressAction { copy, } -class _RecipientInputFieldState extends State { +class _RecipientInputFieldState extends ConsumerState { final _focusNode = FocusNode(); late TextEditingController _controller; @@ -56,7 +77,7 @@ class _RecipientInputFieldState extends State { } @override - dispose() { + void dispose() { super.dispose(); _focusNode.dispose(); _controller.dispose(); @@ -66,16 +87,18 @@ class _RecipientInputFieldState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final localizations = context.text; + return DragTarget( builder: (context, candidateData, rejectedData) { final labelText = widget.labelText; + return Container( color: candidateData.isEmpty ? null : theme.hoverColor, child: Wrap( 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 +119,7 @@ class _RecipientInputFieldState extends State { ), ), ), - feedbackOffset: const Offset(10.0, 10.0), + feedbackOffset: const Offset(10, 10), childWhenDragging: Opacity( opacity: 0.6, child: _AddressChip( @@ -133,7 +156,9 @@ class _RecipientInputFieldState extends State { ); }, onAccept: (mailAddress) { - widget.addresses.add(mailAddress); + if (!widget.addresses.contains(mailAddress)) { + widget.addresses.add(mailAddress); + } }, onLeave: (mailAddress) { widget.addresses.remove(mailAddress); @@ -141,37 +166,36 @@ class _RecipientInputFieldState extends State { ); } - Widget buildInput(ThemeData theme, BuildContext context) { - return 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) { - return 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: + widget.addresses.forEach(matches.remove); + + return matches; + }, + displayStringForOption: (option) => option.toString(), + fieldViewBuilder: + (context, textEditingController, focusNode, onFieldSubmitted) => + DecoratedPlatformTextField( controller: textEditingController, focusNode: focusNode, autofocus: widget.autofocus, @@ -191,7 +215,7 @@ class _RecipientInputFieldState extends State { : Row( mainAxisSize: MainAxisSize.min, children: [ - widget.additionalSuffixIcon!, + widget.additionalSuffixIcon ?? const SizedBox.shrink(), PlatformIconButton( icon: const Icon(Icons.contacts), onPressed: () => _pickContact(textEditingController), @@ -199,24 +223,26 @@ 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); + return PlatformActionChip( label: Column( children: [ - if (option.hasPersonalName) Text(option.personalName!), + if (option.hasPersonalName) + Text( + option.personalName ?? '', + ), Text(option.email, style: theme.textTheme.bodySmall), ], ), @@ -230,7 +256,8 @@ class _RecipientInputFieldState extends State { } else { _controller.value = TextEditingValue( selection: TextSelection.collapsed( - offset: currentTextInput.length), + offset: currentTextInput.length, + ), text: currentTextInput, ); } @@ -240,10 +267,8 @@ class _RecipientInputFieldState extends State { ), ), ), - ); - }, - ); - } + ), + ); void checkEmail(String input) { if (Validator.validateEmail(input)) { @@ -254,46 +279,37 @@ class _RecipientInputFieldState extends State { } } - void _pickContact(TextEditingController controller) async { + Future _pickContact(TextEditingController controller) async { try { - final contact = - await FlutterContactPicker.pickEmailContact(askForPermission: true); - widget.addresses.add( - MailAddress( - contact.fullName, - contact.email!.email!, - ), - ); - setState(() {}); + ref + .read(appLifecycleProvider.notifier) + .ignoreNextInactivationCycle(timeout: const Duration(seconds: 120)); - // final contact = - // await FlutterContactPicker.pickEmailContact(askForPermission: true); - // widget.addresses - // .add(MailAddress(contact.fullName, contact.email!.email!)); - // setState(() {}); - // if (controller.text.isNotEmpty) { - // controller.text += '; ' + contact.email.email; - // } else { - // controller.text = contact.email.email; - // } - // controller.selection = - // TextSelection.collapsed(offset: controller.text.length); - } catch (e, s) { - if (kDebugMode) { - print('Unable to pick contact $e $s'); + final contact = await FlutterContactPicker.pickEmailContact(); + final email = contact.email?.email; + if (email != null) { + widget.addresses.add( + MailAddress( + contact.fullName, + email, + ), + ); } + setState(() {}); + } catch (e, s) { + logger.e('Unable to pick contact $e', error: e, stackTrace: s); } } } 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; @@ -306,22 +322,24 @@ class _AddressChip extends StatelessWidget { @override Widget build(BuildContext context) { final content = PlatformChip( - label: Column( - children: [ - Text(address.personalName ?? ''), - Text(address.email, style: Theme.of(context).textTheme.bodySmall), - ], - ), - deleteIcon: const Icon(Icons.close), - onDeleted: onDeleted); + label: Column( + children: [ + Text(address.personalName ?? ''), + Text(address.email, style: Theme.of(context).textTheme.bodySmall), + ], + ), + deleteIcon: const Icon(Icons.close), + onDeleted: onDeleted, + ); final menuItems = this.menuItems; if (menuItems == null) { return content; } final theme = Theme.of(context); + return PlatformPopupMenuButton( cupertinoButtonPadding: EdgeInsets.zero, - title: address.hasPersonalName ? Text(address.personalName!) : null, + title: address.hasPersonalName ? Text(address.personalName ?? '') : null, message: Text(address.email, style: theme.textTheme.bodySmall), itemBuilder: (context) => menuItems, onSelected: onMenuItemSelected, diff --git a/lib/widgets/search_text_field.dart b/lib/widgets/search_text_field.dart index 9b97c47..58b21bb 100644 --- a/lib/widgets/search_text_field.dart +++ b/lib/widgets/search_text_field.dart @@ -1,35 +1,43 @@ 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 'package:enough_platform_widgets/cupertino.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; -import '../locator.dart'; +import '../localization/extension.dart'; +import '../models/message_source.dart'; +import '../routes/routes.dart'; /// A dedicated search field optimized for Cupertino class CupertinoSearch extends StatelessWidget { - const CupertinoSearch({Key? key, required this.messageSource}) - : super(key: key); + /// Creates a new [CupertinoSearch] + const CupertinoSearch({super.key, required this.messageSource}); + /// The source in which should be searched final MessageSource messageSource; @override Widget build(BuildContext context) { final localizations = context.text; + return CupertinoSearchFlowTextField( - onSubmitted: _onSearchSubmitted, - cancelText: localizations.actionCancel); + onSubmitted: (text) => _onSearchSubmitted(context, text.trim()), + cancelText: localizations.actionCancel, + ); } - void _onSearchSubmitted(String text) { + void _onSearchSubmitted(BuildContext context, String text) { + if (text.isEmpty) { + return; + } final search = MailSearch(text, SearchQueryType.allTextHeaders); - final next = messageSource.search(search); - locator().push( + final next = messageSource.search(context.text, search); + context.pushNamed( Routes.messageSource, - arguments: next, + pathParameters: { + Routes.pathParameterEmail: messageSource.account.email, + }, + extra: next, ); } } diff --git a/lib/widgets/signature.dart b/lib/widgets/signature.dart index 6505acf..06e4704 100644 --- a/lib/widgets/signature.dart +++ b/lib/widgets/signature.dart @@ -1,17 +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 '../l10n/extension.dart'; -import '../locator.dart'; -import '../models/account.dart'; -import '../services/icon_service.dart'; -import '../services/mail_service.dart'; +import '../account/model.dart'; +import '../account/provider.dart'; +import '../localization/extension.dart'; import '../settings/provider.dart'; +import '../settings/theme/icon_service.dart'; import '../util/modal_bottom_sheet_helper.dart'; class SignatureWidget extends HookConsumerWidget { @@ -22,22 +24,24 @@ class SignatureWidget extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final account = this.account; final signatureState = useState( - account?.signatureHtml ?? - ref.read(settingsProvider.notifier).getSignatureHtmlGlobal(), + account?.getSignatureHtml(context.text.localeName) ?? + ref.read(settingsProvider.notifier).getSignatureHtmlGlobal(context), ); final signature = signatureState.value; Future showEditor() async { final localizations = context.text; - final iconService = locator(); + final iconService = IconService.instance; HtmlEditorApi? editorApi; - final result = await ModelBottomSheetHelper.showModalBottomSheet( + final result = await ModelBottomSheetHelper.showModalBottomSheet( context, account?.name ?? localizations.signatureSettingsTitle, PackagedHtmlEditor( initialContent: signature ?? - ref.read(settingsProvider.notifier).getSignatureHtmlGlobal(), + ref + .read(settingsProvider.notifier) + .getSignatureHtmlGlobal(context), excludeDocumentLevelControls: true, onCreated: (api) => editorApi = api, ), @@ -48,29 +52,30 @@ 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); + signatureState.value = + notifier.getSignatureHtmlGlobal(context); await notifier.update( settings.withoutSignatures(), ); - signatureState.value = notifier.getSignatureHtmlGlobal(); } }, ), DensePlatformIconButton( icon: Icon(CommonPlatformIcons.ok), - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => context.pop(true), ), ], ); - - if (result && editorApi != null) { - final newSignature = await editorApi!.getText(); + final usedEditorApi = editorApi; + if ((result ?? false) && usedEditorApi != null) { + final newSignature = await usedEditorApi.getText(); signatureState.value = newSignature; if (account == null) { final settings = ref.read(settingsProvider); @@ -79,7 +84,7 @@ class SignatureWidget extends HookConsumerWidget { ); } else { account.signatureHtml = newSignature; - await locator().saveAccounts(); + unawaited(ref.read(realAccountsProvider.notifier).save()); } } } diff --git a/lib/widgets/text_with_links.dart b/lib/widgets/text_with_links.dart index 76873c0..9044737 100644 --- a/lib/widgets/text_with_links.dart +++ b/lib/widgets/text_with_links.dart @@ -2,16 +2,23 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; +/// Displays text with links that can be tapped. class TextWithLinks extends StatelessWidget { + /// Creates a new text with links widget. + const TextWithLinks({super.key, required this.text, this.style}); + + /// The text to display. final String text; + + /// The style to use for the 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}://'); - // 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, + 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( - r'(([a-z]{3,6}:\/\/)|(^|\s))([a-zA-Z0-9\-]+\.)+[a-z]{2,13}([\?\/]+[\.\?\=\&\%\/\w\+\-]*)?'); + static final RegExp _linkRegEx = RegExp( + r'(([a-z]{3,6}:\/\/)|(^|\s))([a-zA-Z0-9\-]+\.)+[a-z]{2,13}([\?\/]+[\.\?\=\&\%\/\w\+\-]*)?', + ); @override Widget build(BuildContext context) { @@ -19,16 +26,18 @@ class TextWithLinks extends StatelessWidget { final textStyle = style ?? theme.textTheme.bodyMedium ?? TextStyle( - color: theme.brightness == Brightness.light - ? Colors.black - : Colors.white); - final matches = linkRegEx.allMatches(text); + color: theme.brightness == Brightness.light + ? Colors.black + : Colors.white, + ); + final matches = _linkRegEx.allMatches(text); if (matches.isEmpty) { return SelectableText(text, style: textStyle); } final linkStyle = textStyle.copyWith( - decoration: TextDecoration.underline, - color: theme.colorScheme.secondary); + decoration: TextDecoration.underline, + color: theme.colorScheme.secondary, + ); final spans = []; var end = 0; for (final match in matches) { @@ -36,14 +45,15 @@ class TextWithLinks extends StatelessWidget { // this is an email address, abort abort! ;-) continue; } - final originalGroup = match.group(0)!; + final originalGroup = match.group(0) ?? ''; final group = originalGroup.trimLeft(); final start = match.start + originalGroup.length - group.length; spans.add(TextSpan(text: text.substring(end, start))); final endsWithDot = group.endsWith('.'); final urlText = endsWithDot ? group.substring(0, group.length - 1) : group; - final url = !group.startsWith(schemeRegEx) ? 'https://$urlText' : urlText; + final url = + !group.startsWith(_schemeRegEx) ? 'https://$urlText' : urlText; spans.add( TextSpan( text: urlText, @@ -57,18 +67,26 @@ class TextWithLinks extends StatelessWidget { if (end < text.length) { spans.add(TextSpan(text: text.substring(end))); } + return SelectableText.rich( - TextSpan(children: spans, style: textStyle), + TextSpan( + children: spans, + style: textStyle, + ), ); } } +/// Displays text with links that can be tapped. class TextWithNamedLinks extends StatelessWidget { + /// Creates a new text with links widget. + const TextWithNamedLinks({super.key, required this.parts, this.style}); + + /// The text parts to display. final List parts; - final TextStyle? style; - const TextWithNamedLinks({Key? key, required this.parts, this.style}) - : super(key: key); + /// The style to use for the text. + final TextStyle? style; @override Widget build(BuildContext context) { @@ -76,9 +94,10 @@ class TextWithNamedLinks extends StatelessWidget { final textStyle = style ?? theme.textTheme.bodyMedium ?? TextStyle( - color: theme.brightness == Brightness.light - ? Colors.black - : Colors.white); + color: theme.brightness == Brightness.light + ? Colors.black + : Colors.white, + ); final linkStyle = textStyle.copyWith( decoration: TextDecoration.underline, color: theme.colorScheme.secondary, @@ -97,7 +116,7 @@ class TextWithNamedLinks extends StatelessWidget { if (callback != null) { callback(); } else { - launchUrl(Uri.parse(url!)); + launchUrl(Uri.parse(url ?? '')); } }, ), @@ -106,17 +125,27 @@ class TextWithNamedLinks extends StatelessWidget { spans.add(TextSpan(text: part.text)); } } + return SelectableText.rich( TextSpan(children: spans, style: textStyle), ); } } +/// A link in a text. class TextLink { + /// Creates a new text link. + const TextLink(this.text, [this.url]) : callback = null; + + /// Creates a new text link with a callback. + const TextLink.callback(this.text, this.callback) : url = null; + + /// The text to display. final String text; + + /// The URL to open when the link is tapped. final String? url; - final void Function()? callback; - const TextLink(this.text, [this.url]) : callback = null; - const TextLink.callback(this.text, this.callback) : url = null; + /// The callback to call when the link is tapped. + final void Function()? callback; } diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index f3c626c..b526f6a 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -1,9 +1,8 @@ -export 'account_provider_selector.dart'; +export 'account_hoster_selector.dart'; export 'account_selector.dart'; export 'app_drawer.dart'; export 'attachment_chip.dart'; export 'attachment_compose_bar.dart'; -export 'button_text.dart'; export 'cupertino_status_bar.dart'; export 'editor_extensions.dart'; export 'empty_message.dart'; @@ -19,6 +18,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/missing-translations.txt b/missing-translations.txt index 9e26dfe..22a6c04 100644 --- a/missing-translations.txt +++ b/missing-translations.txt @@ -1 +1,9 @@ -{} \ No newline at end of file +{ + "de": [ + "multipleSelectionActionFailed" + ], + + "es": [ + "multipleSelectionActionFailed" + ] +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e83d6a8 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1980 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + url: "https://pub.dev" + source: hosted + version: "61.0.0" + add_2_calendar: + dependency: transitive + description: + name: add_2_calendar + sha256: "8d7a82aba607d35f2a5bc913419e12f865a96a350a8ad2509a59322bc161f200" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + url: "https://pub.dev" + source: hosted + version: "5.13.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + url: "https://pub.dev" + source: hosted + version: "0.11.2" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + archive: + dependency: transitive + description: + name: archive + sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + url: "https://pub.dev" + source: hosted + version: "3.4.10" + args: + dependency: transitive + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "21afe4333076c02877d14f4a89df111e658a6d466cbfc802eb705eb91bd5adfd" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + background_fetch: + dependency: "direct main" + description: + name: background_fetch + sha256: f70b28a0f7a3156195e9742229696f004ea3bf10f74039b7bf4c78a74fbda8a4 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + badges: + dependency: "direct main" + description: + name: badges + sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + basic_utils: + dependency: transitive + description: + name: basic_utils + sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + url: "https://pub.dev" + source: hosted + version: "1.1.1" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: c9e32d21dd6626b5c163d48b037ce906bbe428bc23ab77bcd77bb21e593b6185 + url: "https://pub.dev" + source: hosted + version: "7.2.11" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: c9aabae0718ec394e5bc3c7272e6bb0dc0b32201a08fe185ec1d8401d3e39309 + url: "https://pub.dev" + source: hosted + version: "8.8.1" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + chewie: + dependency: transitive + description: + name: chewie + sha256: "3427e469d7cc99536ac4fbaa069b3352c21760263e65ffb4f0e1c054af43a73e" + url: "https://pub.dev" + source: hosted + version: "1.7.4" + chewie_audio: + dependency: transitive + description: + name: chewie_audio + sha256: "73948a8b9841d050433af3498a1f8b11320bd5a2cd70b449bdbe16d4405e97c5" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: feee43a5c05e7b3199bb375a86430b8ada1b04104f2923d0e03cc01ca87b6d84 + url: "https://pub.dev" + source: hosted + version: "4.9.0" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + community_material_icon: + dependency: "direct main" + description: + name: community_material_icon + sha256: bb389689f6278158d7b9d9b0c9433e603933283104fea226594590f61503fd08 + url: "https://pub.dev" + source: hosted + version: "5.9.55" + contact_picker_platform_interface: + dependency: transitive + description: + name: contact_picker_platform_interface + sha256: "40847ffa0f6e6755c0047e8ef35c4b622a2b053f41ef175d22c19f03984a9ed1" + url: "https://pub.dev" + source: hosted + version: "4.7.0" + contact_picker_web: + dependency: transitive + description: + name: contact_picker_web + sha256: "13e739a6ce8f3286e441028dec5967b90fd2d5ceaec53045ae054b030877fe31" + url: "https://pub.dev" + source: hosted + version: "4.7.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: fedaadfa3a6996f75211d835aaeb8fede285dae94262485698afd832371b9a5e + url: "https://pub.dev" + source: hosted + version: "0.3.3+8" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + csslib: + dependency: transitive + description: + name: csslib + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + url: "https://pub.dev" + source: hosted + version: "1.0.6" + cupertino_progress_bar: + dependency: transitive + description: + name: cupertino_progress_bar + sha256: "4962a7c6db9b94d1c19462ad6036fdf20db43defa4114c908a3c4cbef1b3a2e0" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + cupertino_stepper: + dependency: transitive + description: + name: cupertino_stepper + sha256: "124690b8b23db7b43fc6d547688e42a9f3d73c8f46d0b210568e98a04b5645b2" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "198ec6b8e084d22f508a76556c9afcfb71706ad3f42b083fe0ee923351a96d90" + url: "https://pub.dev" + source: hosted + version: "0.5.7" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: dfcfa987d2bd9d0ba751ef4bdef0f6c1aa0062f2a67fe716fd5f3f8b709d6418 + url: "https://pub.dev" + source: hosted + version: "0.5.7" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: f84c3fe2f27ef3b8831953e477e59d4a29c2952623f9eac450d7b40d9cdd94cc + url: "https://pub.dev" + source: hosted + version: "0.5.7" + dart_code_metrics: + dependency: "direct dev" + description: + name: dart_code_metrics + sha256: "3dede3f7abc077a4181ec7445448a289a9ce08e2981e6a4d49a3fb5099d47e1f" + url: "https://pub.dev" + source: hosted + version: "5.7.6" + dart_code_metrics_presets: + dependency: transitive + description: + name: dart_code_metrics_presets + sha256: b71eadf02a3787ebd5c887623f83f6fdc204d45c75a081bd636c4104b3fd8b73 + url: "https://pub.dev" + source: hosted + version: "1.8.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "0042cb3b2a76413ea5f8a2b40cec2a33e01d0c937e91f0f7c211fde4f7739ba6" + url: "https://pub.dev" + source: hosted + version: "9.1.1" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64 + url: "https://pub.dev" + source: hosted + version: "7.0.0" + dots_indicator: + dependency: transitive + description: + name: dots_indicator + sha256: f1599baa429936ba87f06ae5f2adc920a367b16d08f74db58c3d0f6e93bcdb5c + url: "https://pub.dev" + source: hosted + version: "2.1.2" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + enough_ascii_art: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: f1ba3913c6f37b86121f3b453fde5ba545142a9c + url: "https://github.com/Enough-Software/enough_ascii_art.git" + source: git + version: "1.1.0" + enough_convert: + dependency: transitive + description: + name: enough_convert + sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + enough_giphy: + dependency: transitive + description: + name: enough_giphy + sha256: "71939f18e6bbe195d8d70f2fef828dea9634258dda429f294bc9d5322fff3ae7" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + enough_giphy_flutter: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: e20aa96b1d368db670f1657837888df63f482bb1 + url: "https://github.com/Enough-Software/enough_giphy_flutter.git" + source: git + version: "0.4.1" + enough_html_editor: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: f8bd4f835d8d7934a612cb9493fac4abc5915904 + url: "https://github.com/Enough-Software/enough_html_editor.git" + source: git + version: "0.0.5" + enough_icalendar: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "2682fd5e45090452c13e89fc47c30c5180eeb911" + url: "https://github.com/Enough-Software/enough_icalendar.git" + source: git + version: "0.15.0" + enough_icalendar_export: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "3d4811292267f70a20d843b204f263132d476592" + url: "https://github.com/Enough-Software/enough_icalendar_export.git" + source: git + version: "0.3.0" + enough_mail: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "7587d07dc1ade92cb6d2a5f5ff0b0af74079d318" + url: "https://github.com/Enough-Software/enough_mail.git" + source: git + version: "2.1.6" + enough_mail_flutter: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "38eb65ca7a34c2a7695618ed1301d7159c4d9deb" + url: "https://github.com/Enough-Software/enough_mail_flutter.git" + source: git + version: "2.1.0" + enough_mail_html: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "378b93dd2daa5201098ee34677f16a0ffb96a1d5" + url: "https://github.com/Enough-Software/enough_mail_html.git" + source: git + version: "2.0.1" + enough_mail_icalendar: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "55a7b249c59cc62b242cbe299fbad6968ffd9a76" + url: "https://github.com/Enough-Software/enough_mail_icalendar.git" + source: git + version: "0.2.1" + enough_media: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: a27f06f6bfe7c714d1d15c0e3a22834682c39a29 + url: "https://github.com/Enough-Software/enough_media.git" + source: git + version: "2.2.2" + enough_platform_widgets: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "2eb6f9f8d1ab0347161a861b9d232370988d3006" + url: "https://github.com/Enough-Software/enough_platform_widgets.git" + source: git + version: "1.0.0" + enough_text_editor: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "6b02e5ba7ce1ce59226149b9bd66d52c7a8546a4" + url: "https://github.com/Enough-Software/enough_text_editor.git" + source: git + version: "0.1.0" + event_bus: + dependency: "direct main" + description: + name: event_bus + sha256: "44baa799834f4c803921873e7446a2add0f3efa45e101a054b1f0ab9b95f8edc" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + extension: + dependency: transitive + description: + name: extension + sha256: be3a6b7f8adad2f6e2e8c63c895d19811fcf203e23466c6296267941d0ff4f24 + url: "https://pub.dev" + source: hosted + version: "0.6.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: "direct overridden" + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "458a6ed8ea480eb16ff892aedb4b7092b2804affd7e046591fb03127e8d8ef8b" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_hooks: + dependency: "direct main" + description: + name: flutter_hooks + sha256: "09f64db63fee3b2ab8b9038a1346be7d8986977fae3fec601275bf32455ccfc0" + url: "https://pub.dev" + source: hosted + version: "0.20.4" + flutter_inappwebview: + dependency: transitive + description: + name: flutter_inappwebview + sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: fd4db51e46f49b140d83a3206851432c54ea920b381137c0ba82d0cf59be1dee + url: "https://pub.dev" + source: hosted + version: "1.0.12" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" + url: "https://pub.dev" + source: hosted + version: "5.4.1" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "892ada16046d641263f30c72e7432397088810a84f34479f6677494802a2b535" + url: "https://pub.dev" + source: hosted + version: "16.3.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03" + url: "https://pub.dev" + source: hosted + version: "4.0.0+1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef" + url: "https://pub.dev" + source: hosted + version: "7.0.0+1" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "9cdb5d9665dab5d098dc50feab74301c2c228cd02ca25c9b546ab572cebcd6af" + url: "https://pub.dev" + source: hosted + version: "2.3.9" + flutter_platform_widgets: + dependency: transitive + description: + name: flutter_platform_widgets + sha256: "4970c211af1dad0a161e6379d04de2cace80283da0439f2f87d31a541f9b2b84" + url: "https://pub.dev" + source: hosted + version: "6.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da + url: "https://pub.dev" + source: hosted + version: "2.0.17" + flutter_riverpod: + dependency: transitive + description: + name: flutter_riverpod + sha256: da9591d1f8d5881628ccd5c25c40e74fc3eef50ba45e40c3905a06e1712412d5 + url: "https://pub.dev" + source: hosted + version: "2.4.9" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: ffdbb60130e4665d2af814a0267c481bcf522c41ae2e43caf69fa0146876d685 + url: "https://pub.dev" + source: hosted + version: "9.0.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "3d5032e314774ee0e1a7d0a9f5e2793486f0dff2dd9ef5a23f4e3fb2a0ae6a9e" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: bd33935b4b628abd0b86c8ca20655c5b36275c3a3f5194769a7b3f37c905369c + url: "https://pub.dev" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "0d4d3a5dd4db28c96ae414d7ba3b8422fd735a8255642774803b2532c9a61d7e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "30f84f102df9dcdaa2241866a958c2ec976902ebdaa8883fbfe525f1f2f3cf20" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "5809c66f9dd3b4b93b0a6e2e8561539405322ee767ac2f64d084e2ab5429d108" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_auth: + dependency: "direct main" + description: + name: flutter_web_auth + sha256: a69fa8f43b9e4d86ac72176bf747b735e7b977dd7cf215076d95b87cb05affdd + url: "https://pub.dev" + source: hosted + version: "0.5.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_widget_from_html_core: + dependency: "direct main" + description: + name: flutter_widget_from_html_core + sha256: "0e281196f962fd951da5b9d3fa50e0674fabf8fda92eafd8745d050d70877c68" + url: "https://pub.dev" + source: hosted + version: "0.14.10+1" + fluttercontactpicker: + dependency: "direct main" + description: + name: fluttercontactpicker + sha256: cddd2ba4631f97927adb59c53a529ee8f965e8685eded66a6d5988347aef6ec4 + url: "https://pub.dev" + source: hosted + version: "4.7.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c3fd9336eb55a38cc1bbd79ab17573113a8deccd0ecbbf926cca3c62803b5c2d + url: "https://pub.dev" + source: hosted + version: "2.4.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: f79870884de16d689cf9a7d15eedf31ed61d750e813c538a6efb92660fea83c3 + url: "https://pub.dev" + source: hosted + version: "7.6.4" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "3b40e751eaaa855179b416974d59d29669e750d2e50fcdb2b37f1cb0ca8c803a" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 + url: "https://pub.dev" + source: hosted + version: "6.1.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + hooks_riverpod: + dependency: "direct main" + description: + name: hooks_riverpod + sha256: c12a456e03ef9be65b0be66963596650ad7a3220e96c7e7b0a048562ea32d6ae + url: "https://pub.dev" + source: hosted + version: "2.4.9" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: "94ee21a60ea2836500799f3af035dc3212b1562027f1e0031c14e087f0231449" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + http: + dependency: "direct main" + description: + name: http + sha256: d4872660c46d929f6b8a9ef4e7a7eff7e49bbf0c4ec3f385ee32df5119175139 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + url: "https://pub.dev" + source: hosted + version: "4.1.3" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + url: "https://pub.dev" + source: hosted + version: "0.18.1" + introduction_screen: + dependency: "direct main" + description: + name: introduction_screen + sha256: "72d25ceb71471773783f72783608e17585af93d4bc6474df577fcfe9e7842852" + url: "https://pub.dev" + source: hosted + version: "3.1.12" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: aa1f5a8912615733e0fdc7a02af03308933c93235bdc8d50d0b0c8a8ccb0b969 + url: "https://pub.dev" + source: hosted + version: "6.7.1" + latlng: + dependency: "direct main" + description: + name: latlng + sha256: cbc68be333d6dd4a152190066d0987737a6f04f0e31e670c31bf8cc7219c3a96 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + lints: + dependency: transitive + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "7e6c63082e399b61e4af71266b012e767a5d4525dd6e9ba41e174fd42d76e115" + url: "https://pub.dev" + source: hosted + version: "2.1.7" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "54e9c35ce52c06333355ab0d0f41e4c06dbca354b23426765ba41dfb1de27598" + url: "https://pub.dev" + source: hosted + version: "1.0.36" + local_auth_ios: + dependency: transitive + description: + name: local_auth_ios + sha256: "8293faf72ef0ac4710f209edd03916c2d4c1eeab0483bdcf9b2e659c2f7d737b" + url: "https://pub.dev" + source: hosted + version: "1.1.5" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "3215f9a97aa532aca91ea7591e9ee6a553bdc66ff9b11f19d14b6dffc4fdf45b" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: "505ba3367ca781efb1c50d3132e44a2446bccc4163427bc203b9b4d8994d97ea" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + location: + dependency: "direct main" + description: + name: location + sha256: "06be54f682c9073cbfec3899eb9bc8ed90faa0e17735c9d9fa7fe426f5be1dd1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + location_platform_interface: + dependency: transitive + description: + name: location_platform_interface + sha256: "8aa1d34eeecc979d7c9fe372931d84f6d2ebbd52226a54fe1620de6fdc0753b1" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + location_web: + dependency: transitive + description: + name: location_web + sha256: ec484c66e8a4ff1ee5d044c203f4b6b71e3a0556a97b739a5bc9616de672412b + url: "https://pub.dev" + source: hosted + version: "4.2.0" + logger: + dependency: "direct main" + description: + name: logger + sha256: "6bbb9d6f7056729537a4309bda2e74e18e5d9f14302489cc1e93f33b3fe32cac" + url: "https://pub.dev" + source: hosted + version: "2.0.2+1" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + map: + dependency: "direct main" + description: + name: map + sha256: dc1975a42304a91b4ab3fc7f064f554f18be0fbefb5bcd4ae95d1c087508a893 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + meta: + dependency: transitive + description: + name: meta + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + url: "https://pub.dev" + source: hosted + version: "1.10.0" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: f603ebd85a576e5914870b02e5839fc5d0243b867bf710651cf239a28ebb365e + url: "https://pub.dev" + source: hosted + version: "1.0.2" + modal_bottom_sheet: + dependency: "direct main" + description: + name: modal_bottom_sheet + sha256: "3bba63c62d35c931bce7f8ae23a47f9a05836d8cb3c11122ada64e0b2f3d718f" + url: "https://pub.dev" + source: hosted + version: "3.0.0-pre" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + open_settings: + dependency: "direct main" + description: + name: open_settings + sha256: ceb716dc476352aecb939805b6fa6a593168a5ed1abfe3caa022b6b1715e94ae + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "7e76fad405b3e4016cd39d08f455a4eb5199723cf594cd1b8916d47140d93017" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + path: + dependency: transitive + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.dev" + source: hosted + version: "1.8.3" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + pdfx: + dependency: "direct overridden" + description: + path: "packages/pdfx" + ref: HEAD + resolved-ref: d637108a2a6e3e97a70304f00f1eda9511fb4f92 + url: "https://github.com/ScerIO/packages.flutter" + source: git + version: "2.5.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + photo_view: + dependency: transitive + description: + name: photo_view + sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" + url: "https://pub.dev" + source: hosted + version: "4.2.4" + provider: + dependency: transitive + description: + name: provider + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: b06600619c8c219065a548f8f7c192b3e080beff95488ed692780f48f69c0625 + url: "https://pub.dev" + source: hosted + version: "0.3.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "942999ee48b899f8a46a860f1e13cee36f2f77609eb54c5b7a669bb20d550b11" + url: "https://pub.dev" + source: hosted + version: "2.4.9" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 + url: "https://pub.dev" + source: hosted + version: "0.3.4" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: b70e95fbd5ca7ce42f5148092022971bb2e9843b6ab71e97d479e8ab52e98979 + url: "https://pub.dev" + source: hosted + version: "2.3.3" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "5b36ad2f2b562cffb37212e8d59390b25499bf045b732276e30a207b16a25f61" + url: "https://pub.dev" + source: hosted + version: "2.3.3" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "70198738c3047ae4f6517ef1a2011a8514a980a52576c7f629a3a08810319a02" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: f74fc3f1cbd99f39760182e176802f693fa0ec9625c045561cfad54681ea93dd + url: "https://pub.dev" + source: hosted + version: "7.2.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: df08bc3a07d01f5ea47b45d03ffcba1fa9cd5370fb44b3f38c70e42cced0f956 + url: "https://pub.dev" + source: hosted + version: "3.3.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + url: "https://pub.dev" + source: hosted + version: "2.3.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "7b15ffb9387ea3e237bb7a66b8a23d2147663d391cafc5c8f37b2e7b4bde5d21" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + shimmer_animation: + dependency: "direct main" + description: + name: shimmer_animation + sha256: "1f12d0f3fc20acbf2dcf8438b79f4268c523d55b2f3de55cf49e5bd3acbc5719" + url: "https://pub.dev" + source: hosted + version: "2.1.0+1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + url: "https://pub.dev" + source: hosted + version: "1.3.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: bb4738f15b23352822f4c42a531677e5c6f522e079461fd240ead29d8d8a54a6 + url: "https://pub.dev" + source: hosted + version: "2.5.0+2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + timezone: + dependency: transitive + description: + name: timezone + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" + url: "https://pub.dev" + source: hosted + version: "0.9.2" + timing: + dependency: transitive + description: + name: timing + sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc + url: "https://pub.dev" + source: hosted + version: "1.0.0+1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e9aa5ea75c84cf46b3db4eea212523591211c3cf2e13099ee4ec147f54201c86 + url: "https://pub.dev" + source: hosted + version: "6.2.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: c0766a55ab42cefaa728cabc951e82919ab41a3a4fee0aaa96176ca82da8cc51 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "46b81e3109cbb2d6b81702ad3077540789a3e74e22795eb9f0b7d494dbaa72ea" + url: "https://pub.dev" + source: hosted + version: "6.2.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: f099b552bd331eacd69affed7ff2f23bfa6b0cb825b629edf3d844375a7501ad + url: "https://pub.dev" + source: hosted + version: "2.2.1" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: fff0932192afeedf63cdd50ecbb1bc825d31aed259f02bb8dba0f3b729a5e88b + url: "https://pub.dev" + source: hosted + version: "2.2.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + uuid: + dependency: "direct overridden" + description: + name: uuid + sha256: "22c94e5ad1e75f9934b766b53c742572ee2677c56bc871d850a57dad0f82127f" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + video_player: + dependency: transitive + description: + name: video_player + sha256: fbf28ce8bcfe709ad91b5789166c832cb7a684d14f571a81891858fefb5bb1c2 + url: "https://pub.dev" + source: hosted + version: "2.8.2" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "7f8f25d7ad56819a82b2948357f3c3af071f6a678db33833b26ec36bbc221316" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "08da93071ef322603839aa42e90e23d4820b03cf2db7eb6a45de5d41fe85c2aa" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: be72301bf2c0150ab35a8c34d66e5a99de525f6de1e8d27c0672b836fe48f73a + url: "https://pub.dev" + source: hosted + version: "6.2.1" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "34beb3a07d4331a24f7e7b2f75b8e2b103289038e07e65529699a671b6a6e2cb" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + url: "https://pub.dev" + source: hosted + version: "13.0.0" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: f268ca2116db22e57577fb99d52515a24bdc1d570f12ac18bb762361d43b043d + url: "https://pub.dev" + source: hosted + version: "1.1.4" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "40fabed5da06caff0796dc638e1f07ee395fb18801fbff3255a2372db2d80385" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: "direct overridden" + description: + name: web + sha256: edc8a9573dd8c5a83a183dae1af2b6fd4131377404706ca4e5420474784906fa + url: "https://pub.dev" + source: hosted + version: "0.4.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "045ec2137c27bf1a32e6ffa0e734d532a6677bf9016a0d1a406c54e499ff945b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: "60e23976834e995c404c0b21d3b9db37ecd77d3303ef74f8b8d7a7b19947fc04" + url: "https://pub.dev" + source: hosted + version: "4.4.3" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "161af93c2abaf94ef2192bffb53a3658b2d721a3bf99b69aa1e47814ee18cc96" + url: "https://pub.dev" + source: hosted + version: "3.13.2" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: dbe745ee459a16b6fec296f7565a8ef430d0d681001d8ae521898b9361854943 + url: "https://pub.dev" + source: hosted + version: "2.9.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "02d8f3ebbc842704b2b662377b3ee11c0f8f1bbaa8eab6398262f40049819160" + url: "https://pub.dev" + source: hosted + version: "3.10.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + url: "https://pub.dev" + source: hosted + version: "5.2.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" + xml: + dependency: "direct overridden" + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3c0fd8b..e04e202 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,9 @@ name: enough_mail_app -description: Maily aims to become a fully feature email app once it has grown up. +description: Maily aims to become a fully featured 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 @@ -15,164 +15,170 @@ publish_to: "none" # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+86 +version: 1.0.0+96 environment: - sdk: '>=2.18.0 <4.0.0' + flutter: ">=3.16.0" + sdk: '>=3.2.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 - 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 + file_picker: ^6.0.0 flutter: sdk: flutter - fluttercontactpicker: ^4.7.0 flutter_colorpicker: ^1.0.3 - flutter_local_notifications: ^15.1.0+1 + flutter_hooks: ^0.20.3 + flutter_local_notifications: ^16.1.0 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 - google_fonts: ^5.1.0 + flutter_widget_from_html_core: ^0.14.4 + fluttercontactpicker: ^4.7.0 + get_it: ^7.2.0 + go_router: ^13.0.0 + google_fonts: ^6.1.0 hive: ^2.2.3 hive_flutter: ^1.1.0 - http: ^1.1.0 + hooks_riverpod: ^2.4.4 + http: ^1.1.1 intl: ^0.17.0 introduction_screen: ^3.0.2 json_annotation: ^4.8.1 - latlng: ^0.2.0 + latlng: ^1.0.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 - intl: ^0.18.0 - http: ^1.1.0 #for dart_code_metrics - xml: ^6.1.0 - device_info_plus: ^9.0.0 - package_info_plus: ^4.0.2 - ffi: ^2.0.1 - # flutter_inappwebview: - # git: https://github.com/CodeEagle/flutter_inappwebview - # out-comment the following to enable git-based development: - # enough_mail: - # git: - # url: https://github.com/Enough-Software/enough_mail.git - # enough_mail_html: - # git: - # url: https://github.com/Enough-Software/enough_mail_html.git - # enough_mail_flutter: - # git: - # url: https://github.com/Enough-Software/enough_mail_flutter.git - # enough_media: - # git: - # url: https://github.com/Enough-Software/enough_media.git - # enough_icalendar: - # git: - # url: https://github.com/Enough-Software/enough_icalendar.git - # enough_mail_icalendar: - # git: - # url: https://github.com/Enough-Software/enough_mail_icalendar.git - # enough_icalendar_export: - # git: - # url: https://github.com/Enough-Software/enough_icalendar_export.git - # enough_html_editor: - # git: - # url: https://github.com/Enough-Software/enough_html_editor.git - # enough_text_editor: - # git: - # url: https://github.com/Enough-Software/enough_text_editor.git - # 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: - # git: - # url: https://github.com/Enough-Software/enough_platform_widgets.git + collection: ^1.18.0 + intl: ^0.18.1 + http: ^1.1.1 # for dart_code_metrics + xml: ^6.4.2 + device_info_plus: ^9.1.0 + package_info_plus: ^4.2.0 + ffi: ^2.1.0 + uuid: ^4.0.0 # for dart_code_metrics + web: ^0.4.0 # for flutter test from SDK / http + pdfx: # fix util compatible version is released + git: + url: 'https://github.com/ScerIO/packages.flutter' + path: packages/pdfx - # out-comment the following to enable local development: + # out-comment the following to enable git-based development: enough_mail: - path: ../enough_mail - enough_mail_flutter: - path: ../enough_mail_flutter + git: + url: https://github.com/Enough-Software/enough_mail.git enough_mail_html: - path: ../enough_mail_html + git: + url: https://github.com/Enough-Software/enough_mail_html.git + enough_mail_flutter: + git: + url: https://github.com/Enough-Software/enough_mail_flutter.git + enough_media: + git: + url: https://github.com/Enough-Software/enough_media.git enough_icalendar: - path: ../enough_icalendar + git: + url: https://github.com/Enough-Software/enough_icalendar.git enough_mail_icalendar: - path: ../enough_mail_icalendar - enough_giphy: - path: ../enough_giphy - enough_giphy_flutter: - path: ../enough_giphy_flutter + git: + url: https://github.com/Enough-Software/enough_mail_icalendar.git enough_icalendar_export: - path: ../enough_icalendar_export - enough_media: - path: ../enough_media - enough_html_editor: - path: ../enough_html_editor + git: + url: https://github.com/Enough-Software/enough_icalendar_export.git + enough_html_editor: + git: + url: https://github.com/Enough-Software/enough_html_editor.git enough_text_editor: - path: ../enough_text_editor + git: + url: https://github.com/Enough-Software/enough_text_editor.git enough_ascii_art: - path: ../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: - path: ../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 + # enough_mail_flutter: + # path: ../enough_mail_flutter + # enough_mail_html: + # path: ../enough_mail_html + # enough_icalendar: + # path: ../enough_icalendar + # enough_mail_icalendar: + # path: ../enough_mail_icalendar + # enough_giphy: + # path: ../enough_giphy + # enough_giphy_flutter: + # path: ../enough_giphy_flutter + # enough_icalendar_export: + # path: ../enough_icalendar_export + # enough_media: + # path: ../enough_media + # enough_html_editor: + # path: ../enough_html_editor + # enough_text_editor: + # path: ../enough_text_editor + # enough_ascii_art: + # path: ../enough_ascii_art + # enough_platform_widgets: + # path: ../enough_platform_widgets dev_dependencies: build_runner: ^2.4.6 custom_lint: ^0.5.3 dart_code_metrics: 5.7.6 - flutter_lints: ^2.0.1 + flutter_lints: ^3.0.0 + flutter_native_splash: ^2.3.2 flutter_test: 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 # 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,13 +188,11 @@ flutter: # For localization / l10n: generate: false - + # To add assets to your application, add an assets section, like this: assets: - assets/images/ - assets/images/providers/ - - assets/keys.txt - # - images/a_dot_ham.jpeg # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. # For details regarding adding assets from package dependencies, see diff --git a/store/logo_padded.png b/store/logo_padded.png new file mode 100644 index 0000000..20447f0 Binary files /dev/null and b/store/logo_padded.png differ diff --git a/test/model/async_mime_source_test.dart b/test/model/async_mime_source_test.dart index c8daa3b..e00aa86 100644 --- a/test/model/async_mime_source_test.dart +++ b/test/model/async_mime_source_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail_app/models/async_mime_source.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -27,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 { @@ -52,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); } }); @@ -90,8 +92,11 @@ void main() async { } final messages = await Future.wait(futures); for (int i = 0; i < messages.length; i++) { - expect(messages[i].sequenceId, expectedSequenceIds[i], - reason: 'failed for index $i'); + expect( + messages[i].sequenceId, + expectedSequenceIds[i], + reason: 'failed for index $i', + ); } }); @@ -126,27 +131,38 @@ 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)); + MailConventions.headerDate, + DateCodec.encodeDate(oldDate), + ); await source.onMessageArrived(newMessage); expect(source.size, 101); var message = await source.getMessage(0); - expect(message.sequenceId, 100, - reason: 'first message should stay the same'); + expect( + message.sequenceId, + 100, + reason: 'first message should stay the same', + ); message = await source.getMessage(1); - expect(message.sequenceId, 101, - reason: 'second message should be the new message'); + expect( + message.sequenceId, + 101, + reason: 'second message should be the new message', + ); }); - Future _expectMessagesOrderedByDate(AsyncMimeSource source, - {int? numberToTest}) async { + Future expectMessagesOrderedByDate( + AsyncMimeSource source, { + int? numberToTest, + }) async { var lastDate = DateTime.now(); var lastSubject = ''; 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, @@ -154,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"', @@ -169,18 +185,25 @@ 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); + await expectMessagesOrderedByDate(source, numberToTest: 20); }); test('onMessagesVanished - sequence IDs', () async { final AsyncMimeSource source = FakeMimeSource(size: 101); - Future expectMessage(int index, int expectedGuid, - [String? reason]) async { + Future expectMessage( + int index, + int expectedGuid, [ + String? reason, + ]) async { final message = await source.getMessage(index); expect(message.guid, expectedGuid, reason: reason); } @@ -232,8 +255,11 @@ void main() async { test('onMessagesVanished - same valid sequence ID twice', () async { final AsyncMimeSource source = FakeMimeSource(size: 101); - Future expectMessage(int index, int expectedSequenceId, - [String? reason]) async { + Future expectMessage( + int index, + int expectedSequenceId, [ + String? reason, + ]) async { final message = await source.getMessage(index); expect(message.sequenceId, expectedSequenceId, reason: reason); } @@ -248,7 +274,10 @@ 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])); @@ -265,8 +294,11 @@ void main() async { test('onMessagesVanished - sequence IDs reverse', () async { final AsyncMimeSource source = FakeMimeSource(size: 101); - Future expectMessage(int index, int expectedGuid, - [String? reason]) async { + Future expectMessage( + int index, + int expectedGuid, [ + String? reason, + ]) async { final message = await source.getMessage(index); expect(message.guid, expectedGuid, reason: reason); } @@ -292,8 +324,11 @@ void main() async { test('onMessagesVanished - UIDs', () async { final AsyncMimeSource source = FakeMimeSource(size: 101); - Future expectMessage(int index, int expectedGuid, - [String? reason]) async { + Future expectMessage( + int index, + int expectedGuid, [ + String? reason, + ]) async { final message = await source.getMessage(index); expect(message.guid, expectedGuid, reason: reason); } @@ -306,7 +341,8 @@ void main() async { expect(message.sequenceId, 101 - i); } await source.onMessagesVanished( - MessageSequence.fromIds([101, 99, 98], isUid: true)); + MessageSequence.fromIds([101, 99, 98], isUid: true), + ); expect(source.size, 98); await expectMessage(0, 100, 'first should be 100'); await expectMessage(1, 97, 'second should be 97'); @@ -320,8 +356,11 @@ void main() async { test('onMessagesVanished - UIDs reversed', () async { final AsyncMimeSource source = FakeMimeSource(size: 101); - Future expectMessage(int index, int expectedGuid, - [String? reason]) async { + Future expectMessage( + int index, + int expectedGuid, [ + String? reason, + ]) async { final message = await source.getMessage(index); expect(message.guid, expectedGuid, reason: reason); } @@ -334,7 +373,8 @@ void main() async { expect(message.sequenceId, 101 - i); } await source.onMessagesVanished( - MessageSequence.fromIds([98, 99, 101], isUid: true)); + MessageSequence.fromIds([98, 99, 101], isUid: true), + ); expect(source.size, 98); await expectMessage(0, 100, 'first should be 100'); await expectMessage(1, 97, 'second should be 97'); @@ -359,8 +399,11 @@ void main() async { expect(source.size, 100); for (int i = 0; i < 20; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); } }); @@ -376,8 +419,11 @@ void main() async { expect(source.size, 100); for (int i = 0; i < 20; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); } }); @@ -395,8 +441,11 @@ void main() async { expect(source.size, 101); for (int i = 0; i < 21; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); } }); @@ -413,8 +462,11 @@ void main() async { expect(source.size, 99); for (int i = 0; i < 19; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); } }); @@ -426,15 +478,18 @@ void main() async { final message = await source.getMessage(i); messages.add(message); } - final copy = source.createMessage(messages[1].sequenceId!); - copy.isSeen = true; + final copy = source.createMessage(messages[1].sequenceId ?? -1) + ..isSeen = true; messages[1] = copy; await source.resyncMessagesManually(messages); expect(source.size, 100); for (int i = 0; i < messages.length; i++) { final message = await source.getMessage(i); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); } }); @@ -449,14 +504,17 @@ 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); for (int i = 0; i < messages.length; i++) { final message = await source.getMessage(i); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); } }); @@ -469,8 +527,8 @@ void main() async { messages.add(message); } - final copy = source.createMessage(messages[1].sequenceId!); - copy.isSeen = true; + final copy = source.createMessage(messages[1].sequenceId ?? -1) + ..isSeen = true; messages[1] = copy; messages.removeAt(2); final newMessage = source.createMessage(101); @@ -479,10 +537,16 @@ void main() async { expect(source.size, 100); for (int i = 0; i < messages.length; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); } }); @@ -500,8 +564,11 @@ void main() async { expect(source.size, 102); for (int i = 0; i < 20; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); } }); @@ -515,14 +582,18 @@ void main() async { final message = source.createMessage(100 - i); messages.add(message); } - messages.removeAt(1); - messages.removeAt(2); + messages + ..removeAt(1) + ..removeAt(2); await source.resyncMessagesManually(messages); expect(source.size, 98); for (int i = 0; i < 18; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); } }); @@ -541,8 +612,11 @@ void main() async { expect(source.size, 100); for (int i = 0; i < messages.length; i++) { final message = await source.getMessage(i); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); } }); @@ -557,18 +631,25 @@ void main() async { messages.add(message); } messages[2].isAnswered = true; - messages.removeAt(3); - messages.removeAt(7); - messages.insert(0, source.createMessage(101)); - messages.insert(0, source.createMessage(102)); + messages + ..removeAt(3) + ..removeAt(7) + ..insert(0, source.createMessage(101)) + ..insert(0, source.createMessage(102)); await source.resyncMessagesManually(messages); expect(source.size, 100); for (int i = 0; i < messages.length; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); } }); @@ -583,91 +664,112 @@ void main() async { messages.add(message); } messages[2].isAnswered = true; - messages.removeAt(3); - messages.removeAt(7); - messages.insert(0, source.createMessage(101)); + messages + ..removeAt(3) + ..removeAt(7) + ..insert(0, source.createMessage(101)); await source.resyncMessagesManually(messages); expect(source.size, 99); for (int i = 0; i < messages.length; i++) { final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); } }); test( - 'out of cache: 1 message added, 2 removed, 2 changed flags after resync', - () async { - final source = FakeMimeSource(size: 100, maxCacheSize: 20); - expect(source.size, 100); - final seen = await source.getMessage(1); - seen.isSeen = true; - final messages = []; - for (int i = 0; i < 20; i++) { - await source.getMessage(i); - final message = source.createMessage(100 - i); - messages.add(message); - } - // as this is out of cache, simulate changes by also these changes - // to the underlying structure: - messages[2].isAnswered = true; - messages.removeAt(3); - messages.removeAt(7); - messages.insert(0, source.createMessage(101)); - final serverMessages = FakeMimeSource.generateMessages(size: 99); - for (int i = 0; i < messages.length; i++) { - final message = messages[i]; - message.sequenceId = 99 - i; - serverMessages[98 - i] = message; - } - // resync: ensure to remove first message from cache: - await source.getMessage(21); - await source.resyncMessagesManually(messages); - source.messages = serverMessages; - expect(source.size, 99); - - for (int i = 0; i < messages.length; i++) { - final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid GUID ${message.guid} at $i'); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); - } - }); + 'out of cache: 1 message added, 2 removed, 2 changed flags after resync', + () async { + final source = FakeMimeSource(size: 100, maxCacheSize: 20); + expect(source.size, 100); + final seen = await source.getMessage(1); + seen.isSeen = true; + final messages = []; + for (int i = 0; i < 20; i++) { + await source.getMessage(i); + final message = source.createMessage(100 - i); + messages.add(message); + } + // as this is out of cache, simulate changes by also these changes + // to the underlying structure: + messages[2].isAnswered = true; + messages + ..removeAt(3) + ..removeAt(7) + ..insert(0, source.createMessage(101)); + final serverMessages = FakeMimeSource.generateMessages(size: 99); + for (int i = 0; i < messages.length; i++) { + final message = messages[i]..sequenceId = 99 - i; + serverMessages[98 - i] = message; + } + // resync: ensure to remove first message from cache: + await source.getMessage(21); + await source.resyncMessagesManually(messages); + source.messages = serverMessages; + expect(source.size, 99); + + for (int i = 0; i < messages.length; i++) { + final message = await source.getMessage(i); + expect( + message.guid, + messages[i].guid, + reason: 'invalid GUID ${message.guid} at $i', + ); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); + } + }, + ); test( - 'delete 1 message, then 1 message added, 2 removed, 2 changed flags after resync', - () async { - final source = FakeMimeSource(size: 100); - expect(source.size, 100); - final seenMessage = await source.getMessage(1); - seenMessage.isSeen = true; - final deleteMessage = await source.getMessage(2); - source.deleteMessages([deleteMessage]); - expect(source.size, 99); - final firstMessage = await source.getMessage(0); - expect(firstMessage.sequenceId, 99); - final messages = []; - for (int i = 0; i < 20; i++) { - final message = source.createMessage(100 - i); - message.sequenceId = 99 - i; - messages.add(message); - } - messages[2].isAnswered = true; - messages.removeAt(3); - messages.removeAt(7); - messages.insert(0, source.createMessage(101)); - await source.resyncMessagesManually(messages); - expect(source.size, 98); - for (int i = 0; i < messages.length; i++) { - final message = await source.getMessage(i); - expect(message.guid, messages[i].guid, - reason: 'invalid message ${message.guid} at $i'); - expect(message.flags, messages[i].flags, - reason: 'flags differ for message ${message.guid} at $i'); - } - }); + 'delete 1 message, then 1 message added, 2 removed, 2 changed flags after resync', + () async { + final source = FakeMimeSource(size: 100); + expect(source.size, 100); + final seenMessage = await source.getMessage(1); + seenMessage.isSeen = true; + final deleteMessage = await source.getMessage(2); + await source.deleteMessages([deleteMessage]); + expect(source.size, 99); + final firstMessage = await source.getMessage(0); + expect(firstMessage.sequenceId, 99); + final messages = []; + for (int i = 0; i < 20; i++) { + final message = source.createMessage(100 - i)..sequenceId = 99 - i; + messages.add(message); + } + messages[2].isAnswered = true; + messages + ..removeAt(3) + ..removeAt(7) + ..insert(0, source.createMessage(101)); + await source.resyncMessagesManually(messages); + expect(source.size, 98); + for (int i = 0; i < messages.length; i++) { + final message = await source.getMessage(i); + expect( + message.guid, + messages[i].guid, + reason: 'invalid message ${message.guid} at $i', + ); + expect( + message.flags, + messages[i].flags, + reason: 'flags differ for message ${message.guid} at $i', + ); + } + }, + ); }); } diff --git a/test/model/fake_mime_source.dart b/test/model/fake_mime_source.dart index 2f21e5d..fd726a1 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, @@ -36,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( @@ -48,32 +47,50 @@ 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), ), ); } + return messages; } - static MimeMessage _generateMessage(int sequenceId, int size, String name, - DateTime startDate, Duration differencePerMessage) => + static MimeMessage _generateMessage( + int sequenceId, + int size, + String name, + DateTime startDate, + Duration differencePerMessage, + ) => MimeMessage() ..sequenceId = sequenceId ..guid = sequenceId ..uid = sequenceId ..addHeader(MailConventions.headerSubject, '${name}Subject $sequenceId') ..addHeader( - MailConventions.headerDate, - DateCodec.encodeDate(_generateDate( - size - sequenceId, startDate, differencePerMessage))); + MailConventions.headerDate, + DateCodec.encodeDate(_generateDate( + size - sequenceId, + startDate, + differencePerMessage, + )), + ); static DateTime _generateDate( - int index, DateTime startDate, Duration differencePerMessage) => + int index, + DateTime startDate, + Duration differencePerMessage, + ) => startDate.subtract(differencePerMessage * index); MimeMessage createMessage(int sequenceId) => _generateMessage( - sequenceId, size, name, _startDate, _differencePerMessage); + sequenceId, + size, + name, + _startDate, + _differencePerMessage, + ); @override final String name; @@ -85,23 +102,25 @@ 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; } } + return Future.value( DeleteResult( DeleteAction.flag, messages.toSequence(), Mailbox( - encodedName: 'INBOX', - encodedPath: 'INBOX', - flags: [MailboxFlag.inbox], - pathSeparator: '/'), + encodedName: 'INBOX', + encodedPath: 'INBOX', + flags: [MailboxFlag.inbox], + pathSeparator: '/', + ), null, null, mailClient, @@ -117,14 +136,22 @@ class FakeMimeSource extends PagedCachedMimeSource { clear(); final sequence = MessageSequence.fromAll(); final mailbox = Mailbox( - encodedName: 'INBOX', - encodedPath: 'INBOX', - flags: [MailboxFlag.inbox], - pathSeparator: '/'); + encodedName: 'INBOX', + encodedPath: 'INBOX', + flags: [MailboxFlag.inbox], + pathSeparator: '/', + ); + return [ DeleteResult( - DeleteAction.flag, sequence, mailbox, sequence, mailbox, mailClient, - canUndo: false) + DeleteAction.flag, + sequence, + mailbox, + sequence, + mailbox, + mailClient, + canUndo: false, + ), ]; } @@ -132,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 @@ -156,6 +183,7 @@ class FakeMimeSource extends PagedCachedMimeSource { final message = messages[index - 1]; result.add(message); } + return result; } @@ -169,14 +197,12 @@ class FakeMimeSource extends PagedCachedMimeSource { @override Future handleOnMessagesVanished(List removed) async { - for (final msg in removed) { - messages.remove(msg); - } + removed.forEach(messages.remove); } @override AsyncMimeSource search(MailSearch search) { - // TODO: implement search + // TODO(RV): implement search throw UnimplementedError(); } @@ -184,72 +210,110 @@ 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 final MailClient mailClient; @override - Future store(List messages, List flags, - {StoreAction action = StoreAction.add}) { - // TODO: implement store + Future store( + List messages, + List flags, { + StoreAction action = StoreAction.add, + }) { + // TODO(RV): implement store throw UnimplementedError(); } @override - Future storeAll(List flags, - {StoreAction action = StoreAction.add}) { - // TODO: implement storeAll + Future storeAll( + List flags, { + StoreAction action = StoreAction.add, + }) { + // TODO(RV): implement storeAll throw UnimplementedError(); } @override Future undoDeleteMessages(DeleteResult deleteResult) { - // TODO: implement undoDeleteMessages + // TODO(RV): implement undoDeleteMessages throw UnimplementedError(); } @override Future moveMessages( - List messages, Mailbox targetMailbox) { - // TODO: implement moveMessages + List messages, + Mailbox targetMailbox, + ) { + // TODO(RV): implement moveMessages throw UnimplementedError(); } @override Future moveMessagesToFlag( - List messages, MailboxFlag targetMailboxFlag) { - // TODO: implement moveMessagesToFlag + List messages, + MailboxFlag targetMailboxFlag, + ) { + // TODO(RV): implement moveMessagesToFlag throw UnimplementedError(); } @override Future undoMoveMessages(MoveResult moveResult) { - // TODO: implement undoMoveMessages + // TODO(RV): implement undoMoveMessages throw UnimplementedError(); } @override - Future fetchMessageContents(MimeMessage message, - {int? maxSize, - bool markAsSeen = false, - List? includedInlineTypes, - Duration? responseTimeout}) { - // TODO: implement fetchMessageContents + Future fetchMessageContents( + MimeMessage message, { + int? maxSize, + bool markAsSeen = false, + List? includedInlineTypes, + Duration? responseTimeout, + }) { + // TODO(RV): implement fetchMessageContents throw UnimplementedError(); } @override - // TODO: implement isInbox + // TODO(RV): implement isInbox bool get isInbox => throw UnimplementedError(); + + @override + Future fetchMessagePart( + MimeMessage message, { + required String fetchId, + Duration? responseTimeout, + }) { + // TODO(RV): implement fetchMessagePart + throw UnimplementedError(); + } + + @override + Future sendMessage( + MimeMessage message, { + MailAddress? from, + bool appendToSent = true, + Mailbox? sentMailbox, + bool use8BitEncoding = false, + List? recipients, + }) { + // TODO(RV): implement sendMessage + throw UnimplementedError(); + } + + @override + // 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 2f09f73..3976d3b 100644 --- a/test/model/multiple_message_source_test.dart +++ b/test/model/multiple_message_source_test.dart @@ -1,24 +1,34 @@ +// ignore_for_file: lines_longer_than_80_chars + 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'; -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/notification/model.dart'; +import 'package:enough_mail_app/notification/service.dart'; +import 'package:enough_mail_app/scaffold_messenger/service.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'; +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); 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); @@ -40,10 +50,14 @@ 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 { + Future expectMessagesOrderedByDate({int numberToTest = 20}) async { var lastDate = DateTime.now(); var lastSubject = ''; for (int i = 0; i < numberToTest; i++) { @@ -57,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; } } @@ -75,43 +89,45 @@ 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()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); }); 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 { @@ -121,11 +137,16 @@ void main() async { for (int i = 1; i < source.size; i++) { final nextDateTime = (await source.getMessageAt(i)).mimeMessage.decodeDate(); - expect(nextDateTime, isNotNull, - reason: 'decodeDate() is null for message $i'); - expect(nextDateTime?.isBefore(lastDateTime!), isTrue, - reason: - '$nextDateTime should be before $lastDateTime for message $i'); + expect( + nextDateTime, + isNotNull, + reason: 'decodeDate() is null for message $i', + ); + expect( + nextDateTime?.isBefore(lastDateTime ?? DateTime.now()), + isTrue, + reason: '$nextDateTime should be before $lastDateTime for message $i', + ); lastDateTime = nextDateTime; } }); @@ -133,16 +154,16 @@ 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'); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); - await _expectMessagesOrderedByDate(); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); + await expectMessagesOrderedByDate(); }); test('real update - onMessageArrived x 1', () async { @@ -153,20 +174,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: - (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'); - expect(message.mailClient, firstMimeSource.mailClient); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); expect(hasBeenNotified, isTrue); }); @@ -178,35 +200,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: - (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'); - 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 { @@ -217,25 +242,26 @@ 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: - (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'); - 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(); + await expectMessagesOrderedByDate(); }); test('onMessageArrived - once per source ordered by data', () async { @@ -245,25 +271,27 @@ 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); 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()); - await _expectMessagesOrderedByDate(); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); + await expectMessagesOrderedByDate(); }); test('onMessageArrived - once per source out of date order', () async { @@ -273,25 +301,27 @@ 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); 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()); - await _expectMessagesOrderedByDate(); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .add(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); + await expectMessagesOrderedByDate(); }); }); @@ -304,18 +334,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 { @@ -359,19 +391,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 { @@ -465,9 +499,9 @@ void main() async { }); final updatedMime = (secondMimeSource as FakeMimeSource) - .createMessage(firstMime.sequenceId!); - updatedMime.setFlag(MessageFlags.seen, true); - secondMimeSource.onMessageFlagsUpdated(updatedMime); + .createMessage(firstMime.sequenceId ?? 0) + ..setFlag(MessageFlags.seen, true); + await secondMimeSource.onMessageFlagsUpdated(updatedMime); expect(notifyCounter, 1); expect(firstMessage.isSeen, isTrue); }); @@ -486,9 +520,9 @@ void main() async { }); final updatedMime = (secondMimeSource as FakeMimeSource) - .createMessage(firstMime.sequenceId!); - updatedMime.setFlag(MessageFlags.seen, false); - secondMimeSource.onMessageFlagsUpdated(updatedMime); + .createMessage(firstMime.sequenceId ?? 0) + ..setFlag(MessageFlags.seen, false); + await secondMimeSource.onMessageFlagsUpdated(updatedMime); expect(notifyCounter, 1); expect(firstMessage.isSeen, isFalse); }); @@ -534,17 +568,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 = 0; i < 20; i++) { @@ -559,21 +595,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 { @@ -583,17 +620,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++) { @@ -608,24 +647,25 @@ 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(), - secondMimeSourceStartDate - .subtract(secondMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .subtract(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); - await _expectMessagesOrderedByDate(); + await expectMessagesOrderedByDate(); }); test('1 message added flag after resync', () async { @@ -640,8 +680,8 @@ void main() async { messages.add(message); } final copy = (firstMimeSource as FakeMimeSource) - .createMessage(messages[1].sequenceId!); - copy.isSeen = true; + .createMessage(messages[1].sequenceId ?? 0) + ..isSeen = true; messages[1] = copy; var message = await source.getMessageAt(2); @@ -661,7 +701,7 @@ void main() async { expect(message.mimeMessage.sequenceId, copy.sequenceId); expect(message.isSeen, isTrue); - await _expectMessagesOrderedByDate(); + await expectMessagesOrderedByDate(); }); test('1 message removed flag after resync', () async { @@ -677,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); @@ -697,7 +737,7 @@ void main() async { expect(message.mimeMessage.sequenceId, copy.sequenceId); expect(message.isSeen, isFalse); - await _expectMessagesOrderedByDate(); + await expectMessagesOrderedByDate(); }); test('1 message added, 1 removed, 1 changed flags after resync', () async { @@ -707,17 +747,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(), + ); message = await source.getMessageAt(2); expect(message.mimeMessage.decodeSubject(), 'firstSubject 99'); @@ -740,269 +782,301 @@ void main() async { expect(source.size, 120); expect(notifyCounter, 2); 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.guid, + 101, + reason: 'first message should be the 101', + ); expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); expect(message.isSeen, isFalse); 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(), + ); expect(message.isSeen, isFalse); message = await source.getMessageAt(2); expect(message.mimeMessage.decodeSubject(), 'firstSubject 99'); expect(message.isSeen, isTrue); expect( - message.mimeMessage.decodeDate(), - firstMimeSourceStartDate - .subtract(firstMimeSourceDifferencePerMessage) - .toLocal()); + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate + .subtract(firstMimeSourceDifferencePerMessage) + .toLocal(), + ); message = await source.getMessageAt(3); expect(message.mimeMessage.decodeSubject(), 'secondSubject 19'); expect(message.isSeen, isFalse); - await _expectMessagesOrderedByDate(); + await expectMessagesOrderedByDate(); }); - test('1 message added ordered by date on each source after resync', - () async { - var notifyCounter = 0; - source.addListener(() { - notifyCounter++; - }); - 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()); - - var messages = []; - for (int i = 0; i < 20; i++) { - final message = - (firstMimeSource as FakeMimeSource).createMessage(101 - i); - messages.add(message); - } - await firstMimeSource.resyncMessagesManually(messages); - - messages = []; - for (int i = 0; i < 20; i++) { - final message = - (secondMimeSource as FakeMimeSource).createMessage(21 - i); - messages.add(message); - } - await secondMimeSource.resyncMessagesManually(messages); + test( + '1 message added ordered by date on each source after resync', + () async { + var notifyCounter = 0; + source.addListener(() { + notifyCounter++; + }); + var message = await source.getMessageAt(0); + expect(message.mimeMessage.sequenceId, 20); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); - expect(source.size, 122); - 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 = await source.getMessageAt(1); + expect(message.mimeMessage.sequenceId, 100); + expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); + expect( + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); + + var messages = []; + for (int i = 0; i < 20; i++) { + final message = + (firstMimeSource as FakeMimeSource).createMessage(101 - i); + messages.add(message); + } + await firstMimeSource.resyncMessagesManually(messages); + + messages = []; + for (int i = 0; i < 20; i++) { + final message = + (secondMimeSource as FakeMimeSource).createMessage(21 - i); + messages.add(message); + } + await secondMimeSource.resyncMessagesManually(messages); + + expect(source.size, 122); + expect(notifyCounter, 2); + message = await source.getMessageAt(0); + expect(message.mimeMessage.sequenceId, 21); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); + expect( message.mimeMessage.decodeDate(), secondMimeSourceStartDate .add(secondMimeSourceDifferencePerMessage) - .toLocal()); - expect(message.isSeen, isFalse); - - // 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( + .toLocal(), + ); + expect(message.isSeen, isFalse); + + // previous first message should now be at the second position: + message = await source.getMessageAt(1); + expect(message.mimeMessage.sequenceId, 101); + expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); + expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + .toLocal(), + ); - message = await source.getMessageAt(2); - expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); - await _expectMessagesOrderedByDate(); - }); - - test('1 message added unordered by date on each source after resync', - () async { - var notifyCounter = 0; - source.addListener(() { - notifyCounter++; - }); - 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()); - - var messages = []; - for (int i = 0; i < 20; i++) { - final message = - (secondMimeSource as FakeMimeSource).createMessage(21 - i); - messages.add(message); - } - await secondMimeSource.resyncMessagesManually(messages); + message = await source.getMessageAt(2); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); + await expectMessagesOrderedByDate(); + }, + ); - messages = []; - for (int i = 0; i < 20; i++) { - final message = - (firstMimeSource as FakeMimeSource).createMessage(101 - i); - messages.add(message); - } - await firstMimeSource.resyncMessagesManually(messages); + test( + '1 message added unordered by date on each source after resync', + () async { + var notifyCounter = 0; + source.addListener(() { + notifyCounter++; + }); + var message = await source.getMessageAt(0); + expect(message.mimeMessage.sequenceId, 20); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); - expect(source.size, 122); - 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 = await source.getMessageAt(1); + expect(message.mimeMessage.sequenceId, 100); + expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); + expect( + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); + + var messages = []; + for (int i = 0; i < 20; i++) { + final message = + (secondMimeSource as FakeMimeSource).createMessage(21 - i); + messages.add(message); + } + await secondMimeSource.resyncMessagesManually(messages); + + messages = []; + for (int i = 0; i < 20; i++) { + final message = + (firstMimeSource as FakeMimeSource).createMessage(101 - i); + messages.add(message); + } + await firstMimeSource.resyncMessagesManually(messages); + + expect(source.size, 122); + expect(notifyCounter, 2); + message = await source.getMessageAt(0); + expect(message.mimeMessage.sequenceId, 21); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 21'); + expect( message.mimeMessage.decodeDate(), secondMimeSourceStartDate .add(secondMimeSourceDifferencePerMessage) - .toLocal()); - expect(message.isSeen, isFalse); - - // 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( + .toLocal(), + ); + expect(message.isSeen, isFalse); + + // previous first message should now be at the second position: + message = await source.getMessageAt(1); + expect(message.mimeMessage.sequenceId, 101); + expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); + expect( message.mimeMessage.decodeDate(), firstMimeSourceStartDate .add(firstMimeSourceDifferencePerMessage) - .toLocal()); + .toLocal(), + ); - message = await source.getMessageAt(2); - expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - expect(message.mimeMessage.decodeDate(), - secondMimeSourceStartDate.toLocal()); - await _expectMessagesOrderedByDate(); - }); + message = await source.getMessageAt(2); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); + await expectMessagesOrderedByDate(); + }, + ); test( - 'out of cache: 1 message added, 2 removed, 2 changed flags after resync', - () async { - firstMimeSource = FakeMimeSource( - size: 100, - name: 'first', - startDate: firstMimeSourceStartDate, - differencePerMessage: firstMimeSourceDifferencePerMessage, - maxCacheSize: 20, - ); - secondMimeSource = FakeMimeSource( - size: 20, - name: 'second', - startDate: secondMimeSourceStartDate, - differencePerMessage: secondMimeSourceDifferencePerMessage, - ); - source = MultipleMessageSource( - [firstMimeSource, secondMimeSource], 'multiple', MailboxFlag.inbox); - - var notifyCounter = 0; - source.addListener(() { - notifyCounter++; - }); - - // ensure caches are initialized across mime and message sources: - var message = await source.getMessageAt(0); - message.isSeen = true; - expect(message.mimeMessage.sequenceId, 20); - expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); - 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()); - - // create test messages: - final messages = []; - for (int i = 0; i < 20; i++) { - await firstMimeSource.getMessage(i); - final message = - (firstMimeSource as FakeMimeSource).createMessage(100 - i); - messages.add(message); - } - // as this is out of cache, simulate changes by also these changes - // to the underlying structure: - messages[2].isAnswered = true; - messages.removeAt(3); - messages.removeAt(7); - messages.insert( - 0, (firstMimeSource as FakeMimeSource).createMessage(101)); - final serverMessages = FakeMimeSource.generateMessages(size: 99); - for (int i = 0; i < messages.length; i++) { - final message = messages[i]; - message.sequenceId = 99 - i; - serverMessages[98 - i] = message; - } - // resync: ensure to remove first message from cache: - await firstMimeSource.getMessage(21); - await firstMimeSource.resyncMessagesManually(messages); - (firstMimeSource as FakeMimeSource).messages = serverMessages; + 'out of cache: 1 message added, 2 removed, 2 changed flags after resync', + () async { + firstMimeSource = FakeMimeSource( + size: 100, + name: 'first', + startDate: firstMimeSourceStartDate, + differencePerMessage: firstMimeSourceDifferencePerMessage, + maxCacheSize: 20, + ); + secondMimeSource = FakeMimeSource( + size: 20, + name: 'second', + startDate: secondMimeSourceStartDate, + differencePerMessage: secondMimeSourceDifferencePerMessage, + ); + source = MultipleMessageSource( + [firstMimeSource, secondMimeSource], + 'multiple', + MailboxFlag.inbox, + account: MockUnifiedAccount(), + ); + + var notifyCounter = 0; + source.addListener(() { + notifyCounter++; + }); + + // ensure caches are initialized across mime and message sources: + var message = await source.getMessageAt(0); + message.isSeen = true; + expect(message.mimeMessage.sequenceId, 20); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); - expect(source.size, 119); - expect(notifyCounter, 1); - message = await source.getMessageAt(0); - expect(message.mimeMessage.sequenceId, 99); - expect(message.mimeMessage.guid, 101); - expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); - expect(message.isSeen, isFalse); - expect( + message = await source.getMessageAt(1); + expect(message.mimeMessage.sequenceId, 100); + expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); + expect( + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); + + // create test messages: + final messages = []; + for (int i = 0; i < 20; i++) { + await firstMimeSource.getMessage(i); + final message = + (firstMimeSource as FakeMimeSource).createMessage(100 - i); + messages.add(message); + } + // as this is out of cache, simulate changes by also these changes + // to the underlying structure: + messages[2].isAnswered = true; + messages + ..removeAt(3) + ..removeAt(7) + ..insert( + 0, + (firstMimeSource as FakeMimeSource).createMessage(101), + ); + final serverMessages = FakeMimeSource.generateMessages(size: 99); + for (int i = 0; i < messages.length; i++) { + final message = messages[i]..sequenceId = 99 - i; + serverMessages[98 - i] = message; + } + // resync: ensure to remove first message from cache: + await firstMimeSource.getMessage(21); + await firstMimeSource.resyncMessagesManually(messages); + (firstMimeSource as FakeMimeSource).messages = serverMessages; + + expect(source.size, 119); + expect(notifyCounter, 1); + message = await source.getMessageAt(0); + expect(message.mimeMessage.sequenceId, 99); + expect(message.mimeMessage.guid, 101); + expect(message.mimeMessage.decodeSubject(), 'firstSubject 101'); + expect(message.isSeen, isFalse); + expect( 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.isSeen, isTrue); - - message = await source.getMessageAt(2); - expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); - expect(message.isSeen, isFalse); - expect( - message.mimeMessage.decodeDate(), firstMimeSourceStartDate.toLocal()); - await _expectMessagesOrderedByDate(); - }); + .toLocal(), + ); + + // previous first message should now be at the second position: + message = await source.getMessageAt(1); + expect(message.mimeMessage.sequenceId, 20); + expect(message.mimeMessage.decodeSubject(), 'secondSubject 20'); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); + expect(message.isSeen, isTrue); + + message = await source.getMessageAt(2); + expect(message.mimeMessage.decodeSubject(), 'firstSubject 100'); + expect(message.isSeen, isFalse); + expect( + message.mimeMessage.decodeDate(), + firstMimeSourceStartDate.toLocal(), + ); + await expectMessagesOrderedByDate(); + }, + ); }); group('delete', () { @@ -1015,41 +1089,47 @@ 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(), + ); 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(), + ); 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(), + ); 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); @@ -1057,29 +1137,31 @@ 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()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); 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.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(), - secondMimeSourceStartDate - .subtract(secondMimeSourceDifferencePerMessage) - .toLocal()); - await _expectMessagesOrderedByDate(); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .subtract(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); + await expectMessagesOrderedByDate(); }); test('delete 1 message and clear cache', () async { @@ -1091,41 +1173,47 @@ 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(), + ); 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(), + ); 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(), + ); 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); @@ -1134,29 +1222,31 @@ 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()); + expect( + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate.toLocal(), + ); 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.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(), - secondMimeSourceStartDate - .subtract(secondMimeSourceDifferencePerMessage) - .toLocal()); - await _expectMessagesOrderedByDate(); + message.mimeMessage.decodeDate(), + secondMimeSourceStartDate + .subtract(secondMimeSourceDifferencePerMessage) + .toLocal(), + ); + await expectMessagesOrderedByDate(); }); }); } @@ -1164,22 +1254,26 @@ 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(); @override - void showTextSnackBar(String text, {Function()? undo}) { - // TODO: implement showTextSnackBar + void showTextSnackBar( + AppLocalizations localization, + String text, { + Function()? undo, + }) { + // TODO(RV): implement showTextSnackBar } @override set statusBarState(CupertinoStatusBarState state) { - // TODO: implement statusBarState + // TODO(RV): implement statusBarState } } @@ -1200,49 +1294,51 @@ class TestNotificationService implements NotificationService { } @override - void cancelNotificationForMail(MimeMessage mimeMessage) { + void cancelNotificationForMime(MimeMessage mimeMessage) { _cancelledNotifications++; } @override - void cancelNotificationForMailMessage(Message message) { + void cancelNotificationForMessage(Message message) { _cancelledNotifications++; } @override Future> getActiveMailNotifications() { - // TODO: implement getActiveMailNotifications + // TODO(RV): implement getActiveMailNotifications throw UnimplementedError(); } @override - Future init( - {bool checkForLaunchDetails = true}) { - // TODO: implement init + Future init({ + bool checkForLaunchDetails = true, + BuildContext? context, + }) { + // TODO(RV): implement init throw UnimplementedError(); } - @override - Future sendLocalNotification(int id, String title, String? text, - {String? payloadText, DateTime? when, bool channelShowBadge = true}) { - _sendNotifications++; - return Future.value(); - } - @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) { _sendNotifications++; + return Future.value(); } } diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index b2e4bd8..4f2af69 100644 --- a/windows/flutter/CMakeLists.txt +++ b/windows/flutter/CMakeLists.txt @@ -9,6 +9,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -91,7 +96,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 1cfdf69..db2bc4c 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -60,14 +60,14 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // Version // -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0 +#define VERSION_AS_NUMBER 1,0,0,0 #endif -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION #else #define VERSION_AS_STRING "1.0.0" #endif