From d4d8246db93779178586f3caf65a7c58f4e0c1b1 Mon Sep 17 00:00:00 2001 From: Domai Date: Sun, 6 Apr 2025 13:56:27 +0200 Subject: [PATCH 01/20] upgrade AGP --- android/.gitignore | 3 +- android/app/build.gradle | 83 ----------------- android/app/build.gradle.kts | 89 +++++++++++++++++++ android/build.gradle | 18 ---- android/build.gradle.kts | 21 +++++ android/gradle.properties | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 5 +- android/settings.gradle | 26 ------ android/settings.gradle.kts | 26 ++++++ 9 files changed, 142 insertions(+), 131 deletions(-) delete mode 100644 android/app/build.gradle create mode 100644 android/app/build.gradle.kts delete mode 100644 android/build.gradle create mode 100644 android/build.gradle.kts delete mode 100644 android/settings.gradle create mode 100644 android/settings.gradle.kts diff --git a/android/.gitignore b/android/.gitignore index 6f568019..be3943c9 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,9 +5,10 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx/ # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index cbd9ea8f..00000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,83 +0,0 @@ -plugins { - id "com.android.application" - id "kotlin-android" - id "dev.flutter.flutter-gradle-plugin" - id "com.google.gms.google-services" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -android { - compileSdk 34 - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "de.asta_bochum.campus_app" - minSdkVersion 26 - targetSdkVersion 34 - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] - } - } - buildTypes { - release { - signingConfig signingConfigs.release - } - } - namespace 'de.asta_bochum.campus_app' -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.20" - implementation('androidx.appcompat:appcompat:1.6.1') - implementation("androidx.appcompat:appcompat-resources:1.6.1") -} diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..6c31750a --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,89 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") +} + +val localProperties = Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.reader(Charsets.UTF_8).use { load(it) } + } +} + +val flutterVersionCode = localProperties.getProperty("flutter.versionCode") ?: "1" +val flutterVersionName = localProperties.getProperty("flutter.versionName") ?: "1.0" + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) } +} + +android { + compileSdk = 35 + + namespace = "de.asta_bochum.campus_app" + + compileOptions { + // Flag to enable support for the new language APIs + isCoreLibraryDesugaringEnabled = true + // Sets Java compatibility to Java 11 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "de.asta_bochum.campus_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = 26 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + + multiDexEnabled = true + + // Add the Dart define flag for Cronet HTTP without Play Services + applicationVariants.all { + mergedFlavor.manifestPlaceholders["cronetHttpNoPlay"] = "true" + } + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as? String + keyPassword = keystoreProperties["keyPassword"] as? String + storeFile = keystoreProperties["storeFile"]?.toString()?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as? String + } + } + + buildTypes { + getByName("release") { + // TODO: Add your own signing config for the release build. + signingConfig = signingConfigs.getByName("release") + } + } +} + +flutter { + source = "../.." +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.20") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.appcompat:appcompat-resources:1.6.1") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index bc157bd1..00000000 --- a/android/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..89176ef4 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties index 4d3226ab..24863d21 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3a84c269..79eb9d00 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Mon Jul 24 21:37:27 CEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 43f1da8d..00000000 --- a/android/settings.gradle +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.9.20" apply false - id "com.google.gms.google-services" version "4.4.0" apply false -} - -include ":app" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..98512944 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("com.google.gms.google-services") version "4.4.0" apply false +} + +include(":app") From 1568d88bef497394ac1fa71ebe8a0e18e2134001 Mon Sep 17 00:00:00 2001 From: Domai Date: Sun, 6 Apr 2025 13:58:21 +0200 Subject: [PATCH 02/20] upgrade packages --- lib/pages/feed/widgets/feed_item.dart | 10 +- lib/pages/feed/widgets/video_player.dart | 6 +- pubspec.lock | 633 +++++++++++++---------- pubspec.yaml | 199 +++---- 4 files changed, 459 insertions(+), 389 deletions(-) diff --git a/lib/pages/feed/widgets/feed_item.dart b/lib/pages/feed/widgets/feed_item.dart index 904821d9..a90d7015 100644 --- a/lib/pages/feed/widgets/feed_item.dart +++ b/lib/pages/feed/widgets/feed_item.dart @@ -8,7 +8,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; -import 'package:video_thumbnail/video_thumbnail.dart'; +import 'package:get_thumbnail_video/video_thumbnail.dart'; import 'package:campus_app/core/themes.dart'; import 'package:campus_app/pages/calendar/calendar_detail_page.dart'; @@ -113,11 +113,9 @@ class FeedItemState extends State with AutomaticKeepAliveClientMixin { quality: 80, ); - if (file != null) { - setState(() { - videoThumbnailFile = File(file); - }); - } + setState(() { + videoThumbnailFile = File(file.path); + }); } @override diff --git a/lib/pages/feed/widgets/video_player.dart b/lib/pages/feed/widgets/video_player.dart index 99eb4838..1b610904 100644 --- a/lib/pages/feed/widgets/video_player.dart +++ b/lib/pages/feed/widgets/video_player.dart @@ -1,4 +1,4 @@ -import 'package:appinio_video_player/appinio_video_player.dart'; +import 'package:flutter_videoplayer/flutter_videoplayer.dart'; import 'package:provider/provider.dart'; import 'package:campus_app/core/themes.dart'; import 'package:flutter/material.dart'; @@ -30,7 +30,7 @@ class FeedVideoPlayer extends StatefulWidget { class _FeedVideoPlayerState extends State { /// The controller object to handle video player - late CachedVideoPlayerController _videoPlayerController; + late CachedVideoPlayerPlusController _videoPlayerController; late CustomVideoPlayerController _customVideoPlayerController; // Show replay instead of pause / play button @@ -39,7 +39,7 @@ class _FeedVideoPlayerState extends State { @override void initState() { super.initState(); - _videoPlayerController = CachedVideoPlayerController.network(widget.url)..initialize(); + _videoPlayerController = CachedVideoPlayerPlusController.networkUrl(Uri.parse(widget.url))..initialize(); _customVideoPlayerController = CustomVideoPlayerController( context: context, videoPlayerController: _videoPlayerController, diff --git a/pubspec.lock b/pubspec.lock index c06952c0..4268e45d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,31 +5,31 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: f256b0c0ba6c7577c15e2e4e114755640a875e885099367bf6e012b19314c834 + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "72.0.0" + version: "76.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: "5a0296da7ae717ffb7444dee8439ca25ac80e162a345b933aa57f0a4a48dca2c" + sha256: de9ecbb3ddafd446095f7e833c853aff2fa1682b017921fe63a833f9d6f0e422 url: "https://pub.dev" source: hosted - version: "1.3.45" + version: "1.3.54" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: b652861553cd3990d8ed361f7979dc6d7053a9ac8843fa73820ab68ce5410139 + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.7.0" + version: "6.11.0" animations: dependency: "direct main" description: @@ -50,10 +50,10 @@ packages: dependency: "direct main" description: name: app_links - sha256: ae5f9a1b7d40d26178f605414be81ed4260350b4fae8259fe5ca4f89fe70c4af + sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "6.4.0" app_links_linux: dependency: transitive description: @@ -78,102 +78,94 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - appinio_video_player: - dependency: "direct main" - description: - name: appinio_video_player - sha256: "43b5a269d461a8ec37394ee12c6e3461e8348b9127f9928e49b84adb90c5cf86" - url: "https://pub.dev" - source: hosted - version: "1.3.0" appwrite: dependency: "direct main" description: name: appwrite - sha256: "0d12a8aa778e4748de068bcdacda7adca181b888b14c698389b7b03364526a10" + sha256: "20fbe2bf739480dfd0f59c1d1fe635b509bd7d8606e937277d4f50da048f4c51" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "15.0.0" archive: dependency: transitive description: name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + sha256: "7dcbd0f87fe5f61cb28da39a1a8b70dbc106e2fe0516f7836eb7bb2948481a12" url: "https://pub.dev" source: hosted - version: "3.6.1" + version: "4.0.5" args: dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.0.4" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" url: "https://pub.dev" source: hosted - version: "2.4.13" + version: "2.4.15" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" url: "https://pub.dev" source: hosted - version: "7.3.2" + version: "8.0.0" built_collection: dependency: transitive description: @@ -186,10 +178,10 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.9.5" cached_network_image: dependency: "direct main" description: @@ -214,22 +206,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - cached_video_player: + cached_video_player_plus: dependency: transitive description: - name: cached_video_player - sha256: "13c25fc1af3bb239da83d9e965d119463a67a782fd9af3714ed86a1182ded20c" + name: cached_video_player_plus + sha256: "451ee48bdbd28fac3d49b4389929c44d259b1def5be6dab0c5bfd3ae1f05e8b5" url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "3.0.3" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -250,10 +242,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -266,10 +258,10 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" convert: dependency: transitive description: @@ -290,10 +282,10 @@ packages: dependency: transitive description: name: cronet_http - sha256: "3af9c4d57bf07ef4b307e77b22be4ad61bea19ee6ff65e62184863f3a09f1415" + sha256: "0b98ef6d6fee016915276bf1486761cdd1671a5588fe9c9e5183b31bf98ad9f5" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" cross_file: dependency: transitive description: @@ -314,26 +306,26 @@ packages: dependency: transitive description: name: csslib - sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.dev" source: hosted - version: "0.17.3" + version: "1.0.2" cupertino_http: dependency: transitive description: name: cupertino_http - sha256: "7e75c45a27cc13a886ab0a1e4d8570078397057bd612de9d24fe5df0d9387717" + sha256: "5a043ec21fd7f56b24c549fd293a7fc60bba899509246cd0ffc2a91cb78c9be2" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "2.1.0" dart_style: dependency: transitive description: name: dart_style - sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "2.3.8" dartz: dependency: "direct main" description: @@ -346,10 +338,18 @@ packages: dependency: transitive description: name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.2.3" device_info_plus: dependency: transitive description: @@ -362,34 +362,34 @@ packages: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba" + sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" dio: dependency: "direct main" description: name: dio - sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" + sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9" url: "https://pub.dev" source: hosted - version: "5.7.0" + version: "5.8.0+1" dio_cookie_manager: dependency: "direct main" description: name: dio_cookie_manager - sha256: e79498b0f632897ff0c28d6e8178b4bc6e9087412401f618c31fa0904ace050d + sha256: "47cacbf6a783c263bfa7cd7d08101e93127d87760ddb003ba289162f7be0f679" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.2.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" dismissible_page: dependency: "direct main" description: @@ -402,42 +402,42 @@ packages: dependency: "direct main" description: name: envied - sha256: bbff9c76120e4dc5e2e36a46690cf0a26feb65e7765633f4e8d916bcd173a450 + sha256: a4e2b1d0caa479b5d61332ae516518c175a6d09328a35a0bc0a53894cc5d7e4d url: "https://pub.dev" source: hosted - version: "0.5.4+1" + version: "1.1.1" envied_generator: dependency: "direct dev" description: name: envied_generator - sha256: "517b70de08d13dcd40e97b4e5347e216a0b1c75c99e704f3c85c0474a392d14a" + sha256: "894f6c5eb624c60a1ce6f642b6fd7ec68bc3440aa6f1881837aa9acbbeade0c8" url: "https://pub.dev" source: hosted - version: "0.5.4+1" + version: "1.1.1" equatable: dependency: transitive description: name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.7" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" file: dependency: transitive description: @@ -450,50 +450,50 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: e59141ff83e70a9ba571a1f8733c5598cf57e6e68037ab185581d7fc0a436738 + sha256: "017d17d9915670e6117497e640b2859e0b868026ea36bf3a57feb28c3b97debe" url: "https://pub.dev" source: hosted - version: "3.7.0" + version: "3.13.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: e30da58198a6d4b49d5bce4e852f985c32cb10db329ebef9473db2b9f09ce810 + sha256: d7253d255ff10f85cfd2adaba9ac17bae878fa3ba577462451163bd9f1d1f0bf url: "https://pub.dev" source: hosted - version: "5.3.0" + version: "5.4.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: f967a7138f5d2ffb1ce15950e2a382924239eaa521150a8f144af34e68b3b3e5 + sha256: "129a34d1e0fb62e2b488d988a1fc26cc15636357e50944ffee2862efe8929b23" url: "https://pub.dev" source: hosted - version: "2.18.1" + version: "2.22.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: a988c6ab37fa5a6abf2f8087a44b765e058848ace6f3253fb1602d1d44a63747 + sha256: "5f8918848ee0c8eb172fc7698619b2bcd7dda9ade8b93522c6297dd8f9178356" url: "https://pub.dev" source: hosted - version: "15.1.4" + version: "15.2.5" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "66aa477277baf2430904096234dd2095ad2e0248d0bfefc1b11695e68bf1790e" + sha256: "0bbea00680249595fc896e7313a2bd90bd55be6e0abbe8b9a39d81b6b306acb6" url: "https://pub.dev" source: hosted - version: "4.5.47" + version: "4.6.5" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "8b590d8c421dc4f63a28c6b9690a050424c28b99a54886ded4510c0806237130" + sha256: ffb392ce2a7e8439cd0a9a80e3c702194e73c927e5c7b4f0adf6faa00b245b17 url: "https://pub.dev" source: hosted - version: "3.9.3" + version: "3.10.5" fixnum: dependency: transitive description: @@ -527,10 +527,10 @@ packages: dependency: "direct main" description: name: flutter_html - sha256: "02ad69e813ecfc0728a455e4bf892b9379983e050722b1dce00192ee2e41d1ee" + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" url: "https://pub.dev" source: hosted - version: "3.0.0-beta.2" + version: "3.0.0" flutter_inappwebview: dependency: "direct main" description: @@ -551,10 +551,10 @@ packages: dependency: transitive description: name: flutter_inappwebview_internal_annotations - sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" flutter_inappwebview_ios: dependency: transitive description: @@ -599,10 +599,10 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "619817c4b65b322b5104b6bb6dfe6cda62d9729bd7ad4303ecc8b4e690a67a77" + sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c url: "https://pub.dev" source: hosted - version: "0.14.1" + version: "0.14.3" flutter_lints: dependency: "direct dev" description: @@ -615,26 +615,34 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + sha256: d59eeafd6df92174b1d5f68fc9d66634c97ce2e7cfe2293476236547bb19bbbd url: "https://pub.dev" source: hosted - version: "18.0.1" + version: "19.0.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + sha256: "2569b973fc9d1f63a37410a9f7c1c552081226c597190cb359ef5d5762d1631c" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.0.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: f8fc0652a601f83419d623c85723a3e82ad81f92b33eaa9bcc21ea1b94773e6e + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -644,18 +652,18 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: ee5c9bd2b74ea8676442fd4ab876b5d41681df49276488854d6c81a5377c0ef1 + sha256: edb09c35ee9230c4b03f13dd45bb3a276d0801865f0a4650b7e2a3bba61a803a url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.5" flutter_nfc_kit: dependency: "direct main" description: name: flutter_nfc_kit - sha256: fe0b86f4883e4a0ebeb6d1fbbc5a86123b95969a4d225bc5a760acda5e3f42e8 + sha256: "3cc4059626fa672031261512299458dd274de4ccb57a7f0ee0951ddd70a048e5" url: "https://pub.dev" source: hosted - version: "3.5.2" + version: "3.6.0" flutter_onboarding: dependency: "direct main" description: @@ -669,26 +677,26 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0" + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" url: "https://pub.dev" source: hosted - version: "9.2.2" + version: "9.2.4" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b" + sha256: bf7404619d7ab5c0a1151d7c4e802edad8f33535abfbeff2f9e1fe1274e2d705 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" flutter_secure_storage_macos: dependency: transitive description: name: flutter_secure_storage_macos - sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81" + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" flutter_secure_storage_platform_interface: dependency: transitive description: @@ -717,31 +725,40 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "578bd8c508144fdaffd4f77b8ef2d8c523602275cd697cc3db284dbd762ef4ce" + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.0.17" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_videoplayer: + dependency: "direct main" + description: + path: "." + ref: "v2.0" + resolved-ref: "093d3c02f67beb553e586570f599442af18c2bd6" + url: "https://github.com/astarub/flutter_videoplayer" + source: git + version: "0.0.1" flutter_web_auth_2: - dependency: transitive + dependency: "direct overridden" description: name: flutter_web_auth_2 - sha256: "4d3d2fd3d26bf1a26b3beafd4b4b899c0ffe10dc99af25abc58ffe24e991133c" + sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" flutter_web_auth_2_platform_interface: dependency: transitive description: name: flutter_web_auth_2_platform_interface - sha256: e8669e262005a8354389ba2971f0fc1c36188481234ff50d013aaf993f30f739 + sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.1.0" flutter_web_plugins: dependency: transitive description: flutter @@ -751,10 +768,10 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: "95f349437aeebe524ef7d6c9bde3e6b4772717cf46a0eb6a3ceaddc740b297cc" + sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1" url: "https://pub.dev" source: hosted - version: "8.2.8" + version: "8.2.12" frontend_server_client: dependency: transitive description: @@ -763,30 +780,54 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + get: + dependency: transitive + description: + name: get + sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 + url: "https://pub.dev" + source: hosted + version: "4.7.2" get_it: dependency: "direct main" description: name: get_it - sha256: c49895c1ecb0ee2a0ec568d39de882e2c299ba26355aa6744ab1001f98cebd15 + sha256: f126a3e286b7f5b578bf436d5592968706c4c1de28a228b870ce375d9f743103 url: "https://pub.dev" source: hosted - version: "8.0.2" + version: "8.0.3" + get_storage: + dependency: transitive + description: + name: get_storage + sha256: "39db1fffe779d0c22b3a744376e86febe4ade43bf65e06eab5af707dc84185a2" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + get_thumbnail_video: + dependency: "direct main" + description: + name: get_thumbnail_video + sha256: ff61495b42051765d2a9e93bd14dac7ede5853033837bde71c27575a192c53fc + url: "https://pub.dev" + source: hosted + version: "0.7.3" glob: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" graphs: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" gtk: dependency: transitive description: @@ -823,34 +864,34 @@ packages: dependency: "direct main" description: name: html - sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" url: "https://pub.dev" source: hosted - version: "0.15.4" + version: "0.15.5" http: dependency: "direct main" description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.3.0" http_multi_server: dependency: transitive description: name: http_multi_server - sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "4.1.2" http_profile: dependency: transitive description: @@ -863,10 +904,10 @@ packages: dependency: "direct main" description: name: image - sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.5.4" intl: dependency: "direct main" description: @@ -879,26 +920,26 @@ packages: dependency: transitive description: name: io - sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" jni: dependency: transitive description: name: jni - sha256: f377c585ea9c08d48b427dc2e03780af2889d1bb094440da853c6883c1acba4b + sha256: "459727a9daf91bdfb39b014cf3c186cf77f0136124a274ac83c186e12262ac4e" url: "https://pub.dev" source: hosted - version: "0.10.1" + version: "0.12.2" js: dependency: "direct overridden" description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2" json_annotation: dependency: transitive description: @@ -911,18 +952,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -935,10 +976,10 @@ packages: dependency: transitive description: name: lints - sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.1" list_counter: dependency: transitive description: @@ -959,26 +1000,26 @@ packages: dependency: "direct main" description: name: lottie - sha256: "7afc60865a2429d994144f7d66ced2ae4305fe35d82890b8766e3359872d872c" + sha256: c5fa04a80a620066c15cf19cc44773e19e9b38e989ff23ea32e5903ef1015950 url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.3.1" macros: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -991,10 +1032,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1007,26 +1048,26 @@ packages: dependency: "direct dev" description: name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + sha256: f99d8d072e249f719a5531735d146d8cf04c580d93920b04de75bef6dfb2daf6 url: "https://pub.dev" source: hosted - version: "5.4.4" + version: "5.4.5" native_dio_adapter: dependency: "direct main" description: name: native_dio_adapter - sha256: "4c925ba15a44478be0eb6e97b62a1c1d07e56b28e566283dbcb15e58418bdaae" + sha256: "7420bc9517b2abe09810199a19924617b45690a44ecfb0616ac9babc11875c03" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" ndef: dependency: transitive description: name: ndef - sha256: "634d2b5c6f2c186e953218bac9905f3f5e1824b15e30bd1ed6e03a91cdbc7293" + sha256: "5083507cff4bb823b2a198a27ea2c70c4d6bc27a97b66097d966a250e1615d54" url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.3.4" nested: dependency: transitive description: @@ -1035,6 +1076,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "49e569fac1202d7fee1655fbbdfbf228840e11416be592bce2a6797b23de8231" + url: "https://pub.dev" + source: hosted + version: "7.0.0" octo_image: dependency: transitive description: @@ -1047,42 +1096,42 @@ packages: dependency: transitive description: name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" package_info_plus: dependency: transitive description: name: package_info_plus - sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce + sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191" url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "8.3.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: ac1f4a4847f1ade8e6a87d1f39f5d7c67490738642e2542f559ec38c37489a66 + sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.2.0" page_transition: dependency: "direct main" description: name: page_transition - sha256: dee976b1f23de9bbef5cd512fe567e9f6278caee11f5eaca9a2115c19dc49ef6 + sha256: "9d2a780d7d68b53ae82fbcc43e06a16195e6775e9aae40e55dc0cbb593460f9d" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.1" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_parsing: dependency: transitive description: @@ -1103,18 +1152,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: c464428172cb986b758c6d1724c603097febb8fb855aa265aeecc9280c294d4a + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" url: "https://pub.dev" source: hosted - version: "2.2.12" + version: "2.2.16" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" path_provider_linux: dependency: transitive description: @@ -1143,10 +1192,10 @@ packages: dependency: transitive description: name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.1.0" platform: dependency: transitive description: @@ -1171,30 +1220,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + url: "https://pub.dev" + source: hosted + version: "6.0.1" provider: dependency: "direct main" description: name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + sha256: "489024f942069c2920c844ee18bb3d467c69e48955a4f32d1677f71be103e310" url: "https://pub.dev" source: hosted - version: "6.1.2" + version: "6.1.4" pub_semver: dependency: transitive description: name: pub_semver - sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.5.0" recase: dependency: transitive description: @@ -1215,103 +1272,103 @@ packages: dependency: "direct main" description: name: screen_brightness - sha256: a43fdbccd5b90044f68057412dde7715cd7499a4c24f5d5da7e01ed4cf41e0af + sha256: eca7bd9d2c3c688bcad14855361cab7097839400b6b4a56f62b7ae511c709958 url: "https://pub.dev" source: hosted - version: "2.0.0+2" + version: "2.1.2" screen_brightness_android: dependency: transitive description: name: screen_brightness_android - sha256: "74455f9901ab8a1a45c9097b83855dbbb7498110cc2bc249cb5a86570dd1cf7c" + sha256: "6ba1b5812f66c64e9e4892be2d36ecd34210f4e0da8bdec6a2ea34f1aa42683e" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" screen_brightness_ios: dependency: transitive description: name: screen_brightness_ios - sha256: caee02b34e0089b138a7aee35c461bd2d7c78446dd417f07613def192598ca08 + sha256: bfd9bfd0ac852e7aa170e7e356cc27195b2a75037b72c8c6336cf6fb2115cffb url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" screen_brightness_macos: dependency: transitive description: name: screen_brightness_macos - sha256: "84fc8ffcbcf19c03d76b7673b0f2c2a2663c09aa2bc37c76ea83ab049294a97a" + sha256: "4edf330ad21078686d8bfaf89413325fbaf571dcebe1e89254d675a3f288b5b9" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.1" screen_brightness_platform_interface: dependency: transitive description: name: screen_brightness_platform_interface - sha256: "321e9455b0057e3647fd37700931e063739d94a8aa1b094f98133c01cb56c27b" + sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" screen_brightness_windows: dependency: transitive description: name: screen_brightness_windows - sha256: fa97ae838c42f762f04d2d70adb3d957350d6a84e3598ec800e269e7c466eedd + sha256: d3518bf0f5d7a884cee2c14449ae0b36803802866de09f7ef74077874b6b2448 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" sentry: - dependency: transitive + dependency: "direct main" description: name: sentry - sha256: "2440763ae96fa8fd1bcdfc224f5232e1b7a09af76a72f4e626ee313a261faf6f" + sha256: "077b03f9ee44cfb1eaadbf8af58255e670de62b3f240ca154ce96a5591dc3885" url: "https://pub.dev" source: hosted - version: "8.10.1" + version: "8.14.1" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: "3b30038b3b9303540a8b2c8b1c8f0bb93a207f8e4b25691c59d969ddeb4734fd" + sha256: a348e2a365a8ad7682dd09db54f50f19f1c87180b8278f088bc393c511aea5e0 url: "https://pub.dev" source: hosted - version: "8.10.1" + version: "8.14.1" share_plus: dependency: "direct main" description: name: share_plus - sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8" + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da url: "https://pub.dev" source: hosted - version: "10.1.2" + version: "10.1.4" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48 + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b url: "https://pub.dev" source: hosted - version: "5.0.1" + version: "5.0.2" shelf: dependency: transitive description: name: shelf - sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" slugid: dependency: "direct main" description: @@ -1324,10 +1381,10 @@ packages: dependency: transitive description: name: smooth_page_indicator - sha256: "3b28b0c545fa67ed9e5997d9f9720d486f54c0c607e056a1094544e36934dff3" + sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c url: "https://pub.dev" source: hosted - version: "1.2.0+3" + version: "1.2.1" snapping_sheet_2: dependency: "direct main" description: @@ -1348,18 +1405,18 @@ packages: dependency: transitive description: name: source_helper - sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" url: "https://pub.dev" source: hosted - version: "1.3.4" + version: "1.3.5" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" sprintf: dependency: transitive description: @@ -1372,34 +1429,34 @@ packages: dependency: transitive description: name: sqflite - sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "4468b24876d673418a7b7147e5a08a715b4998a7ae69227acafaab762e0e5490" + sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" url: "https://pub.dev" source: hosted - version: "2.5.4+5" + version: "2.5.5" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474" + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" sqflite_platform_interface: dependency: transitive description: @@ -1412,58 +1469,58 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: name: stream_transform - sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" url: "https://pub.dev" source: hosted - version: "3.3.0+3" + version: "3.3.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.4" timezone: dependency: transitive description: @@ -1476,10 +1533,10 @@ packages: dependency: transitive description: name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" typed_data: dependency: transitive description: @@ -1508,18 +1565,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" + sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" url: "https://pub.dev" source: hosted - version: "6.3.14" + version: "6.3.15" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: e43b677296fadce447e987a2f519dcf5f6d1e527dc35d01ffab4fff5b8a7063e + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.3" url_launcher_linux: dependency: transitive description: @@ -1532,10 +1589,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_platform_interface: dependency: transitive description: @@ -1548,18 +1605,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" uuid: dependency: transitive description: @@ -1572,26 +1629,26 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "773c9522d66d523e1c7b25dfb95cc91c26a1e17b107039cfe147285e92de7878" + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" url: "https://pub.dev" source: hosted - version: "1.1.14" + version: "1.1.18" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec - sha256: "2430b973a4ca3c4dbc9999b62b8c719a160100dcbae5c819bae0cacce32c9cdb" + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" url: "https://pub.dev" source: hosted - version: "1.1.12" + version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: ab9ff38fc771e9ee1139320adbe3d18a60327370c218c60752068ebee4b49ab1 + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" url: "https://pub.dev" source: hosted - version: "1.1.15" + version: "1.1.16" vector_math: dependency: transitive description: @@ -1600,30 +1657,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: ae7d4f1b41e3ac6d24dd9b9d5d6831b52d74a61bdd90a7a6262a33d8bb97c29a + url: "https://pub.dev" + source: hosted + version: "2.8.2" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "84b4752745eeccb6e75865c9aab39b3d28eb27ba5726d352d45db8297fbd75bc" + url: "https://pub.dev" + source: hosted + version: "2.7.0" video_player_platform_interface: dependency: transitive description: name: video_player_platform_interface - sha256: "318a6d20577e1c78cf0bf40670883cc571ea860c72a4f7426d7dacce4bdd4343" + sha256: df534476c341ab2c6a835078066fc681b8265048addd853a1e3c78740316a844 url: "https://pub.dev" source: hosted - version: "5.1.4" + version: "6.3.0" video_player_web: dependency: transitive description: name: video_player_web - sha256: fb3bbeaf0302cb0c31340ebd6075487939aa1fe3b379d1a8784ef852b679940e - url: "https://pub.dev" - source: hosted - version: "2.0.15" - video_thumbnail: - dependency: "direct main" - description: - name: video_thumbnail - sha256: "3455c189d3f0bb4e3fc2236475aa84fe598b9b2d0e08f43b9761f5bc44210016" + sha256: "3ef40ea6d72434edbfdba4624b90fd3a80a0740d260667d91e7ecd2d79e13476" url: "https://pub.dev" source: hosted - version: "0.5.3" + version: "2.3.4" visibility_detector: dependency: "direct main" description: @@ -1636,26 +1701,26 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.3.1" watcher: dependency: transitive description: name: watcher - sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web: dependency: transitive description: name: web - sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" web_socket: dependency: transitive description: @@ -1668,18 +1733,18 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" win32: dependency: transitive description: name: win32 - sha256: "84ba388638ed7a8cb3445a320c8273136ab2631cd5f2c57888335504ddab1bc2" + sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f url: "https://pub.dev" source: hosted - version: "5.8.0" + version: "5.12.0" win32_registry: dependency: transitive description: @@ -1716,10 +1781,10 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" + dart: ">=3.7.2 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index 64cc53b1..beda45a3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,77 +1,84 @@ name: campus_app description: Simplifie, improve and facilitate everyday students life. -publish_to: 'none' -version: 2.3.4 +publish_to: "none" +version: 2.3.2 environment: - sdk: ">=3.5.0 <4.0.0" + sdk: ">=3.2.0 <4.0.0" dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - envied: ^0.5.4+1 - flutter_native_splash: ^2.4.2 + envied: ^1.1.1 + flutter_native_splash: ^2.4.0 flutter_html: ^3.0.0-alpha.5 flutter_secure_storage: ^9.2.2 provider: ^6.1.1 - flutter_svg: ^2.0.14 + flutter_svg: ^2.0.9 xml: ^6.3.0 animations: ^2.0.8 page_transition: ^2.1.0 - cached_network_image: ^3.4.1 + cached_network_image: ^3.3.1 cookie_jar: ^4.0.8 dartz: ^0.10.1 - dio: ^5.7.0 + dio: ^5.4.3+1 dio_cookie_manager: ^3.1.1 native_dio_adapter: ^1.3.0 - get_it: ^8.0.2 + get_it: ^8.0.3 html: ^0.15.4 - http: ^1.2.2 + http: ^1.2.1 intl: ^0.19.0 - path_provider: ^2.1.5 + path_provider: ^2.1.2 snapping_sheet_2: ^3.1.5 flutter_displaymode: ^0.6.0 hive: ^2.2.3 hive_flutter: ^1.1.0 hive_generator: ^2.0.1 - url_launcher: ^6.3.1 + url_launcher: ^6.2.3 flutter_inappwebview: ^6.1.5 - firebase_core: ^3.7.0 - firebase_messaging: ^15.1.4 - flutter_local_notifications: ^18.0.1 - share_plus: ^10.1.2 + firebase_core: ^3.12.1 + firebase_messaging: ^15.2.4 + flutter_local_notifications: ^19.0.0 + share_plus: ^10.1.4 flutter_onboarding: git: url: https://github.com/C4s4r/flutter_onboarding ref: 9d74464 flutter_nfc_kit: ^3.5.2 - lottie: ^3.1.3 - screen_brightness: ^2.0.0+2 - fluttertoast: ^8.2.8 - appwrite: ^13.0.0 + lottie: ^3.0.0 + screen_brightness: ^2.1.2 + fluttertoast: ^8.2.5 + appwrite: ^15.0.0 slugid: ^1.1.2 visibility_detector: ^0.4.0+2 - appinio_video_player: ^1.2.2 - sentry_flutter: ^8.10.1 - video_thumbnail: ^0.5.3 - image: ^4.3.0 + flutter_videoplayer: + git: + url: https://github.com/astarub/flutter_videoplayer + ref: v2.0 + sentry_flutter: ^8.2.0 + sentry: ^8.2.0 + get_thumbnail_video: ^0.7.3 + image: ^4.2.0 dismissible_page: ^1.0.2 - app_links: ^6.1.4 - + app_links: ^6.4.0 + dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.13 + build_runner: ^2.4.10 flutter_lints: ^5.0.0 mockito: ^5.4.3 - envied_generator: ^0.5.3 - flutter_launcher_icons: ^0.14.1 + envied_generator: ^1.1.1 + flutter_launcher_icons: ^0.14.3 -# Override js version as flutter_inappwebview depends on flutter_inappwebview_web which uses js version ^0.6.4 but as the web package is not used, this is risk-free dependency_overrides: - js: ^0.7.1 + # AppWrite depends on outdated package. + # - https://github.com/appwrite/sdk-for-flutter/issues/214#issuecomment-2278577156 + flutter_web_auth_2: ^4.1.0 + # Override js version as flutter_inappwebview depends on flutter_inappwebview_web which uses js version ^0.6.4 but as the web package is not used, this is risk-free + js: ^0.7.2 flutter_native_splash: image: "assets/img/Android-RUB_Campus_App_Logo.png" @@ -102,70 +109,70 @@ flutter_icons: flutter: assets: - - assets/img/SplashScreen-logo.png - - assets/img/SplashScreen-AStA-branding.png - - assets/img/asta_logo.png - - assets/img/mensa.png - - assets/img/qwest.png - - assets/img/rotebeete.png - - assets/img/asta-gaming-hub.png - - assets/img/icons/hochschulsport_icon.png - - assets/img/icons/home-outlined.png - - assets/img/icons/home-filled.png - - assets/img/icons/calendar-outlined.png - - assets/img/icons/calendar-filled.png - - assets/img/icons/mensa-outlined.png - - assets/img/icons/mensa-filled.png - - assets/img/icons/help-outlined.png - - assets/img/icons/help-filled.png - - assets/img/icons/wallet-outlined.png - - assets/img/icons/wallet-filled.png - - assets/img/icons/vote.svg - - assets/img/icons/error.svg - - assets/img/icons/more.png - - assets/img/icons/settings.svg - - assets/img/icons/info.svg - - assets/img/icons/instagram.svg - - assets/img/icons/facebook.svg - - assets/img/icons/twitch.svg - - assets/img/icons/website.svg - - assets/img/icons/mail.svg - - assets/img/icons/search.svg - - assets/img/icons/filter.svg - - assets/img/icons/arrow-left.svg - - assets/img/icons/arrow-right.svg - - assets/img/icons/info-message.svg - - assets/img/icons/x.svg - - assets/img/icons/map-outlined.png - - assets/img/icons/map-filled.png - - assets/img/icons/mensa-alcohol.png - - assets/img/icons/mensa-beef.png - - assets/img/icons/mensa-chicken.png - - assets/img/icons/mensa-fish.png - - assets/img/icons/mensa-halal.png - - assets/img/icons/mensa-lamm.png - - assets/img/icons/mensa-pork.png - - assets/img/icons/mensa-vegan.png - - assets/img/icons/mensa-vegetarian.png - - assets/img/icons/mensa-venison.png - - assets/img/icons/chevron-right.svg - - assets/img/icons/external-link.svg - - assets/img/icons/mail-link.png - - assets/img/icons/moodle-link.png - - assets/img/icons/flexnow-link.png - - assets/img/icons/rub-link.png - - assets/img/icons/siren.svg - - assets/img/icons/message-square.svg - - assets/img/icons/share.svg - - assets/img/icons/discord-filled.svg - - assets/img/icons/github.svg - - assets/img/icons/help-circle.svg - - assets/img/icons/euro.svg - - assets/animations/nfc-light.json - - assets/animations/nfc-dark.json - - assets/animations/coin-flip.json - - assets/img/bogestra-logo.svg - - assets/img/icons/file-plus.svg + - assets/img/SplashScreen-logo.png + - assets/img/SplashScreen-AStA-branding.png + - assets/img/asta_logo.png + - assets/img/mensa.png + - assets/img/qwest.png + - assets/img/rotebeete.png + - assets/img/asta-gaming-hub.png + - assets/img/icons/hochschulsport_icon.png + - assets/img/icons/home-outlined.png + - assets/img/icons/home-filled.png + - assets/img/icons/calendar-outlined.png + - assets/img/icons/calendar-filled.png + - assets/img/icons/mensa-outlined.png + - assets/img/icons/mensa-filled.png + - assets/img/icons/help-outlined.png + - assets/img/icons/help-filled.png + - assets/img/icons/wallet-outlined.png + - assets/img/icons/wallet-filled.png + - assets/img/icons/vote.svg + - assets/img/icons/error.svg + - assets/img/icons/more.png + - assets/img/icons/settings.svg + - assets/img/icons/info.svg + - assets/img/icons/instagram.svg + - assets/img/icons/facebook.svg + - assets/img/icons/twitch.svg + - assets/img/icons/website.svg + - assets/img/icons/mail.svg + - assets/img/icons/search.svg + - assets/img/icons/filter.svg + - assets/img/icons/arrow-left.svg + - assets/img/icons/arrow-right.svg + - assets/img/icons/info-message.svg + - assets/img/icons/x.svg + - assets/img/icons/map-outlined.png + - assets/img/icons/map-filled.png + - assets/img/icons/mensa-alcohol.png + - assets/img/icons/mensa-beef.png + - assets/img/icons/mensa-chicken.png + - assets/img/icons/mensa-fish.png + - assets/img/icons/mensa-halal.png + - assets/img/icons/mensa-lamm.png + - assets/img/icons/mensa-pork.png + - assets/img/icons/mensa-vegan.png + - assets/img/icons/mensa-vegetarian.png + - assets/img/icons/mensa-venison.png + - assets/img/icons/chevron-right.svg + - assets/img/icons/external-link.svg + - assets/img/icons/mail-link.png + - assets/img/icons/moodle-link.png + - assets/img/icons/flexnow-link.png + - assets/img/icons/rub-link.png + - assets/img/icons/siren.svg + - assets/img/icons/message-square.svg + - assets/img/icons/share.svg + - assets/img/icons/discord-filled.svg + - assets/img/icons/github.svg + - assets/img/icons/help-circle.svg + - assets/img/icons/euro.svg + - assets/animations/nfc-light.json + - assets/animations/nfc-dark.json + - assets/animations/coin-flip.json + - assets/img/bogestra-logo.svg + - assets/img/icons/file-plus.svg fonts: - family: SF-Pro @@ -186,4 +193,4 @@ flutter: weight: 900 uses-material-design: true - generate: true \ No newline at end of file + generate: true From f7d8e15be53991fcee9754be8e8f319243183fca Mon Sep 17 00:00:00 2001 From: Domai Date: Sun, 6 Apr 2025 13:58:32 +0200 Subject: [PATCH 03/20] regenerate mocks --- .../calendar_datasource_test.mocks.dart | 56 ++++++++++++++++++- .../calendar_repository_test.mocks.dart | 3 +- .../calendar_usecases_test.mocks.dart | 3 +- .../mensa/mensa_datasource_test.mocks.dart | 56 ++++++++++++++++++- .../mensa/mensa_repository_test.mocks.dart | 3 +- .../mensa/mensa_usecases_test.mocks.dart | 3 +- .../news/news_repository_test.mocks.dart | 3 +- test/pages/news/news_usecases_test.mocks.dart | 3 +- .../news/rubnews_datasource_test.mocks.dart | 56 ++++++++++++++++++- 9 files changed, 171 insertions(+), 15 deletions(-) diff --git a/test/pages/calendar/calendar_datasource_test.mocks.dart b/test/pages/calendar/calendar_datasource_test.mocks.dart index 1f4fda77..ffb2175c 100644 --- a/test/pages/calendar/calendar_datasource_test.mocks.dart +++ b/test/pages/calendar/calendar_datasource_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/calendar/calendar_datasource_test.dart. // Do not manually edit this file. @@ -24,6 +24,7 @@ import 'package:mockito/src/dummies.dart' as _i11; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -80,6 +81,16 @@ class _FakeResponse_4 extends _i1.SmartFake implements _i6.Response { ); } +class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio { + _FakeDio_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [Dio]. /// /// See the documentation for Mockito's code generation for more information. @@ -602,7 +613,8 @@ class MockDio extends _i1.Mock implements _i7.Dio { Map? queryParameters, _i9.CancelToken? cancelToken, bool? deleteOnError = true, - String? lengthHeader = r'content-length', + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', Object? data, _i2.Options? options, }) => @@ -618,6 +630,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #queryParameters: queryParameters, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -637,6 +650,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #queryParameters: queryParameters, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -652,7 +666,8 @@ class MockDio extends _i1.Mock implements _i7.Dio { _i2.ProgressCallback? onReceiveProgress, _i9.CancelToken? cancelToken, bool? deleteOnError = true, - String? lengthHeader = r'content-length', + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', Object? data, _i2.Options? options, }) => @@ -667,6 +682,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #onReceiveProgress: onReceiveProgress, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -685,6 +701,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #onReceiveProgress: onReceiveProgress, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -785,6 +802,39 @@ class MockDio extends _i1.Mock implements _i7.Dio { ), )), ) as _i8.Future<_i6.Response>); + + @override + _i7.Dio clone({ + _i2.BaseOptions? options, + _i5.Interceptors? interceptors, + _i3.HttpClientAdapter? httpClientAdapter, + _i4.Transformer? transformer, + }) => + (super.noSuchMethod( + Invocation.method( + #clone, + [], + { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }, + ), + returnValue: _FakeDio_5( + this, + Invocation.method( + #clone, + [], + { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }, + ), + ), + ) as _i7.Dio); } /// A class which mocks [Box]. diff --git a/test/pages/calendar/calendar_repository_test.mocks.dart b/test/pages/calendar/calendar_repository_test.mocks.dart index 140ff6dd..f2b775e3 100644 --- a/test/pages/calendar/calendar_repository_test.mocks.dart +++ b/test/pages/calendar/calendar_repository_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/calendar/calendar_repository_test.dart. // Do not manually edit this file. @@ -19,6 +19,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/test/pages/calendar/calendar_usecases_test.mocks.dart b/test/pages/calendar/calendar_usecases_test.mocks.dart index 36aef855..72498722 100644 --- a/test/pages/calendar/calendar_usecases_test.mocks.dart +++ b/test/pages/calendar/calendar_usecases_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/calendar/calendar_usecases_test.dart. // Do not manually edit this file. @@ -20,6 +20,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/test/pages/mensa/mensa_datasource_test.mocks.dart b/test/pages/mensa/mensa_datasource_test.mocks.dart index 96157f6b..aee62f40 100644 --- a/test/pages/mensa/mensa_datasource_test.mocks.dart +++ b/test/pages/mensa/mensa_datasource_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/mensa/mensa_datasource_test.dart. // Do not manually edit this file. @@ -24,6 +24,7 @@ import 'package:mockito/src/dummies.dart' as _i11; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -80,6 +81,16 @@ class _FakeResponse_4 extends _i1.SmartFake implements _i6.Response { ); } +class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio { + _FakeDio_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [Dio]. /// /// See the documentation for Mockito's code generation for more information. @@ -602,7 +613,8 @@ class MockDio extends _i1.Mock implements _i7.Dio { Map? queryParameters, _i9.CancelToken? cancelToken, bool? deleteOnError = true, - String? lengthHeader = r'content-length', + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', Object? data, _i2.Options? options, }) => @@ -618,6 +630,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #queryParameters: queryParameters, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -637,6 +650,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #queryParameters: queryParameters, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -652,7 +666,8 @@ class MockDio extends _i1.Mock implements _i7.Dio { _i2.ProgressCallback? onReceiveProgress, _i9.CancelToken? cancelToken, bool? deleteOnError = true, - String? lengthHeader = r'content-length', + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', Object? data, _i2.Options? options, }) => @@ -667,6 +682,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #onReceiveProgress: onReceiveProgress, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -685,6 +701,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #onReceiveProgress: onReceiveProgress, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -785,6 +802,39 @@ class MockDio extends _i1.Mock implements _i7.Dio { ), )), ) as _i8.Future<_i6.Response>); + + @override + _i7.Dio clone({ + _i2.BaseOptions? options, + _i5.Interceptors? interceptors, + _i3.HttpClientAdapter? httpClientAdapter, + _i4.Transformer? transformer, + }) => + (super.noSuchMethod( + Invocation.method( + #clone, + [], + { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }, + ), + returnValue: _FakeDio_5( + this, + Invocation.method( + #clone, + [], + { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }, + ), + ), + ) as _i7.Dio); } /// A class which mocks [Box]. diff --git a/test/pages/mensa/mensa_repository_test.mocks.dart b/test/pages/mensa/mensa_repository_test.mocks.dart index aebb00e1..4ebed507 100644 --- a/test/pages/mensa/mensa_repository_test.mocks.dart +++ b/test/pages/mensa/mensa_repository_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/mensa/mensa_repository_test.dart. // Do not manually edit this file. @@ -19,6 +19,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/test/pages/mensa/mensa_usecases_test.mocks.dart b/test/pages/mensa/mensa_usecases_test.mocks.dart index 2eaae348..786f9899 100644 --- a/test/pages/mensa/mensa_usecases_test.mocks.dart +++ b/test/pages/mensa/mensa_usecases_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/mensa/mensa_usecases_test.dart. // Do not manually edit this file. @@ -20,6 +20,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/test/pages/news/news_repository_test.mocks.dart b/test/pages/news/news_repository_test.mocks.dart index ff0c5c8c..214c0911 100644 --- a/test/pages/news/news_repository_test.mocks.dart +++ b/test/pages/news/news_repository_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/news/news_repository_test.dart. // Do not manually edit this file. @@ -20,6 +20,7 @@ import 'package:xml/xml.dart' as _i4; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/test/pages/news/news_usecases_test.mocks.dart b/test/pages/news/news_usecases_test.mocks.dart index cf7fbbed..c2157f72 100644 --- a/test/pages/news/news_usecases_test.mocks.dart +++ b/test/pages/news/news_usecases_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/news/news_usecases_test.dart. // Do not manually edit this file. @@ -20,6 +20,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types diff --git a/test/pages/news/rubnews_datasource_test.mocks.dart b/test/pages/news/rubnews_datasource_test.mocks.dart index d852a4fd..62147a8a 100644 --- a/test/pages/news/rubnews_datasource_test.mocks.dart +++ b/test/pages/news/rubnews_datasource_test.mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in campus_app/test/pages/news/rubnews_datasource_test.dart. // Do not manually edit this file. @@ -24,6 +24,7 @@ import 'package:mockito/src/dummies.dart' as _i11; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -80,6 +81,16 @@ class _FakeResponse_4 extends _i1.SmartFake implements _i6.Response { ); } +class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio { + _FakeDio_5( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [Dio]. /// /// See the documentation for Mockito's code generation for more information. @@ -602,7 +613,8 @@ class MockDio extends _i1.Mock implements _i7.Dio { Map? queryParameters, _i9.CancelToken? cancelToken, bool? deleteOnError = true, - String? lengthHeader = r'content-length', + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', Object? data, _i2.Options? options, }) => @@ -618,6 +630,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #queryParameters: queryParameters, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -637,6 +650,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #queryParameters: queryParameters, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -652,7 +666,8 @@ class MockDio extends _i1.Mock implements _i7.Dio { _i2.ProgressCallback? onReceiveProgress, _i9.CancelToken? cancelToken, bool? deleteOnError = true, - String? lengthHeader = r'content-length', + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', Object? data, _i2.Options? options, }) => @@ -667,6 +682,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #onReceiveProgress: onReceiveProgress, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -685,6 +701,7 @@ class MockDio extends _i1.Mock implements _i7.Dio { #onReceiveProgress: onReceiveProgress, #cancelToken: cancelToken, #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, #lengthHeader: lengthHeader, #data: data, #options: options, @@ -785,6 +802,39 @@ class MockDio extends _i1.Mock implements _i7.Dio { ), )), ) as _i8.Future<_i6.Response>); + + @override + _i7.Dio clone({ + _i2.BaseOptions? options, + _i5.Interceptors? interceptors, + _i3.HttpClientAdapter? httpClientAdapter, + _i4.Transformer? transformer, + }) => + (super.noSuchMethod( + Invocation.method( + #clone, + [], + { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }, + ), + returnValue: _FakeDio_5( + this, + Invocation.method( + #clone, + [], + { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }, + ), + ), + ) as _i7.Dio); } /// A class which mocks [Box]. From f0be4fb2ab381fe1c36daa9a981b8a3e08a323e4 Mon Sep 17 00:00:00 2001 From: Domai Date: Sun, 6 Apr 2025 14:06:11 +0200 Subject: [PATCH 04/20] handle analyze options --- analysis_options.yaml | 10 +++++----- lib/main.dart | 2 ++ lib/utils/widgets/campus_segmented_control.dart | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 5cec895a..6204d2fa 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,6 +6,7 @@ analyzer: close_sinks: ignore missing_required_param: error missing_return: error + no_default_cases: ignore strong-mode: implicit-casts: true @@ -18,7 +19,7 @@ dart_code_metrics: maximum-nesting-level: 5 source-lines-of-code: 50 maintainability-index: 40 - + rules: - no-boolean-literal-compare - no-empty-block @@ -38,13 +39,13 @@ dart_code_metrics: - prefer-single-widget-per-file: ignore-private-widgets: true - prefer-extracting-callbacks - + metrics-exclude: - test/** - + rules-exclude: - test/** - + anti-patterns: - long-method - long-parameter-list @@ -124,7 +125,6 @@ linter: - one_member_abstracts - only_throw_errors - overridden_fields - - package_api_docs - package_names - package_prefixed_library_names - parameter_assignments diff --git a/lib/main.dart b/lib/main.dart index 041fda26..69da3f74 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -345,6 +345,8 @@ class CampusAppState extends State with WidgetsBindingObserver { alignment: Alignment.center, ); } + + return null; }, navigatorKey: mainNavigatorKey, debugShowCheckedModeBanner: false, diff --git a/lib/utils/widgets/campus_segmented_control.dart b/lib/utils/widgets/campus_segmented_control.dart index f6fe067d..86900529 100644 --- a/lib/utils/widgets/campus_segmented_control.dart +++ b/lib/utils/widgets/campus_segmented_control.dart @@ -5,6 +5,7 @@ import 'package:campus_app/core/themes.dart'; /// This widget allows the user to pick between two options. /// It is a linear set of two segments, each of which functions as a button. +// ignore: must_be_immutable class CampusSegmentedControl extends StatefulWidget { /// The displayed text on the left button of the SegmentedControl final String leftTitle; From 7a365149f5cff38f270c8cdb77e6428ca7dc50cd Mon Sep 17 00:00:00 2001 From: Domai Date: Sun, 6 Apr 2025 14:21:22 +0200 Subject: [PATCH 05/20] fix package upgrades manually --- pubspec.yaml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index beda45a3..4f3e9b35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,10 +1,10 @@ name: campus_app description: Simplifie, improve and facilitate everyday students life. publish_to: "none" -version: 2.3.2 +version: 2.3.4 environment: - sdk: ">=3.2.0 <4.0.0" + sdk: ">=3.5.0 <4.0.0" dependencies: flutter: @@ -12,31 +12,31 @@ dependencies: flutter_localizations: sdk: flutter envied: ^1.1.1 - flutter_native_splash: ^2.4.0 + flutter_native_splash: ^2.4.2 flutter_html: ^3.0.0-alpha.5 flutter_secure_storage: ^9.2.2 provider: ^6.1.1 - flutter_svg: ^2.0.9 + flutter_svg: ^2.0.14 xml: ^6.3.0 animations: ^2.0.8 page_transition: ^2.1.0 - cached_network_image: ^3.3.1 + cached_network_image: ^3.4.1 cookie_jar: ^4.0.8 dartz: ^0.10.1 - dio: ^5.4.3+1 + dio: ^5.7.0 dio_cookie_manager: ^3.1.1 native_dio_adapter: ^1.3.0 get_it: ^8.0.3 html: ^0.15.4 - http: ^1.2.1 + http: ^1.2.2 intl: ^0.19.0 - path_provider: ^2.1.2 + path_provider: ^2.1.5 snapping_sheet_2: ^3.1.5 flutter_displaymode: ^0.6.0 hive: ^2.2.3 hive_flutter: ^1.1.0 hive_generator: ^2.0.1 - url_launcher: ^6.2.3 + url_launcher: ^6.3.1 flutter_inappwebview: ^6.1.5 firebase_core: ^3.12.1 firebase_messaging: ^15.2.4 @@ -47,9 +47,9 @@ dependencies: url: https://github.com/C4s4r/flutter_onboarding ref: 9d74464 flutter_nfc_kit: ^3.5.2 - lottie: ^3.0.0 + lottie: ^3.1.3 screen_brightness: ^2.1.2 - fluttertoast: ^8.2.5 + fluttertoast: ^8.2.8 appwrite: ^15.0.0 slugid: ^1.1.2 visibility_detector: ^0.4.0+2 @@ -57,17 +57,17 @@ dependencies: git: url: https://github.com/astarub/flutter_videoplayer ref: v2.0 - sentry_flutter: ^8.2.0 + sentry_flutter: ^8.10.1 sentry: ^8.2.0 get_thumbnail_video: ^0.7.3 - image: ^4.2.0 + image: ^4.3.0 dismissible_page: ^1.0.2 app_links: ^6.4.0 dev_dependencies: flutter_test: sdk: flutter - build_runner: ^2.4.10 + build_runner: ^2.4.13 flutter_lints: ^5.0.0 mockito: ^5.4.3 envied_generator: ^1.1.1 From cbb0caaff15ae77a6de9ac22528bac3f538565e2 Mon Sep 17 00:00:00 2001 From: Domai Date: Sun, 6 Apr 2025 14:58:03 +0200 Subject: [PATCH 06/20] do not use synthetic package --- l10n.yaml | 2 +- lib/l10n/l10n.dart | 33 +++++++++++++++++++-------------- lib/l10n/l10n_de.dart | 8 +++++--- lib/l10n/l10n_en.dart | 8 +++++--- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/l10n.yaml b/l10n.yaml index 798c757e..23f9d2f7 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -2,4 +2,4 @@ arb-dir: assets/l10n template-arb-file: l10n_en.arb output-localization-file: l10n.dart output-dir: lib/l10n -synthetic-package: true \ No newline at end of file +synthetic-package: false diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index f30d8976..a77d2286 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -1,5 +1,3 @@ -// ignore_for_file: non_constant_identifier_names - import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -7,8 +5,10 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:intl/intl.dart' as intl; -import 'package:campus_app/l10n/l10n_de.dart'; -import 'package:campus_app/l10n/l10n_en.dart'; +import 'l10n_de.dart'; +import 'l10n_en.dart'; + +// ignore_for_file: type=lint /// Callers can lookup localized strings with an instance of AppLocalizations /// returned by `AppLocalizations.of(context)`. @@ -62,7 +62,7 @@ import 'package:campus_app/l10n/l10n_en.dart'; /// be consistent with the languages listed in the AppLocalizations.supportedLocales /// property. abstract class AppLocalizations { - AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale); + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); final String localeName; @@ -90,7 +90,10 @@ abstract class AppLocalizations { ]; /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [Locale('de'), Locale('en')]; + static const List supportedLocales = [ + Locale('de'), + Locale('en') + ]; /// No description provided for @helloWorld. /// @@ -211,16 +214,18 @@ class _AppLocalizationsDelegate extends LocalizationsDelegate } AppLocalizations lookupAppLocalizations(Locale locale) { + + // Lookup logic when only language code is specified. switch (locale.languageCode) { - case 'de': - return AppLocalizationsDe(); - case 'en': - return AppLocalizationsEn(); + case 'de': return AppLocalizationsDe(); + case 'en': return AppLocalizationsEn(); } - throw FlutterError('AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.'); + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.' + ); } diff --git a/lib/l10n/l10n_de.dart b/lib/l10n/l10n_de.dart index b4ddcb50..0a700abc 100644 --- a/lib/l10n/l10n_de.dart +++ b/lib/l10n/l10n_de.dart @@ -1,10 +1,12 @@ -// ignore_for_file: non_constant_identifier_names +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'l10n.dart'; -import 'package:campus_app/l10n/l10n.dart'; +// ignore_for_file: type=lint /// The translations for German (`de`). class AppLocalizationsDe extends AppLocalizations { - AppLocalizationsDe([super.locale = 'de']); + AppLocalizationsDe([String locale = 'de']) : super(locale); @override String get helloWorld => 'Hallo Welt!'; diff --git a/lib/l10n/l10n_en.dart b/lib/l10n/l10n_en.dart index 48e01e79..f57a9b47 100644 --- a/lib/l10n/l10n_en.dart +++ b/lib/l10n/l10n_en.dart @@ -1,10 +1,12 @@ -// ignore_for_file: non_constant_identifier_names +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'l10n.dart'; -import 'package:campus_app/l10n/l10n.dart'; +// ignore_for_file: type=lint /// The translations for English (`en`). class AppLocalizationsEn extends AppLocalizations { - AppLocalizationsEn([super.locale = 'en']); + AppLocalizationsEn([String locale = 'en']) : super(locale); @override String get helloWorld => 'Hello World!'; From d4ab2f1065d79dc786b433b34521a2de4b2c5522 Mon Sep 17 00:00:00 2001 From: Domai Date: Sun, 6 Apr 2025 14:59:21 +0200 Subject: [PATCH 07/20] remove duplicated sentry dependency --- pubspec.lock | 2 +- pubspec.yaml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 4268e45d..49cbde0c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1317,7 +1317,7 @@ packages: source: hosted version: "2.1.0" sentry: - dependency: "direct main" + dependency: transitive description: name: sentry sha256: "077b03f9ee44cfb1eaadbf8af58255e670de62b3f240ca154ce96a5591dc3885" diff --git a/pubspec.yaml b/pubspec.yaml index 4f3e9b35..dccb73b5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,7 +58,6 @@ dependencies: url: https://github.com/astarub/flutter_videoplayer ref: v2.0 sentry_flutter: ^8.10.1 - sentry: ^8.2.0 get_thumbnail_video: ^0.7.3 image: ^4.3.0 dismissible_page: ^1.0.2 From 8c7ca5c4eaaf371efea10d3b089d173103a2fba4 Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Mon, 19 May 2025 12:32:39 +0200 Subject: [PATCH 08/20] Email Client UI --- devtools_options.yaml | 3 + .../email_client/compose_email_Screen.dart | 187 ++++++++++++++++ lib/pages/email_client/email_client_page.dart | 160 ++++++++++++++ lib/pages/email_client/email_drawer.dart | 111 ++++++++++ .../email_client/email_drawer/archives.dart | 44 ++++ .../email_client/email_drawer/drafts.dart | 34 +++ .../email_drawer/email_settings.dart | 0 lib/pages/email_client/email_drawer/sent.dart | 34 +++ .../email_client/email_drawer/trash.dart | 58 +++++ lib/pages/email_client/email_view.dart | 203 ++++++++++++++++++ lib/pages/email_client/models/email.dart | 113 ++++++++++ .../email_client/widgets/email_tile.dart | 78 +++++++ .../email_client/widgets/search_bar.dart | 68 ++++++ .../email_client/widgets/select_email.dart | 1 + lib/pages/more/more_page.dart | 31 +++ pubspec.lock | 8 +- windows/flutter/CMakeLists.txt | 7 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 19 files changed, 1140 insertions(+), 5 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/pages/email_client/compose_email_Screen.dart create mode 100644 lib/pages/email_client/email_client_page.dart create mode 100644 lib/pages/email_client/email_drawer.dart create mode 100644 lib/pages/email_client/email_drawer/archives.dart create mode 100644 lib/pages/email_client/email_drawer/drafts.dart create mode 100644 lib/pages/email_client/email_drawer/email_settings.dart create mode 100644 lib/pages/email_client/email_drawer/sent.dart create mode 100644 lib/pages/email_client/email_drawer/trash.dart create mode 100644 lib/pages/email_client/email_view.dart create mode 100644 lib/pages/email_client/models/email.dart create mode 100644 lib/pages/email_client/widgets/email_tile.dart create mode 100644 lib/pages/email_client/widgets/search_bar.dart create mode 100644 lib/pages/email_client/widgets/select_email.dart diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/pages/email_client/compose_email_Screen.dart b/lib/pages/email_client/compose_email_Screen.dart new file mode 100644 index 00000000..2d405f0e --- /dev/null +++ b/lib/pages/email_client/compose_email_Screen.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +class ComposeEmailScreen extends StatefulWidget { + final Email? replyTo; + final Email? forwardFrom; + + const ComposeEmailScreen({ + super.key, + this.replyTo, + this.forwardFrom, + }); + + @override + State createState() => _ComposeEmailScreenState(); +} + +class _ComposeEmailScreenState extends State { + final _formKey = GlobalKey(); + final _toController = TextEditingController(); + final _ccController = TextEditingController(); + final _bccController = TextEditingController(); + final _subjectController = TextEditingController(); + final _bodyController = TextEditingController(); + bool _showCcBcc = false; + List _attachments = []; + + @override + void initState() { + super.initState(); + // Pre-fill fields if replying or forwarding + if (widget.replyTo != null) { + _toController.text = widget.replyTo!.senderEmail; + _subjectController.text = 'Re: ${widget.replyTo!.subject}'; + _bodyController.text = '\n\n----------\n${widget.replyTo!.body}'; + } else if (widget.forwardFrom != null) { + _subjectController.text = 'Fwd: ${widget.forwardFrom!.subject}'; + _bodyController.text = '\n\n----------\n${widget.forwardFrom!.body}'; + } + } + + @override + void dispose() { + _toController.dispose(); + _ccController.dispose(); + _bccController.dispose(); + _subjectController.dispose(); + _bodyController.dispose(); + super.dispose(); + } + + void _sendEmail() { + if (_formKey.currentState!.validate()) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email sent')), + ); + Navigator.pop(context); + } + } + + void _attachFile() async { + // TODO: Implement file attachment + setState(() { + _attachments.add('file_${_attachments.length + 1}.pdf'); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.replyTo != null ? 'Reply' : 'Compose'), + actions: [ + IconButton( + icon: const Icon(Icons.attach_file), + onPressed: _attachFile, + tooltip: 'Attach file', + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: _sendEmail, + tooltip: 'Send', + ), + ], + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // To Field + TextFormField( + controller: _toController, + decoration: const InputDecoration( + labelText: 'To', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter recipient'; + } + return null; + }, + ), + const SizedBox(height: 8), + + // CC/BCC Toggle + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => setState(() => _showCcBcc = !_showCcBcc), + child: Text(_showCcBcc ? 'Hide CC/BCC' : 'Add CC/BCC'), + ), + ), + + // CC Field (conditional) + if (_showCcBcc) ...[ + TextFormField( + controller: _ccController, + decoration: const InputDecoration( + labelText: 'CC', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + ], + + // BCC Field (conditional) + if (_showCcBcc) ...[ + TextFormField( + controller: _bccController, + decoration: const InputDecoration( + labelText: 'BCC', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + ], + + // Subject Field + TextFormField( + controller: _subjectController, + decoration: const InputDecoration( + labelText: 'Subject', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + + // Attachments + if (_attachments.isNotEmpty) ...[ + SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _attachments.length, + itemBuilder: (context, index) => Chip( + label: Text(_attachments[index]), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => setState(() => _attachments.removeAt(index)), + ), + ), + ), + const SizedBox(height: 8), + ], + + // Email Body + Expanded( + child: TextFormField( + controller: _bodyController, + decoration: const InputDecoration( + hintText: 'Compose your email...', + border: InputBorder.none, + ), + maxLines: null, + expands: true, + keyboardType: TextInputType.multiline, + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/email_client/email_client_page.dart b/lib/pages/email_client/email_client_page.dart new file mode 100644 index 00000000..e4649907 --- /dev/null +++ b/lib/pages/email_client/email_client_page.dart @@ -0,0 +1,160 @@ +import 'package:campus_app/pages/email_client/email_drawer.dart'; +import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/compose_email_screen.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/widgets/search_bar.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:flutter/material.dart'; + +class EmailClientScreen extends StatefulWidget { + const EmailClientScreen({super.key}); + + @override + State createState() => _EmailClientScreenState(); +} + +class _EmailClientScreenState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + // Placeholder emails to work with untill backend is implemented + final List _allEmails = List.generate(10, (i) => Email.dummy(i)); + late List _filteredEmails; + final TextEditingController _searchController = TextEditingController(); + bool _isSearching = false; + + // When initialized show emails that are in the inbox only. + @override + void initState() { + super.initState(); + _filteredEmails = _allEmails.where((e) => e.folder == EmailFolder.inbox).toList(); + } + + // logic for filtering Emails. If the query is Empty all inbox emails are shown. + // Otherwise the emails are filtered whilst handling case sensitivity and field matching logic + void _filterEmails(String query) { + setState(() { + if (query.isEmpty) { + _filteredEmails = _allEmails.where((e) => e.folder == EmailFolder.inbox).toList(); + } else { + _filteredEmails = _allEmails + .where((email) => + email.folder == EmailFolder.inbox && + (email.sender.toLowerCase().contains(query.toLowerCase()) || + email.subject.toLowerCase().contains(query.toLowerCase()))) + .toList(); + } + }); + } + + //When an email is deleted it is moved to the Trash list and removed from inbox. + void _moveToTrash(Email email) { + setState(() { + final index = _allEmails.indexWhere((e) => e.id == email.id); + if (index != -1) { + _allEmails[index] = email.copyWith(folder: EmailFolder.trash); + _filterEmails(_searchController.text); // Refresh filtered list + } + }); + } + + // with this I am trying to make it so that the back press closes things in the right order, + // however there are some issues with the order still. + // This will close the drawer if it's open while searching before closing the search: + Future _handlePop(BuildContext context) async { + final isDrawerOpen = _scaffoldKey.currentState?.isEndDrawerOpen ?? false; + if (isDrawerOpen) { + Navigator.of(context).maybePop(); + } else if (_isSearching) { + setState(() { + _isSearching = false; + _searchController.clear(); + _filterEmails(''); + }); + } else { + Navigator.of(context).maybePop(); // Normal pop + } + } + + // onPopInvoked is depricated in the new flutter version. + // TODO: fix pop order. + // TODO: find another function to replace onPopInvoked. + @override + Widget build(BuildContext context) { + return PopScope( + canPop: true, + onPopInvoked: (didPop) async { + if (!didPop) { + await _handlePop(context); + } + }, + child: Scaffold( + key: _scaffoldKey, + + //Here we use the functionality implemented in '.../email_client/widgets/search_bar.dart' + //This code is UI layer it handles user interactionslike button press and text input. + //The logic behind the filtering is _filterEmails (see above) + appBar: SearchAppBar( + isSearching: _isSearching, + onStartSearch: () => setState(() => _isSearching = true), + onStopSearch: () { + setState(() { + _isSearching = false; + _searchController.clear(); + _filterEmails(''); + }); + }, + onSearchChanged: _filterEmails, + searchController: _searchController, + ), + + // endDrawer add the drawer button on the right + endDrawer: EmailDrawer(allEmails: _allEmails), + + body: RefreshIndicator( + //Waits 1 second then re_runs _filterEmails + onRefresh: () async { + await Future.delayed(const Duration(seconds: 1)); + _filterEmails(_searchController.text); + }, + // Scrollable email list + child: ListView.separated( + itemCount: _filteredEmails.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) => EmailTile( + email: _filteredEmails[index], + // Tapping opens the Email view and provides a delete functionality from there + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView( + email: _filteredEmails[index], + onDelete: _moveToTrash, + ), + ), + ), + ), + ), + ), + + //This button is for composing a new email + floatingActionButton: FloatingActionButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ComposeEmailScreen()), + ), + child: const Icon(Icons.edit), + ), + ), + ); + } +} + +// Trash is fully implemented so far. +// TODO: Sent, Archives, Drafts +// TODO: Settings, so far I am unsure what to add in here. + +// TODO: add selection functionality, preferably as an independent file to be used anywhere. + +// TODO: Upload on a git branch. + +// TODO: extract _filterEmails as a separate component(maybe?) diff --git a/lib/pages/email_client/email_drawer.dart b/lib/pages/email_client/email_drawer.dart new file mode 100644 index 00000000..53421cea --- /dev/null +++ b/lib/pages/email_client/email_drawer.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/email_drawer/archives.dart'; +import 'package:campus_app/pages/email_client/email_drawer/drafts.dart'; +import 'package:campus_app/pages/email_client/email_drawer/sent.dart'; +import 'package:campus_app/pages/email_client/email_drawer/trash.dart'; + +class EmailDrawer extends StatelessWidget { + final List allEmails; + + const EmailDrawer({super.key, required this.allEmails}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Drawer( + child: Container( + color: theme.scaffoldBackgroundColor, + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + CircleAvatar( + radius: 25, + child: Icon(Icons.person, size: 30), + ), + SizedBox(height: 10), + Text( + 'Your Name', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Text( + 'you@example.com', + style: TextStyle(fontSize: 14), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.inbox), + title: const Text('Inbox'), + onTap: () { + Navigator.pop(context); // Just closes drawer (you're already in inbox) + }, + ), + ListTile( + leading: const Icon(Icons.send), + title: const Text('Sent'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => SentPage(allEmails: allEmails)), + ); + }, + ), + ListTile( + leading: const Icon(Icons.archive), + title: const Text('Archives'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => ArchivesPage(allEmails: allEmails)), + ); + }, + ), + ListTile( + leading: const Icon(Icons.drafts), + title: const Text('Drafts'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => DraftsPage(allEmails: allEmails)), + ); + }, + ), + ListTile( + leading: const Icon(Icons.delete), + title: const Text('Trash'), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => TrashPage(allEmails: allEmails)), + ); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Settings'), + onTap: () { + Navigator.pop(context); + // TODO: Add SettingsPage() later + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/archives.dart b/lib/pages/email_client/email_drawer/archives.dart new file mode 100644 index 00000000..ebf59b8a --- /dev/null +++ b/lib/pages/email_client/email_drawer/archives.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client//email_view.dart'; + +class ArchivesPage extends StatelessWidget { + final List allEmails; + + const ArchivesPage({Key? key, required this.allEmails}) : super(key: key); + + @override + Widget build(BuildContext context) { + // Filter emails by "archives" folder + final archivedEmails = allEmails.where((email) => email.folder == EmailFolder.archives).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Archived Emails'), + ), + body: archivedEmails.isEmpty + ? const Center( + child: Text('No archived emails.'), + ) + : ListView.separated( + itemCount: archivedEmails.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final email = archivedEmails[index]; + return EmailTile( + email: email, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart new file mode 100644 index 00000000..1a6f8d92 --- /dev/null +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +class DraftsPage extends StatelessWidget { + final List allEmails; + + const DraftsPage({super.key, required this.allEmails}); + + @override + Widget build(BuildContext context) { + final draftEmails = allEmails.where((e) => e.folder == EmailFolder.drafts).toList(); + + return Scaffold( + appBar: AppBar(title: const Text('Drafts')), + body: draftEmails.isEmpty + ? const Center(child: Text('No drafts available.')) + : ListView.separated( + itemCount: draftEmails.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) => EmailTile( + email: draftEmails[index], + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: draftEmails[index]), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/email_settings.dart b/lib/pages/email_client/email_drawer/email_settings.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/pages/email_client/email_drawer/sent.dart b/lib/pages/email_client/email_drawer/sent.dart new file mode 100644 index 00000000..461d695c --- /dev/null +++ b/lib/pages/email_client/email_drawer/sent.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +class SentPage extends StatelessWidget { + final List allEmails; + + const SentPage({super.key, required this.allEmails}); + + @override + Widget build(BuildContext context) { + final sentEmails = allEmails.where((e) => e.folder == EmailFolder.sent).toList(); + + return Scaffold( + appBar: AppBar(title: const Text('Sent')), + body: sentEmails.isEmpty + ? const Center(child: Text('No sent emails.')) + : ListView.separated( + itemCount: sentEmails.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) => EmailTile( + email: sentEmails[index], + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: sentEmails[index]), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/trash.dart b/lib/pages/email_client/email_drawer/trash.dart new file mode 100644 index 00000000..91d0c8bb --- /dev/null +++ b/lib/pages/email_client/email_drawer/trash.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +class TrashPage extends StatefulWidget { + final List allEmails; + + const TrashPage({super.key, required this.allEmails}); + + @override + State createState() => _TrashPageState(); +} + +class _TrashPageState extends State { + @override + Widget build(BuildContext context) { + final trashEmails = widget.allEmails.where((e) => e.folder == EmailFolder.trash).toList(); + + return Scaffold( + appBar: AppBar(title: const Text('Trash')), + body: trashEmails.isEmpty + ? const Center(child: Text('Trash is empty.')) + : ListView.separated( + itemCount: trashEmails.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) { + final email = trashEmails[index]; + return EmailTile( + email: email, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView( + email: email, + isInTrash: true, + onDelete: (Email emailToDelete) { + setState(() { + widget.allEmails.removeWhere((e) => e.id == emailToDelete.id); + }); + }, + onRestore: (Email emailToRestore) { + setState(() { + final index = widget.allEmails.indexWhere((e) => e.id == emailToRestore.id); + if (index != -1) { + widget.allEmails[index] = widget.allEmails[index].copyWith(folder: EmailFolder.inbox); + } + }); + }, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/email_client/email_view.dart b/lib/pages/email_client/email_view.dart new file mode 100644 index 00000000..91a78958 --- /dev/null +++ b/lib/pages/email_client/email_view.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/compose_email_screen.dart'; + +class EmailView extends StatelessWidget { + final Email email; + final void Function(Email)? onDelete; + final void Function(Email)? onRestore; + final bool isInTrash; + + const EmailView({ + super.key, + required this.email, + this.onDelete, + this.onRestore, + this.isInTrash = false, + }); + + void _handleReply(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ComposeEmailScreen(replyTo: email), + ), + ); + } + + void _confirmPermanentDelete(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Permanently Delete'), + content: const Text('This action is permanent. Are you sure?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(ctx); // Close dialog + if (onDelete != null) { + onDelete!(email); + } + Navigator.pop(context); // Close email view + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email permanently deleted')), + ); + }, + child: const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + void _handleRestore(BuildContext context) { + if (onRestore != null) { + onRestore!(email); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email restored from trash')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final timeText = '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}'; + + return Scaffold( + appBar: AppBar( + title: const Text('RubMail'), + actions: [ + if (!isInTrash) + IconButton( + icon: const Icon(Icons.reply), + onPressed: () => _handleReply(context), + tooltip: 'Reply', + ), + if (!isInTrash && onDelete != null) + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + onDelete!(email); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Email moved to trash')), + ); + }, + tooltip: 'Delete', + ), + if (isInTrash) + IconButton( + icon: const Icon(Icons.restore_from_trash), + onPressed: () => _handleRestore(context), + tooltip: 'Restore', + ), + if (isInTrash) + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () => _confirmPermanentDelete(context), + tooltip: 'Permanently Delete', + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + email.sender, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + if (email.senderEmail.isNotEmpty) + Text( + email.senderEmail, + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]), + ), + ], + ), + ), + Text( + timeText, + style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]), + ), + ], + ), + const SizedBox(height: 16), + + // Subject + Text( + email.subject, + style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // Body + Text( + email.body, + style: theme.textTheme.bodyLarge, + ), + + // Attachments + if (email.attachments.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + 'Attachments (${email.attachments.length})', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + SizedBox( + height: 100, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: email.attachments.length, + itemBuilder: (context, index) => Container( + width: 80, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.insert_drive_file, size: 30), + const SizedBox(height: 4), + Text( + 'File ${index + 1}', + style: theme.textTheme.labelSmall, + ), + ], + ), + ), + ), + ), + ], + ], + ), + ), + floatingActionButton: !isInTrash + ? FloatingActionButton( + onPressed: () => _handleReply(context), + child: const Icon(Icons.reply), + tooltip: 'Reply', + ) + : null, + ); + } +} diff --git a/lib/pages/email_client/models/email.dart b/lib/pages/email_client/models/email.dart new file mode 100644 index 00000000..256dcf94 --- /dev/null +++ b/lib/pages/email_client/models/email.dart @@ -0,0 +1,113 @@ +// this files defines the data model of an email (structure) +// by defining a data class that represents an email's properties +class Email { + final String id; + final String sender; + final String senderEmail; + final List recipients; + final String subject; + final String body; + final DateTime date; + final bool isUnread; + final bool isStarred; + final List attachments; + final EmailFolder folder; + + const Email({ + required this.id, + required this.sender, + required this.senderEmail, + required this.recipients, + required this.subject, + required this.body, + required this.date, + this.isUnread = false, + this.isStarred = false, + this.attachments = const [], + this.folder = EmailFolder.inbox, + }); + + // Updated dummy constructor + factory Email.dummy(int index) => Email( + id: index.toString(), + sender: 'Sender $index', + senderEmail: 'sender$index@example.com', + recipients: ['recipient$index@example.com'], + subject: 'Subject line $index', + body: 'This is the body content of email $index.\n\n' + 'It contains multiple paragraphs of sample text.\n\n' + 'Best regards,\nSender $index', + date: DateTime.now().subtract(Duration(hours: index)), + isUnread: index % 2 == 0, + isStarred: index % 3 == 0, + attachments: index % 4 == 0 ? ['document$index.pdf', 'image$index.jpg'] : [], + ); + + // JSON serialization + Map toJson() => { + 'id': id, + 'sender': sender, + 'senderEmail': senderEmail, + 'recipients': recipients, + 'subject': subject, + 'body': body, + 'date': date.toIso8601String(), + 'isRead': !isUnread, + 'isStarred': isStarred, + 'attachments': attachments, + 'folder': folder.name, + }; + + factory Email.fromJson(Map json) => Email( + id: json['id'], + sender: json['sender'], + senderEmail: json['senderEmail'], + recipients: List.from(json['recipients']), + subject: json['subject'], + body: json['body'], + date: DateTime.parse(json['date']), + isUnread: !json['isRead'], + isStarred: json['isStarred'], + attachments: List.from(json['attachments']), + folder: EmailFolder.values.byName(json['folder']), + ); + + Email copyWith({ + String? id, + String? sender, + String? senderEmail, + List? recipients, + String? subject, + String? body, + DateTime? date, + bool? isUnread, + bool? isStarred, + List? attachments, + EmailFolder? folder, + }) => + Email( + id: id ?? this.id, + sender: sender ?? this.sender, + senderEmail: senderEmail ?? this.senderEmail, + recipients: recipients ?? this.recipients, + subject: subject ?? this.subject, + body: body ?? this.body, + date: date ?? this.date, + isUnread: isUnread ?? this.isUnread, + isStarred: isStarred ?? this.isStarred, + attachments: attachments ?? this.attachments, + folder: folder ?? this.folder, + ); + + String get preview { + return body.length > 50 ? '${body.substring(0, 50)}...' : body; + } +} + +enum EmailFolder { + inbox, + sent, + drafts, + trash, + archives, +} diff --git a/lib/pages/email_client/widgets/email_tile.dart b/lib/pages/email_client/widgets/email_tile.dart new file mode 100644 index 00000000..b327293e --- /dev/null +++ b/lib/pages/email_client/widgets/email_tile.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +class EmailTile extends StatelessWidget { + final Email email; + final VoidCallback onTap; + + const EmailTile({ + super.key, + required this.email, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + color: email.isUnread ? Colors.blue[50] : Theme.of(context).canvasColor, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const CircleAvatar( + radius: 20, + backgroundColor: Colors.grey, + child: Icon(Icons.person, color: Colors.white), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + email.sender, + style: TextStyle( + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Text( + email.subject, + style: TextStyle( + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Text( + email.preview, + style: TextStyle( + color: Colors.grey[600], + fontWeight: email.isUnread ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Column( + children: [ + Text( + '${email.date.hour}:${email.date.minute}', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + if (email.isStarred) const Icon(Icons.star, color: Colors.amber, size: 16), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/email_client/widgets/search_bar.dart b/lib/pages/email_client/widgets/search_bar.dart new file mode 100644 index 00000000..09ad0534 --- /dev/null +++ b/lib/pages/email_client/widgets/search_bar.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +class SearchAppBar extends StatefulWidget implements PreferredSizeWidget { + final bool isSearching; + final VoidCallback onStopSearch; + final ValueChanged onSearchChanged; + final TextEditingController searchController; + final VoidCallback onStartSearch; + + const SearchAppBar({ + super.key, + required this.isSearching, + required this.onStartSearch, + required this.onStopSearch, + required this.onSearchChanged, + required this.searchController, + }); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + State createState() => _SearchAppBarState(); +} + +class _SearchAppBarState extends State { + @override + Widget build(BuildContext context) { + return AppBar( + leading: widget.isSearching + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: widget.onStopSearch, + ) + : null, + title: widget.isSearching + ? TextField( + controller: widget.searchController, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search emails...', + border: InputBorder.none, + suffixIcon: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + widget.searchController.clear(); + widget.onSearchChanged(''); + }, + ), + ), + onChanged: widget.onSearchChanged, + ) + : const Text('RubMail'), + actions: [ + if (!widget.isSearching) ...[ + IconButton( + icon: const Icon(Icons.search), + onPressed: widget.onStartSearch, // Triggers search mode + ), + IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openEndDrawer(), + ), + ], + ], + ); + } +} diff --git a/lib/pages/email_client/widgets/select_email.dart b/lib/pages/email_client/widgets/select_email.dart new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/lib/pages/email_client/widgets/select_email.dart @@ -0,0 +1 @@ + diff --git a/lib/pages/more/more_page.dart b/lib/pages/more/more_page.dart index 802e5cbe..bddc0f17 100644 --- a/lib/pages/more/more_page.dart +++ b/lib/pages/more/more_page.dart @@ -1,4 +1,5 @@ import 'dart:io' show Platform; +import 'package:campus_app/pages/email_client/email_client_page.dart'; import 'package:campus_app/pages/more/privacy_policy_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -146,6 +147,36 @@ class MorePageState extends State with AutomaticKeepAliveClientMixin API + // UI und Backend separate, start with UI it is easier. + // flutter secure storage login daten, take it from there + Container( + margin: const EdgeInsets.only(bottom: 30), + decoration: BoxDecoration( + color: Provider.of(context, listen: false).currentTheme == AppThemes.light + ? const Color.fromRGBO(245, 246, 250, 1) + : const Color.fromRGBO(34, 40, 54, 1), + borderRadius: BorderRadius.circular(15), + ), + child: Column( + children: [ + ExternalLinkButton( + title: 'RubMail', + leadingIconPath: 'assets/img/icons/mail-link.png', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EmailClientScreen(), + ), + ); + }, + ), + ], + ), + ), // RUB links ButtonGroup( headline: 'Nützliche Links', diff --git a/pubspec.lock b/pubspec.lock index 49cbde0c..adc22cf1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -106,10 +106,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.12.0" boolean_selector: dependency: transitive description: @@ -426,10 +426,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.2" ffi: dependency: transitive description: diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt index b2e4bd8d..4f2af69b 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/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 61655f97..2e52ad8b 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); FirebaseCorePluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d7bb58ad..f9579c55 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + desktop_webview_window firebase_core flutter_inappwebview_windows flutter_secure_storage_windows @@ -15,6 +16,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows jni ) From 1bd352377e14207f23ba2e3fc36a6a0730a2f740 Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Sat, 24 May 2025 14:28:05 +0200 Subject: [PATCH 09/20] added selection with delete and archive options --- lib/pages/email_client/email_client_page.dart | 268 +++++++++++++----- .../email_client/email_drawer/archives.dart | 2 + .../email_client/email_drawer/drafts.dart | 2 + lib/pages/email_client/email_drawer/sent.dart | 2 + .../email_client/email_drawer/trash.dart | 2 + .../email_client/services/email_service.dart | 44 +++ .../email_client/widgets/email_tile.dart | 27 +- .../email_client/widgets/search_bar.dart | 68 ----- .../email_client/widgets/select_email.dart | 70 +++++ 9 files changed, 336 insertions(+), 149 deletions(-) create mode 100644 lib/pages/email_client/services/email_service.dart delete mode 100644 lib/pages/email_client/widgets/search_bar.dart diff --git a/lib/pages/email_client/email_client_page.dart b/lib/pages/email_client/email_client_page.dart index e4649907..36340503 100644 --- a/lib/pages/email_client/email_client_page.dart +++ b/lib/pages/email_client/email_client_page.dart @@ -2,8 +2,8 @@ import 'package:campus_app/pages/email_client/email_drawer.dart'; import 'package:campus_app/pages/email_client/email_view.dart'; import 'package:campus_app/pages/email_client/compose_email_screen.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; -import 'package:campus_app/pages/email_client/widgets/search_bar.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/widgets/select_email.dart'; import 'package:flutter/material.dart'; class EmailClientScreen extends StatefulWidget { @@ -15,22 +15,71 @@ class EmailClientScreen extends StatefulWidget { class _EmailClientScreenState extends State { final GlobalKey _scaffoldKey = GlobalKey(); - - // Placeholder emails to work with untill backend is implemented final List _allEmails = List.generate(10, (i) => Email.dummy(i)); late List _filteredEmails; final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; + late EmailSelectionController _selectionController; - // When initialized show emails that are in the inbox only. @override void initState() { super.initState(); _filteredEmails = _allEmails.where((e) => e.folder == EmailFolder.inbox).toList(); + _selectionController = EmailSelectionController( + onDelete: _deleteSelected, + onArchive: _archiveSelected, + onEmailUpdated: _updateEmail, + )..addListener(_onSelectionChanged); + } + + void _onSelectionChanged() { + setState(() {}); // Rebuild when selection changes + } + + @override + void dispose() { + _selectionController.removeListener(_onSelectionChanged); + _selectionController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + void _updateEmail(Email updatedEmail) { + setState(() { + final index = _allEmails.indexWhere((e) => e.id == updatedEmail.id); + if (index != -1) { + _allEmails[index] = updatedEmail; + _filterEmails(_searchController.text); + } + }); + } + + Future _deleteSelected(Set emails) async { + setState(() { + for (final email in emails) { + final index = _allEmails.indexWhere((e) => e.id == email.id); + if (index != -1) { + _allEmails[index] = email.copyWith(folder: EmailFolder.trash); + } + } + _selectionController.clearSelection(); + _filterEmails(_searchController.text); + }); + } + + Future _archiveSelected(Set emails) async { + setState(() { + for (final email in emails) { + final index = _allEmails.indexWhere((e) => e.id == email.id); + if (index != -1) { + _allEmails[index] = email.copyWith(folder: EmailFolder.archives); + } + } + _selectionController.clearSelection(); + _filterEmails(_searchController.text); + }); } - // logic for filtering Emails. If the query is Empty all inbox emails are shown. - // Otherwise the emails are filtered whilst handling case sensitivity and field matching logic void _filterEmails(String query) { setState(() { if (query.isEmpty) { @@ -46,104 +95,172 @@ class _EmailClientScreenState extends State { }); } - //When an email is deleted it is moved to the Trash list and removed from inbox. void _moveToTrash(Email email) { setState(() { final index = _allEmails.indexWhere((e) => e.id == email.id); if (index != -1) { _allEmails[index] = email.copyWith(folder: EmailFolder.trash); - _filterEmails(_searchController.text); // Refresh filtered list + _filterEmails(_searchController.text); } }); } - // with this I am trying to make it so that the back press closes things in the right order, - // however there are some issues with the order still. - // This will close the drawer if it's open while searching before closing the search: Future _handlePop(BuildContext context) async { - final isDrawerOpen = _scaffoldKey.currentState?.isEndDrawerOpen ?? false; - if (isDrawerOpen) { - Navigator.of(context).maybePop(); - } else if (_isSearching) { + if (_selectionController.isSelecting) { + _selectionController.clearSelection(); + return; + } + if (_isSearching) { setState(() { _isSearching = false; _searchController.clear(); _filterEmails(''); }); - } else { - Navigator.of(context).maybePop(); // Normal pop + return; + } + if (_scaffoldKey.currentState?.isEndDrawerOpen ?? false) { + Navigator.of(context).pop(); + return; } + Navigator.of(context).maybePop(); } - // onPopInvoked is depricated in the new flutter version. - // TODO: fix pop order. - // TODO: find another function to replace onPopInvoked. @override Widget build(BuildContext context) { return PopScope( canPop: true, onPopInvoked: (didPop) async { - if (!didPop) { - await _handlePop(context); - } + if (!didPop) await _handlePop(context); }, child: Scaffold( key: _scaffoldKey, - - //Here we use the functionality implemented in '.../email_client/widgets/search_bar.dart' - //This code is UI layer it handles user interactionslike button press and text input. - //The logic behind the filtering is _filterEmails (see above) - appBar: SearchAppBar( - isSearching: _isSearching, - onStartSearch: () => setState(() => _isSearching = true), - onStopSearch: () { - setState(() { - _isSearching = false; - _searchController.clear(); - _filterEmails(''); - }); - }, - onSearchChanged: _filterEmails, - searchController: _searchController, - ), - - // endDrawer add the drawer button on the right - endDrawer: EmailDrawer(allEmails: _allEmails), - - body: RefreshIndicator( - //Waits 1 second then re_runs _filterEmails - onRefresh: () async { - await Future.delayed(const Duration(seconds: 1)); - _filterEmails(_searchController.text); - }, - // Scrollable email list - child: ListView.separated( - itemCount: _filteredEmails.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (_, index) => EmailTile( - email: _filteredEmails[index], - // Tapping opens the Email view and provides a delete functionality from there - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EmailView( - email: _filteredEmails[index], - onDelete: _moveToTrash, + appBar: AppBar( + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Search emails...', + border: InputBorder.none, ), + onChanged: _filterEmails, + ) + : const Text('RubMail'), + leading: _isSearching + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + setState(() { + _isSearching = false; + _searchController.clear(); + _filterEmails(''); + }); + }, + ) + : null, + actions: [ + if (!_isSearching && !_selectionController.isSelecting) + IconButton( + icon: const Icon(Icons.search), + onPressed: () => setState(() => _isSearching = true), + ), + if (_selectionController.isSelecting) ...[ + IconButton( + icon: const Icon(Icons.select_all), + onPressed: () => _selectionController.selectAll(_filteredEmails), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: _selectionController.clearSelection, + ), + ], + if (!_isSearching && !_selectionController.isSelecting) + Builder( + builder: (context) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(context).openEndDrawer(), ), ), - ), - ), + ], ), - - //This button is for composing a new email - floatingActionButton: FloatingActionButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const ComposeEmailScreen()), + endDrawer: EmailDrawer(allEmails: _allEmails), + body: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _selectionController.isSelecting ? _selectionController.clearSelection : null, + child: RefreshIndicator( + onRefresh: () async { + await Future.delayed(const Duration(seconds: 1)); + _filterEmails(_searchController.text); + }, + child: ListView.separated( + itemCount: _filteredEmails.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) { + final email = _filteredEmails[index]; + return InkWell( + onLongPress: () { + setState(() { + _selectionController.toggleSelection(email); + }); + }, + child: EmailTile( + email: email, + isSelected: _selectionController.isSelected(email), + onTap: () { + //if selecting tapping toggles selection + if (_selectionController.isSelecting) { + setState(() { + _selectionController.toggleSelection(email); + }); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView( + email: email, + onDelete: _moveToTrash, + ), + ), + ); + } + }, + onLongPress: () { + //bulk selection + setState(() { + _selectionController.toggleSelection(email); + }); + }, + ), + ); + }, + ), ), - child: const Icon(Icons.edit), ), + floatingActionButton: _selectionController.isSelecting + ? Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: 'delete', + onPressed: () => _selectionController.onDelete?.call(_selectionController.selectedEmails), + child: const Icon(Icons.delete), + ), + const SizedBox(width: 16), + FloatingActionButton( + heroTag: 'archive', + onPressed: () => _selectionController.onArchive?.call(_selectionController.selectedEmails), + child: const Icon(Icons.archive), + ), + ], + ) + : FloatingActionButton( + //button to compose emails + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ComposeEmailScreen()), + ), + child: const Icon(Icons.edit), + ), ), ); } @@ -155,6 +272,9 @@ class _EmailClientScreenState extends State { // TODO: add selection functionality, preferably as an independent file to be used anywhere. -// TODO: Upload on a git branch. - // TODO: extract _filterEmails as a separate component(maybe?) + +// Check: 'package:flutter_secure_storage/flutter_secure_storage.dart'; +// Check: ticket_datasource +// Use Login, make it go to Email +// Check IMAP plugins (for dart/flutter): enough_mail?s diff --git a/lib/pages/email_client/email_drawer/archives.dart b/lib/pages/email_client/email_drawer/archives.dart index ebf59b8a..43d6444e 100644 --- a/lib/pages/email_client/email_drawer/archives.dart +++ b/lib/pages/email_client/email_drawer/archives.dart @@ -28,6 +28,8 @@ class ArchivesPage extends StatelessWidget { final email = archivedEmails[index]; return EmailTile( email: email, + isSelected: false, + onLongPress: () {}, onTap: () { Navigator.push( context, diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart index 1a6f8d92..e7192071 100644 --- a/lib/pages/email_client/email_drawer/drafts.dart +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -21,6 +21,8 @@ class DraftsPage extends StatelessWidget { separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, index) => EmailTile( email: draftEmails[index], + isSelected: false, + onLongPress: () {}, onTap: () => Navigator.push( context, MaterialPageRoute( diff --git a/lib/pages/email_client/email_drawer/sent.dart b/lib/pages/email_client/email_drawer/sent.dart index 461d695c..2046701e 100644 --- a/lib/pages/email_client/email_drawer/sent.dart +++ b/lib/pages/email_client/email_drawer/sent.dart @@ -21,6 +21,8 @@ class SentPage extends StatelessWidget { separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, index) => EmailTile( email: sentEmails[index], + isSelected: false, + onLongPress: () {}, onTap: () => Navigator.push( context, MaterialPageRoute( diff --git a/lib/pages/email_client/email_drawer/trash.dart b/lib/pages/email_client/email_drawer/trash.dart index 91d0c8bb..57faf00f 100644 --- a/lib/pages/email_client/email_drawer/trash.dart +++ b/lib/pages/email_client/email_drawer/trash.dart @@ -28,6 +28,8 @@ class _TrashPageState extends State { final email = trashEmails[index]; return EmailTile( email: email, + isSelected: false, + onLongPress: () {}, onTap: () => Navigator.push( context, MaterialPageRoute( diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart new file mode 100644 index 00000000..94930321 --- /dev/null +++ b/lib/pages/email_client/services/email_service.dart @@ -0,0 +1,44 @@ +import 'package:campus_app/pages/email_client/models/email.dart'; + +class EmailService { + final List _allEmails; + + EmailService(this._allEmails); + + List filterEmails(String query, EmailFolder folder) { + if (query.isEmpty) { + return _allEmails.where((e) => e.folder == folder).toList(); + } + return _allEmails + .where((email) => + email.folder == folder && + (email.sender.toLowerCase().contains(query.toLowerCase()) || + email.subject.toLowerCase().contains(query.toLowerCase()))) + .toList(); + } + + Future archiveSelected(Set emails) async { + for (final email in emails) { + final index = _allEmails.indexWhere((e) => e.id == email.id); + if (index != -1) { + _allEmails[index] = email.copyWith(folder: EmailFolder.archives); + } + } + } + + Future deleteSelected(Set emails) async { + for (final email in emails) { + final index = _allEmails.indexWhere((e) => e.id == email.id); + if (index != -1) { + _allEmails[index] = email.copyWith(folder: EmailFolder.trash); + } + } + } + + void updateEmail(Email updatedEmail) { + final index = _allEmails.indexWhere((e) => e.id == updatedEmail.id); + if (index != -1) { + _allEmails[index] = updatedEmail; + } + } +} diff --git a/lib/pages/email_client/widgets/email_tile.dart b/lib/pages/email_client/widgets/email_tile.dart index b327293e..194f9c66 100644 --- a/lib/pages/email_client/widgets/email_tile.dart +++ b/lib/pages/email_client/widgets/email_tile.dart @@ -3,29 +3,42 @@ import 'package:campus_app/pages/email_client/models/email.dart'; class EmailTile extends StatelessWidget { final Email email; + final bool isSelected; final VoidCallback onTap; + final VoidCallback onLongPress; const EmailTile({ super.key, required this.email, required this.onTap, + required this.onLongPress, + this.isSelected = false, }); @override Widget build(BuildContext context) { + final bgColor = isSelected + ? Colors.lightBlue.withOpacity(0.2) + : email.isUnread + ? Colors.blue[50] + : Theme.of(context).canvasColor; + return InkWell( onTap: onTap, + onLongPress: onLongPress, child: Container( - color: email.isUnread ? Colors.blue[50] : Theme.of(context).canvasColor, + color: bgColor, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const CircleAvatar( - radius: 20, - backgroundColor: Colors.grey, - child: Icon(Icons.person, color: Colors.white), - ), + isSelected + ? const Icon(Icons.check_circle, color: Colors.blue) + : const CircleAvatar( + radius: 20, + backgroundColor: Colors.grey, + child: Icon(Icons.person, color: Colors.white), + ), const SizedBox(width: 16), Expanded( child: Column( @@ -60,7 +73,7 @@ class EmailTile extends StatelessWidget { Column( children: [ Text( - '${email.date.hour}:${email.date.minute}', + '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}', style: TextStyle( color: Colors.grey, fontSize: 12, diff --git a/lib/pages/email_client/widgets/search_bar.dart b/lib/pages/email_client/widgets/search_bar.dart deleted file mode 100644 index 09ad0534..00000000 --- a/lib/pages/email_client/widgets/search_bar.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; - -class SearchAppBar extends StatefulWidget implements PreferredSizeWidget { - final bool isSearching; - final VoidCallback onStopSearch; - final ValueChanged onSearchChanged; - final TextEditingController searchController; - final VoidCallback onStartSearch; - - const SearchAppBar({ - super.key, - required this.isSearching, - required this.onStartSearch, - required this.onStopSearch, - required this.onSearchChanged, - required this.searchController, - }); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - State createState() => _SearchAppBarState(); -} - -class _SearchAppBarState extends State { - @override - Widget build(BuildContext context) { - return AppBar( - leading: widget.isSearching - ? IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: widget.onStopSearch, - ) - : null, - title: widget.isSearching - ? TextField( - controller: widget.searchController, - autofocus: true, - decoration: InputDecoration( - hintText: 'Search emails...', - border: InputBorder.none, - suffixIcon: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - widget.searchController.clear(); - widget.onSearchChanged(''); - }, - ), - ), - onChanged: widget.onSearchChanged, - ) - : const Text('RubMail'), - actions: [ - if (!widget.isSearching) ...[ - IconButton( - icon: const Icon(Icons.search), - onPressed: widget.onStartSearch, // Triggers search mode - ), - IconButton( - icon: const Icon(Icons.menu), - onPressed: () => Scaffold.of(context).openEndDrawer(), - ), - ], - ], - ); - } -} diff --git a/lib/pages/email_client/widgets/select_email.dart b/lib/pages/email_client/widgets/select_email.dart index 8b137891..3dcd03b3 100644 --- a/lib/pages/email_client/widgets/select_email.dart +++ b/lib/pages/email_client/widgets/select_email.dart @@ -1 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +class EmailSelectionController extends ChangeNotifier { + final Set _selectedEmails = {}; + final Future Function(Set)? onDelete; + final Future Function(Set)? onArchive; + final void Function(Email updatedEmail)? onEmailUpdated; + + EmailSelectionController({this.onDelete, this.onArchive, this.onEmailUpdated}); + + // Public API + Set get selectedEmails => Set.unmodifiable(_selectedEmails); + bool get isSelecting => _selectedEmails.isNotEmpty; + bool isSelected(Email email) => _selectedEmails.contains(email); + int get selectionCount => _selectedEmails.length; + + // Selection management + void toggleSelection(Email email) { + _selectedEmails.contains(email) ? _selectedEmails.remove(email) : _selectedEmails.add(email); + notifyListeners(); + } + + void toggleSelections(Iterable emails) { + for (final email in emails) { + toggleSelection(email); + } + } + + void selectAll(Iterable emails) { + _selectedEmails.addAll(emails); + notifyListeners(); + } + + void selectSingle(Email email) { + _selectedEmails + ..clear() + ..add(email); + notifyListeners(); + } + + void clearSelection() { + _selectedEmails.clear(); + notifyListeners(); + } + + // Email state modifications + Future markAsReadSelected() async { + for (final email in _selectedEmails) { + final updatedEmail = email.copyWith(isUnread: false); + onEmailUpdated?.call(updatedEmail); // Notify parent + } + notifyListeners(); + } + + Future markAsUnreadSelected() async { + for (final email in _selectedEmails) { + final updatedEmail = email.copyWith(isUnread: true); + onEmailUpdated?.call(updatedEmail); + } + notifyListeners(); + } + + void toggleReadState() { + final allUnread = _selectedEmails.every((e) => e.isUnread); + for (final email in _selectedEmails) { + onEmailUpdated?.call(email.copyWith(isUnread: !allUnread)); + } + notifyListeners(); + } +} From 1df4f089974e093241feb4b3d76d69e3e2bdc49c Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Mon, 26 May 2025 16:14:44 +0200 Subject: [PATCH 10/20] migrated the email_related state management to use the provider package --- lib/main.dart | 5 + lib/pages/email_client/email_client_page.dart | 198 +++++++----------- lib/pages/email_client/email_drawer.dart | 100 ++++----- .../email_client/email_drawer/archives.dart | 39 ++-- .../email_client/email_drawer/drafts.dart | 39 ++-- lib/pages/email_client/email_drawer/sent.dart | 41 ++-- .../email_client/email_drawer/trash.dart | 89 ++++---- .../email_client/services/email_service.dart | 58 +++-- .../email_client/widgets/email_tile.dart | 118 ++++++----- .../email_client/widgets/select_email.dart | 50 +++-- 10 files changed, 371 insertions(+), 366 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 69da3f74..60366742 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,9 @@ import 'package:campus_app/pages/calendar/entities/venue_entity.dart'; import 'package:campus_app/utils/pages/main_utils.dart'; import 'package:campus_app/utils/pages/mensa_utils.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + Future main() async { final WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); // Keeps the native splash screen onscreen until all loading is done @@ -66,6 +69,7 @@ Future main() async { // Initializes the provider that handles the app-theme, authentication and other things ChangeNotifierProvider(create: (_) => SettingsHandler()), ChangeNotifierProvider(create: (_) => ThemesNotifier()), + ChangeNotifierProvider(create: (_) => EmailService()), ], child: CampusApp( key: campusAppKey, @@ -80,6 +84,7 @@ Future main() async { // Initializes the provider that handles the app-theme, authentication and other things ChangeNotifierProvider(create: (_) => SettingsHandler()), ChangeNotifierProvider(create: (_) => ThemesNotifier()), + ChangeNotifierProvider(create: (_) => EmailService()), ], child: CampusApp( key: campusAppKey, diff --git a/lib/pages/email_client/email_client_page.dart b/lib/pages/email_client/email_client_page.dart index 36340503..49672bc8 100644 --- a/lib/pages/email_client/email_client_page.dart +++ b/lib/pages/email_client/email_client_page.dart @@ -1,22 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/email_drawer.dart'; import 'package:campus_app/pages/email_client/email_view.dart'; import 'package:campus_app/pages/email_client/compose_email_screen.dart'; -import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; import 'package:campus_app/pages/email_client/widgets/select_email.dart'; -import 'package:flutter/material.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; -class EmailClientScreen extends StatefulWidget { +class EmailClientScreen extends StatelessWidget { const EmailClientScreen({super.key}); @override - State createState() => _EmailClientScreenState(); + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => EmailService(), + child: const _EmailClientContent(), + ); + } } -class _EmailClientScreenState extends State { +class _EmailClientContent extends StatefulWidget { + const _EmailClientContent(); + + @override + State<_EmailClientContent> createState() => _EmailClientContentState(); +} + +class _EmailClientContentState extends State<_EmailClientContent> { final GlobalKey _scaffoldKey = GlobalKey(); - final List _allEmails = List.generate(10, (i) => Email.dummy(i)); - late List _filteredEmails; final TextEditingController _searchController = TextEditingController(); bool _isSearching = false; late EmailSelectionController _selectionController; @@ -24,85 +36,27 @@ class _EmailClientScreenState extends State { @override void initState() { super.initState(); - _filteredEmails = _allEmails.where((e) => e.folder == EmailFolder.inbox).toList(); + final emailService = Provider.of(context, listen: false); _selectionController = EmailSelectionController( - onDelete: _deleteSelected, - onArchive: _archiveSelected, - onEmailUpdated: _updateEmail, + onDelete: (emails) async { + emailService.moveEmailsToFolder(emails, EmailFolder.trash); + _search(); + }, + onArchive: (emails) async { + emailService.moveEmailsToFolder(emails, EmailFolder.archives); + _search(); + }, + onEmailUpdated: (email) async { + emailService.updateEmail(email); + _search(); + }, )..addListener(_onSelectionChanged); } - void _onSelectionChanged() { - setState(() {}); // Rebuild when selection changes - } - - @override - void dispose() { - _selectionController.removeListener(_onSelectionChanged); - _selectionController.dispose(); - _searchController.dispose(); - super.dispose(); - } + void _onSelectionChanged() => setState(() {}); - void _updateEmail(Email updatedEmail) { - setState(() { - final index = _allEmails.indexWhere((e) => e.id == updatedEmail.id); - if (index != -1) { - _allEmails[index] = updatedEmail; - _filterEmails(_searchController.text); - } - }); - } - - Future _deleteSelected(Set emails) async { - setState(() { - for (final email in emails) { - final index = _allEmails.indexWhere((e) => e.id == email.id); - if (index != -1) { - _allEmails[index] = email.copyWith(folder: EmailFolder.trash); - } - } - _selectionController.clearSelection(); - _filterEmails(_searchController.text); - }); - } - - Future _archiveSelected(Set emails) async { - setState(() { - for (final email in emails) { - final index = _allEmails.indexWhere((e) => e.id == email.id); - if (index != -1) { - _allEmails[index] = email.copyWith(folder: EmailFolder.archives); - } - } - _selectionController.clearSelection(); - _filterEmails(_searchController.text); - }); - } - - void _filterEmails(String query) { - setState(() { - if (query.isEmpty) { - _filteredEmails = _allEmails.where((e) => e.folder == EmailFolder.inbox).toList(); - } else { - _filteredEmails = _allEmails - .where((email) => - email.folder == EmailFolder.inbox && - (email.sender.toLowerCase().contains(query.toLowerCase()) || - email.subject.toLowerCase().contains(query.toLowerCase()))) - .toList(); - } - }); - } - - void _moveToTrash(Email email) { - setState(() { - final index = _allEmails.indexWhere((e) => e.id == email.id); - if (index != -1) { - _allEmails[index] = email.copyWith(folder: EmailFolder.trash); - _filterEmails(_searchController.text); - } - }); + void _search() { + setState(() {}); } Future _handlePop(BuildContext context) async { @@ -114,7 +68,6 @@ class _EmailClientScreenState extends State { setState(() { _isSearching = false; _searchController.clear(); - _filterEmails(''); }); return; } @@ -125,8 +78,19 @@ class _EmailClientScreenState extends State { Navigator.of(context).maybePop(); } + @override + void dispose() { + _selectionController.removeListener(_onSelectionChanged); + _selectionController.dispose(); + _searchController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final emailService = Provider.of(context); + final filteredEmails = emailService.filterEmails(_searchController.text, EmailFolder.inbox); + return PopScope( canPop: true, onPopInvoked: (didPop) async { @@ -143,7 +107,7 @@ class _EmailClientScreenState extends State { hintText: 'Search emails...', border: InputBorder.none, ), - onChanged: _filterEmails, + onChanged: (_) => _search(), ) : const Text('RubMail'), leading: _isSearching @@ -153,7 +117,6 @@ class _EmailClientScreenState extends State { setState(() { _isSearching = false; _searchController.clear(); - _filterEmails(''); }); }, ) @@ -167,7 +130,7 @@ class _EmailClientScreenState extends State { if (_selectionController.isSelecting) ...[ IconButton( icon: const Icon(Icons.select_all), - onPressed: () => _selectionController.selectAll(_filteredEmails), + onPressed: () => _selectionController.selectAll(filteredEmails), ), IconButton( icon: const Icon(Icons.close), @@ -183,54 +146,44 @@ class _EmailClientScreenState extends State { ), ], ), - endDrawer: EmailDrawer(allEmails: _allEmails), + endDrawer: const EmailDrawer(), body: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _selectionController.isSelecting ? _selectionController.clearSelection : null, child: RefreshIndicator( onRefresh: () async { await Future.delayed(const Duration(seconds: 1)); - _filterEmails(_searchController.text); + _search(); }, child: ListView.separated( - itemCount: _filteredEmails.length, + itemCount: filteredEmails.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, index) { - final email = _filteredEmails[index]; - return InkWell( + final email = filteredEmails[index]; + return EmailTile( + email: email, + isSelected: _selectionController.isSelected(email), + onTap: () { + if (_selectionController.isSelecting) { + setState(() => _selectionController.toggleSelection(email)); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView( + email: email, + onDelete: (email) { + emailService.moveEmailsToFolder([email], EmailFolder.trash); + _search(); + }, + ), + ), + ); + } + }, onLongPress: () { - setState(() { - _selectionController.toggleSelection(email); - }); + setState(() => _selectionController.toggleSelection(email)); }, - child: EmailTile( - email: email, - isSelected: _selectionController.isSelected(email), - onTap: () { - //if selecting tapping toggles selection - if (_selectionController.isSelecting) { - setState(() { - _selectionController.toggleSelection(email); - }); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EmailView( - email: email, - onDelete: _moveToTrash, - ), - ), - ); - } - }, - onLongPress: () { - //bulk selection - setState(() { - _selectionController.toggleSelection(email); - }); - }, - ), ); }, ), @@ -265,7 +218,6 @@ class _EmailClientScreenState extends State { ); } } - // Trash is fully implemented so far. // TODO: Sent, Archives, Drafts // TODO: Settings, so far I am unsure what to add in here. @@ -275,6 +227,6 @@ class _EmailClientScreenState extends State { // TODO: extract _filterEmails as a separate component(maybe?) // Check: 'package:flutter_secure_storage/flutter_secure_storage.dart'; -// Check: ticket_datasource +// Check: ticket_datasource(pages/wallet/ticket/) and injection.dart (in lib/core) // Use Login, make it go to Email // Check IMAP plugins (for dart/flutter): enough_mail?s diff --git a/lib/pages/email_client/email_drawer.dart b/lib/pages/email_client/email_drawer.dart index 53421cea..15f8b7b8 100644 --- a/lib/pages/email_client/email_drawer.dart +++ b/lib/pages/email_client/email_drawer.dart @@ -1,14 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/email_drawer/archives.dart'; import 'package:campus_app/pages/email_client/email_drawer/drafts.dart'; import 'package:campus_app/pages/email_client/email_drawer/sent.dart'; import 'package:campus_app/pages/email_client/email_drawer/trash.dart'; class EmailDrawer extends StatelessWidget { - final List allEmails; - - const EmailDrawer({super.key, required this.allEmails}); + const EmailDrawer({super.key}); @override Widget build(BuildContext context) { @@ -43,64 +40,48 @@ class EmailDrawer extends StatelessWidget { ], ), ), + + // Inbox without navigation for example: ListTile( leading: const Icon(Icons.inbox), title: const Text('Inbox'), - onTap: () { - Navigator.pop(context); // Just closes drawer (you're already in inbox) - }, + onTap: () => Navigator.pop(context), ), - ListTile( - leading: const Icon(Icons.send), - title: const Text('Sent'), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (_) => SentPage(allEmails: allEmails)), - ); - }, + + // Using helper method to reduce repetition: + _buildDrawerItem( + context, + icon: Icons.send, + title: 'Sent', + page: const SentPage(), ), - ListTile( - leading: const Icon(Icons.archive), - title: const Text('Archives'), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (_) => ArchivesPage(allEmails: allEmails)), - ); - }, + _buildDrawerItem( + context, + icon: Icons.archive, + title: 'Archives', + page: const ArchivesPage(), ), - ListTile( - leading: const Icon(Icons.drafts), - title: const Text('Drafts'), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (_) => DraftsPage(allEmails: allEmails)), - ); - }, + _buildDrawerItem( + context, + icon: Icons.drafts, + title: 'Drafts', + page: const DraftsPage(), ), - ListTile( - leading: const Icon(Icons.delete), - title: const Text('Trash'), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrashPage(allEmails: allEmails)), - ); - }, + _buildDrawerItem( + context, + icon: Icons.delete, + title: 'Trash', + page: const TrashPage(), ), + const Divider(), + ListTile( leading: const Icon(Icons.settings), title: const Text('Settings'), onTap: () { Navigator.pop(context); - // TODO: Add SettingsPage() later + // TODO: Add SettingsPage() navigation here later }, ), ], @@ -108,4 +89,27 @@ class EmailDrawer extends StatelessWidget { ), ); } + + Widget _buildDrawerItem( + BuildContext context, { + required IconData icon, + required String title, + required Widget page, + }) { + return ListTile( + leading: Icon(icon), + title: Text(title), + onTap: () { + Navigator.pop(context); // close the drawer first + + // Delay navigation until after drawer closes + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => page), + ); + }); + }, + ); + } } diff --git a/lib/pages/email_client/email_drawer/archives.dart b/lib/pages/email_client/email_drawer/archives.dart index 43d6444e..96ad940a 100644 --- a/lib/pages/email_client/email_drawer/archives.dart +++ b/lib/pages/email_client/email_drawer/archives.dart @@ -1,43 +1,34 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; -import 'package:campus_app/pages/email_client//email_view.dart'; +import 'package:campus_app/pages/email_client/email_view.dart'; class ArchivesPage extends StatelessWidget { - final List allEmails; - - const ArchivesPage({Key? key, required this.allEmails}) : super(key: key); + const ArchivesPage({super.key}); @override Widget build(BuildContext context) { - // Filter emails by "archives" folder - final archivedEmails = allEmails.where((email) => email.folder == EmailFolder.archives).toList(); + final emailService = Provider.of(context, listen: false); + final archivedEmails = emailService.filterEmails('', EmailFolder.archives); return Scaffold( - appBar: AppBar( - title: const Text('Archived Emails'), - ), + appBar: AppBar(title: const Text('Archives')), body: archivedEmails.isEmpty - ? const Center( - child: Text('No archived emails.'), - ) - : ListView.separated( + ? const Center(child: Text('No archived emails')) + : ListView.builder( itemCount: archivedEmails.length, - separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final email = archivedEmails[index]; return EmailTile( email: email, - isSelected: false, - onLongPress: () {}, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EmailView(email: email), - ), - ); - }, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), + ), ); }, ), diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart index e7192071..77edc2df 100644 --- a/lib/pages/email_client/email_drawer/drafts.dart +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -1,35 +1,36 @@ import 'package:flutter/material.dart'; -import 'package:campus_app/pages/email_client/email_view.dart'; -import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/email_view.dart'; class DraftsPage extends StatelessWidget { - final List allEmails; - - const DraftsPage({super.key, required this.allEmails}); + const DraftsPage({super.key}); @override Widget build(BuildContext context) { - final draftEmails = allEmails.where((e) => e.folder == EmailFolder.drafts).toList(); + final emailService = Provider.of(context, listen: false); + final draftEmails = emailService.filterEmails('', EmailFolder.drafts); return Scaffold( appBar: AppBar(title: const Text('Drafts')), body: draftEmails.isEmpty - ? const Center(child: Text('No drafts available.')) - : ListView.separated( + ? const Center(child: Text('No draft emails')) + : ListView.builder( itemCount: draftEmails.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (_, index) => EmailTile( - email: draftEmails[index], - isSelected: false, - onLongPress: () {}, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EmailView(email: draftEmails[index]), + itemBuilder: (context, index) { + final email = draftEmails[index]; + return EmailTile( + email: email, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), ), - ), - ), + ); + }, ), ); } diff --git a/lib/pages/email_client/email_drawer/sent.dart b/lib/pages/email_client/email_drawer/sent.dart index 2046701e..b831519a 100644 --- a/lib/pages/email_client/email_drawer/sent.dart +++ b/lib/pages/email_client/email_drawer/sent.dart @@ -1,35 +1,36 @@ import 'package:flutter/material.dart'; -import 'package:campus_app/pages/email_client/email_view.dart'; -import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/email_view.dart'; class SentPage extends StatelessWidget { - final List allEmails; - - const SentPage({super.key, required this.allEmails}); + const SentPage({super.key}); @override Widget build(BuildContext context) { - final sentEmails = allEmails.where((e) => e.folder == EmailFolder.sent).toList(); + final emailService = Provider.of(context, listen: false); + final sentEmails = emailService.filterEmails('', EmailFolder.sent); return Scaffold( - appBar: AppBar(title: const Text('Sent')), + appBar: AppBar(title: const Text('Sent Emails')), body: sentEmails.isEmpty - ? const Center(child: Text('No sent emails.')) - : ListView.separated( + ? const Center(child: Text('No sent emails')) + : ListView.builder( itemCount: sentEmails.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (_, index) => EmailTile( - email: sentEmails[index], - isSelected: false, - onLongPress: () {}, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EmailView(email: sentEmails[index]), + itemBuilder: (context, index) { + final email = sentEmails[index]; + return EmailTile( + email: email, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), ), - ), - ), + ); + }, ), ); } diff --git a/lib/pages/email_client/email_drawer/trash.dart b/lib/pages/email_client/email_drawer/trash.dart index 57faf00f..d07eac16 100644 --- a/lib/pages/email_client/email_drawer/trash.dart +++ b/lib/pages/email_client/email_drawer/trash.dart @@ -1,24 +1,48 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/email_view.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; -class TrashPage extends StatefulWidget { - final List allEmails; - - const TrashPage({super.key, required this.allEmails}); +class TrashPage extends StatelessWidget { + const TrashPage({super.key}); - @override - State createState() => _TrashPageState(); -} - -class _TrashPageState extends State { @override Widget build(BuildContext context) { - final trashEmails = widget.allEmails.where((e) => e.folder == EmailFolder.trash).toList(); + final emailService = Provider.of(context, listen: false); + final trashEmails = emailService.filterEmails('', EmailFolder.trash); return Scaffold( - appBar: AppBar(title: const Text('Trash')), + appBar: AppBar( + title: Text( + emailService.selectionController.isSelecting + ? '${emailService.selectionController.selectionCount} selected' + : 'Trash', + ), + actions: [ + if (emailService.selectionController.isSelecting) ...[ + IconButton( + icon: const Icon(Icons.restore), + onPressed: () { + emailService.moveEmailsToFolder( + emailService.selectionController.selectedEmails, + EmailFolder.inbox, + ); + emailService.selectionController.clearSelection(); + }, + ), + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () { + emailService.deleteEmailsPermanently( + emailService.selectionController.selectedEmails, + ); + }, + ), + ], + ], + ), body: trashEmails.isEmpty ? const Center(child: Text('Trash is empty.')) : ListView.separated( @@ -28,30 +52,25 @@ class _TrashPageState extends State { final email = trashEmails[index]; return EmailTile( email: email, - isSelected: false, - onLongPress: () {}, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EmailView( - email: email, - isInTrash: true, - onDelete: (Email emailToDelete) { - setState(() { - widget.allEmails.removeWhere((e) => e.id == emailToDelete.id); - }); - }, - onRestore: (Email emailToRestore) { - setState(() { - final index = widget.allEmails.indexWhere((e) => e.id == emailToRestore.id); - if (index != -1) { - widget.allEmails[index] = widget.allEmails[index].copyWith(folder: EmailFolder.inbox); - } - }); - }, - ), - ), - ), + isSelected: emailService.selectionController.isSelected(email), + onTap: () { + if (emailService.selectionController.isSelecting) { + emailService.selectionController.toggleSelection(email); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView( + email: email, + isInTrash: true, + onDelete: (email) => emailService.deleteEmailsPermanently([email]), + onRestore: (email) => emailService.moveEmailsToFolder([email], EmailFolder.inbox), + ), + ), + ); + } + }, + onLongPress: () => emailService.selectionController.toggleSelection(email), ); }, ), diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index 94930321..189b4141 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -1,44 +1,58 @@ +import 'package:flutter/foundation.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/widgets/select_email.dart'; -class EmailService { +class EmailService extends ChangeNotifier { final List _allEmails; + final EmailSelectionController _selectionController = EmailSelectionController(); - EmailService(this._allEmails); + // Modified constructor: Generates dummy emails internally + EmailService() : _allEmails = List.generate(10, (i) => Email.dummy(i)) { + _selectionController.addListener(notifyListeners); + } + + // Public API (unchanged) + List get allEmails => List.unmodifiable(_allEmails); + EmailSelectionController get selectionController => _selectionController; List filterEmails(String query, EmailFolder folder) { - if (query.isEmpty) { - return _allEmails.where((e) => e.folder == folder).toList(); - } - return _allEmails + final filtered = _allEmails.where((e) => e.folder == folder).toList(); + if (query.isEmpty) return filtered; + + return filtered .where((email) => - email.folder == folder && - (email.sender.toLowerCase().contains(query.toLowerCase()) || - email.subject.toLowerCase().contains(query.toLowerCase()))) + email.sender.toLowerCase().contains(query.toLowerCase()) || + email.subject.toLowerCase().contains(query.toLowerCase())) .toList(); } - Future archiveSelected(Set emails) async { - for (final email in emails) { - final index = _allEmails.indexWhere((e) => e.id == email.id); - if (index != -1) { - _allEmails[index] = email.copyWith(folder: EmailFolder.archives); - } + void updateEmail(Email updatedEmail) { + final index = _allEmails.indexWhere((e) => e.id == updatedEmail.id); + if (index != -1) { + _allEmails[index] = updatedEmail; + notifyListeners(); } } - Future deleteSelected(Set emails) async { + void moveEmailsToFolder(Iterable emails, EmailFolder folder) { for (final email in emails) { final index = _allEmails.indexWhere((e) => e.id == email.id); if (index != -1) { - _allEmails[index] = email.copyWith(folder: EmailFolder.trash); + _allEmails[index] = email.copyWith(folder: folder); } } + notifyListeners(); } - void updateEmail(Email updatedEmail) { - final index = _allEmails.indexWhere((e) => e.id == updatedEmail.id); - if (index != -1) { - _allEmails[index] = updatedEmail; - } + void deleteEmailsPermanently(Iterable emails) { + _allEmails.removeWhere((e) => emails.any((email) => email.id == e.id)); + _selectionController.clearSelection(); + notifyListeners(); + } + + @override + void dispose() { + _selectionController.dispose(); + super.dispose(); } } diff --git a/lib/pages/email_client/widgets/email_tile.dart b/lib/pages/email_client/widgets/email_tile.dart index 194f9c66..8de10e33 100644 --- a/lib/pages/email_client/widgets/email_tile.dart +++ b/lib/pages/email_client/widgets/email_tile.dart @@ -5,13 +5,13 @@ class EmailTile extends StatelessWidget { final Email email; final bool isSelected; final VoidCallback onTap; - final VoidCallback onLongPress; + final VoidCallback? onLongPress; // Made optional const EmailTile({ super.key, required this.email, required this.onTap, - required this.onLongPress, + this.onLongPress, // Now optional this.isSelected = false, }); @@ -25,67 +25,79 @@ class EmailTile extends StatelessWidget { return InkWell( onTap: onTap, - onLongPress: onLongPress, + onLongPress: onLongPress, // Will be null if not provided child: Container( color: bgColor, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - isSelected - ? const Icon(Icons.check_circle, color: Colors.blue) - : const CircleAvatar( - radius: 20, - backgroundColor: Colors.grey, - child: Icon(Icons.person, color: Colors.white), - ), + _buildLeadingIcon(), const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - email.sender, - style: TextStyle( - fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, - ), - ), - const SizedBox(height: 4), - Text( - email.subject, - style: TextStyle( - fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, - ), - ), - const SizedBox(height: 4), - Text( - email.preview, - style: TextStyle( - color: Colors.grey[600], - fontWeight: email.isUnread ? FontWeight.w500 : FontWeight.normal, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Column( - children: [ - Text( - '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}', - style: TextStyle( - color: Colors.grey, - fontSize: 12, - fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, - ), - ), - if (email.isStarred) const Icon(Icons.star, color: Colors.amber, size: 16), - ], - ), + _buildEmailContent(), + _buildTrailingInfo(), ], ), ), ); } + + Widget _buildLeadingIcon() { + return isSelected + ? const Icon(Icons.check_circle, color: Colors.blue) + : const CircleAvatar( + radius: 20, + backgroundColor: Colors.grey, + child: Icon(Icons.person, color: Colors.white), + ); + } + + Widget _buildEmailContent() { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + email.sender, + style: TextStyle( + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Text( + email.subject, + style: TextStyle( + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Text( + email.preview, + style: TextStyle( + color: Colors.grey[600], + fontWeight: email.isUnread ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + Widget _buildTrailingInfo() { + return Column( + children: [ + Text( + '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + if (email.isStarred) const Icon(Icons.star, color: Colors.amber, size: 16), + ], + ); + } } diff --git a/lib/pages/email_client/widgets/select_email.dart b/lib/pages/email_client/widgets/select_email.dart index 3dcd03b3..05bf979e 100644 --- a/lib/pages/email_client/widgets/select_email.dart +++ b/lib/pages/email_client/widgets/select_email.dart @@ -5,50 +5,41 @@ class EmailSelectionController extends ChangeNotifier { final Set _selectedEmails = {}; final Future Function(Set)? onDelete; final Future Function(Set)? onArchive; - final void Function(Email updatedEmail)? onEmailUpdated; + final Future Function(Email)? onEmailUpdated; // Changed to Future - EmailSelectionController({this.onDelete, this.onArchive, this.onEmailUpdated}); + EmailSelectionController({ + this.onDelete, + this.onArchive, + this.onEmailUpdated, + }); - // Public API + // Public API (unchanged) Set get selectedEmails => Set.unmodifiable(_selectedEmails); bool get isSelecting => _selectedEmails.isNotEmpty; bool isSelected(Email email) => _selectedEmails.contains(email); int get selectionCount => _selectedEmails.length; - // Selection management + // Selection management (unchanged) void toggleSelection(Email email) { _selectedEmails.contains(email) ? _selectedEmails.remove(email) : _selectedEmails.add(email); notifyListeners(); } - void toggleSelections(Iterable emails) { - for (final email in emails) { - toggleSelection(email); - } - } - void selectAll(Iterable emails) { _selectedEmails.addAll(emails); notifyListeners(); } - void selectSingle(Email email) { - _selectedEmails - ..clear() - ..add(email); - notifyListeners(); - } - void clearSelection() { _selectedEmails.clear(); notifyListeners(); } - // Email state modifications + // Updated async methods Future markAsReadSelected() async { for (final email in _selectedEmails) { final updatedEmail = email.copyWith(isUnread: false); - onEmailUpdated?.call(updatedEmail); // Notify parent + await onEmailUpdated?.call(updatedEmail); // Added await } notifyListeners(); } @@ -56,16 +47,31 @@ class EmailSelectionController extends ChangeNotifier { Future markAsUnreadSelected() async { for (final email in _selectedEmails) { final updatedEmail = email.copyWith(isUnread: true); - onEmailUpdated?.call(updatedEmail); + await onEmailUpdated?.call(updatedEmail); // Added await } notifyListeners(); } - void toggleReadState() { + Future toggleReadState() async { + // Made async final allUnread = _selectedEmails.every((e) => e.isUnread); for (final email in _selectedEmails) { - onEmailUpdated?.call(email.copyWith(isUnread: !allUnread)); + await onEmailUpdated?.call(email.copyWith(isUnread: !allUnread)); // Added await + } + notifyListeners(); + } + + // New method for batch operations + Future performBatchOperation(Future Function(Email) operation) async { + for (final email in _selectedEmails) { + await operation(email); } notifyListeners(); } + + @override + void dispose() { + _selectedEmails.clear(); + super.dispose(); + } } From 559ebf36dbf65923927378b3d982ad683a8286ce Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Mon, 26 May 2025 21:29:49 +0200 Subject: [PATCH 11/20] removed unnecessary import --- lib/main.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/main.dart b/lib/main.dart index 60366742..f068abda 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,7 +31,6 @@ import 'package:campus_app/utils/pages/main_utils.dart'; import 'package:campus_app/utils/pages/mensa_utils.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; -import 'package:campus_app/pages/email_client/models/email.dart'; Future main() async { final WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); From f586d9aa0a94408fedee360a60de662be9836ed3 Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Wed, 28 May 2025 17:30:39 +0200 Subject: [PATCH 12/20] added drafts and some login logic --- lib/core/injection.dart | 9 + .../email_client/compose_email_Screen.dart | 273 +++++++++++------- lib/pages/email_client/email_client_page.dart | 17 +- .../email_client/email_client_screen.dart | 71 +++++ lib/pages/email_client/email_drawer.dart | 4 +- .../email_client/email_drawer/drafts.dart | 139 +++++++-- lib/pages/email_client/email_view.dart | 4 +- .../services/email_auth_service.dart | 50 ++++ .../email_client/services/email_service.dart | 59 +++- .../email_client/widgets/email_tile.dart | 2 +- lib/pages/feed/widgets/feed_filter_popup.dart | 1 + .../mensa/widgets/preferences_popup.dart | 1 + lib/pages/more/more_page.dart | 6 +- lib/utils/pages/mensa_utils.dart | 4 +- 14 files changed, 500 insertions(+), 140 deletions(-) create mode 100644 lib/pages/email_client/email_client_screen.dart create mode 100644 lib/pages/email_client/services/email_auth_service.dart diff --git a/lib/core/injection.dart b/lib/core/injection.dart index 3240dbaf..34252b6e 100644 --- a/lib/core/injection.dart +++ b/lib/core/injection.dart @@ -27,6 +27,7 @@ import 'package:campus_app/utils/pages/main_utils.dart'; import 'package:campus_app/utils/dio_utils.dart'; import 'package:campus_app/utils/constants.dart'; import 'package:native_dio_adapter/native_dio_adapter.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; final sl = GetIt.instance; // service locator @@ -114,6 +115,14 @@ Future init() async { () => TicketUsecases(ticketRepository: sl()), ); + //! + //! Services + //! + + sl.registerLazySingleton( + () => EmailAuthService(secureStorage: sl()), + ); + //! //! Utils //! diff --git a/lib/pages/email_client/compose_email_Screen.dart b/lib/pages/email_client/compose_email_Screen.dart index 2d405f0e..396938c8 100644 --- a/lib/pages/email_client/compose_email_Screen.dart +++ b/lib/pages/email_client/compose_email_Screen.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; class ComposeEmailScreen extends StatefulWidget { + final Email? draft; final Email? replyTo; final Email? forwardFrom; const ComposeEmailScreen({ super.key, + this.draft, this.replyTo, this.forwardFrom, }); @@ -23,13 +27,17 @@ class _ComposeEmailScreenState extends State { final _subjectController = TextEditingController(); final _bodyController = TextEditingController(); bool _showCcBcc = false; - List _attachments = []; + final List _attachments = []; @override void initState() { super.initState(); - // Pre-fill fields if replying or forwarding - if (widget.replyTo != null) { + if (widget.draft != null) { + _toController.text = widget.draft!.recipients.join(', '); + _subjectController.text = widget.draft!.subject; + _bodyController.text = widget.draft!.body; + _attachments.addAll(widget.draft!.attachments); + } else if (widget.replyTo != null) { _toController.text = widget.replyTo!.senderEmail; _subjectController.text = 'Re: ${widget.replyTo!.subject}'; _bodyController.text = '\n\n----------\n${widget.replyTo!.body}'; @@ -49,8 +57,65 @@ class _ComposeEmailScreenState extends State { super.dispose(); } + // Check if the current composition is empty + bool _isCompositionEmpty() { + return _toController.text.trim().isEmpty && + _ccController.text.trim().isEmpty && + _bccController.text.trim().isEmpty && + _subjectController.text.trim().isEmpty && + _bodyController.text.trim().isEmpty && + _attachments.isEmpty; + } + + // Check if the composition has any meaningful content + bool _hasContent() { + return _toController.text.trim().isNotEmpty || + _subjectController.text.trim().isNotEmpty || + _bodyController.text.trim().isNotEmpty || + _attachments.isNotEmpty; + } + + // Save or update the draft only if it has content + void _saveDraft(EmailService emailService) { + // Only save if there's actual content + if (!_hasContent()) { + // If this was an existing draft that's now empty, remove it + if (widget.draft != null) { + emailService.removeDraft(widget.draft!.id); + } + return; // Don't save empty compositions + } + + final newDraft = Email( + id: widget.draft?.id ?? DateTime.now().millisecondsSinceEpoch.toString(), + sender: 'Me', + senderEmail: 'me@example.com', + recipients: _toController.text.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(), + subject: _subjectController.text, + body: _bodyController.text, + date: DateTime.now(), + attachments: List.from(_attachments), + folder: EmailFolder.drafts, + ); + emailService.saveOrUpdateDraft(newDraft); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Draft saved'), + duration: Duration(seconds: 2), + ), + ); + } + + // Send email and remove draft if exists void _sendEmail() { if (_formKey.currentState!.validate()) { + final emailService = Provider.of(context, listen: false); + if (widget.draft != null) { + emailService.removeDraft(widget.draft!.id); + } + + // TODO: Implement actual sending logic here + ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Email sent')), ); @@ -58,7 +123,7 @@ class _ComposeEmailScreenState extends State { } } - void _attachFile() async { + Future _attachFile() async { // TODO: Implement file attachment setState(() { _attachments.add('file_${_attachments.length + 1}.pdf'); @@ -67,121 +132,135 @@ class _ComposeEmailScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.replyTo != null ? 'Reply' : 'Compose'), - actions: [ - IconButton( - icon: const Icon(Icons.attach_file), - onPressed: _attachFile, - tooltip: 'Attach file', - ), - IconButton( - icon: const Icon(Icons.send), - onPressed: _sendEmail, - tooltip: 'Send', - ), - ], - ), - body: Form( - key: _formKey, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - // To Field - TextFormField( - controller: _toController, - decoration: const InputDecoration( - labelText: 'To', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter recipient'; - } - return null; - }, - ), - const SizedBox(height: 8), - - // CC/BCC Toggle - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () => setState(() => _showCcBcc = !_showCcBcc), - child: Text(_showCcBcc ? 'Hide CC/BCC' : 'Add CC/BCC'), - ), - ), + final emailService = Provider.of(context, listen: false); - // CC Field (conditional) - if (_showCcBcc) ...[ + return WillPopScope( + onWillPop: () async { + _saveDraft(emailService); + return true; + }, + child: Scaffold( + appBar: AppBar( + title: Text( + widget.replyTo != null + ? 'Reply' + : widget.draft != null + ? 'Edit Draft' + : 'Compose', + ), + actions: [ + IconButton( + icon: const Icon(Icons.attach_file), + onPressed: _attachFile, + tooltip: 'Attach file', + ), + IconButton( + icon: const Icon(Icons.send), + onPressed: _sendEmail, + tooltip: 'Send', + ), + ], + ), + body: Form( + key: _formKey, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // To Field TextFormField( - controller: _ccController, + controller: _toController, decoration: const InputDecoration( - labelText: 'CC', + labelText: 'To', border: OutlineInputBorder(), ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter recipient'; + } + return null; + }, ), const SizedBox(height: 8), - ], - // BCC Field (conditional) - if (_showCcBcc) ...[ + // CC/BCC Toggle + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => setState(() => _showCcBcc = !_showCcBcc), + child: Text(_showCcBcc ? 'Hide CC/BCC' : 'Add CC/BCC'), + ), + ), + + // CC Field (conditional) + if (_showCcBcc) ...[ + TextFormField( + controller: _ccController, + decoration: const InputDecoration( + labelText: 'CC', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + ], + + // BCC Field (conditional) + if (_showCcBcc) ...[ + TextFormField( + controller: _bccController, + decoration: const InputDecoration( + labelText: 'BCC', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + ], + + // Subject Field TextFormField( - controller: _bccController, + controller: _subjectController, decoration: const InputDecoration( - labelText: 'BCC', + labelText: 'Subject', border: OutlineInputBorder(), ), ), const SizedBox(height: 8), - ], - // Subject Field - TextFormField( - controller: _subjectController, - decoration: const InputDecoration( - labelText: 'Subject', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - - // Attachments - if (_attachments.isNotEmpty) ...[ - SizedBox( - height: 50, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: _attachments.length, - itemBuilder: (context, index) => Chip( - label: Text(_attachments[index]), - deleteIcon: const Icon(Icons.close, size: 18), - onDeleted: () => setState(() => _attachments.removeAt(index)), + // Attachments + if (_attachments.isNotEmpty) ...[ + SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _attachments.length, + itemBuilder: (context, index) => Chip( + label: Text(_attachments[index]), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => setState(() => _attachments.removeAt(index)), + ), ), ), - ), - const SizedBox(height: 8), - ], + const SizedBox(height: 8), + ], - // Email Body - Expanded( - child: TextFormField( - controller: _bodyController, - decoration: const InputDecoration( - hintText: 'Compose your email...', - border: InputBorder.none, + // Email Body + Expanded( + child: TextFormField( + controller: _bodyController, + decoration: const InputDecoration( + hintText: 'Compose your email...', + border: InputBorder.none, + ), + maxLines: null, + expands: true, + keyboardType: TextInputType.multiline, ), - maxLines: null, - expands: true, - keyboardType: TextInputType.multiline, ), - ), - ], + ], + ), ), ), ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/email_client/email_client_page.dart b/lib/pages/email_client/email_client_page.dart index 49672bc8..0a5eac86 100644 --- a/lib/pages/email_client/email_client_page.dart +++ b/lib/pages/email_client/email_client_page.dart @@ -8,8 +8,8 @@ import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; import 'package:campus_app/pages/email_client/widgets/select_email.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; -class EmailClientScreen extends StatelessWidget { - const EmailClientScreen({super.key}); +class EmailClientPage extends StatelessWidget { + const EmailClientPage({super.key}); @override Widget build(BuildContext context) { @@ -92,7 +92,6 @@ class _EmailClientContentState extends State<_EmailClientContent> { final filteredEmails = emailService.filterEmails(_searchController.text, EmailFolder.inbox); return PopScope( - canPop: true, onPopInvoked: (didPop) async { if (!didPop) await _handlePop(context); }, @@ -218,15 +217,15 @@ class _EmailClientContentState extends State<_EmailClientContent> { ); } } -// Trash is fully implemented so far. +// Trash is fully implemented so far(not anymore) // TODO: Sent, Archives, Drafts -// TODO: Settings, so far I am unsure what to add in here. - -// TODO: add selection functionality, preferably as an independent file to be used anywhere. - -// TODO: extract _filterEmails as a separate component(maybe?) +// TODO: Settings: I am unsure what to add in here. // Check: 'package:flutter_secure_storage/flutter_secure_storage.dart'; // Check: ticket_datasource(pages/wallet/ticket/) and injection.dart (in lib/core) // Use Login, make it go to Email + // Check IMAP plugins (for dart/flutter): enough_mail?s + // SMPT und IMAP client => API + // UI und Backend separate, start with UI it is easier. + // flutter secure storage login daten, take it from there \ No newline at end of file diff --git a/lib/pages/email_client/email_client_screen.dart b/lib/pages/email_client/email_client_screen.dart new file mode 100644 index 00000000..965e3b9b --- /dev/null +++ b/lib/pages/email_client/email_client_screen.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/email_client_page.dart'; +import 'package:campus_app/pages/wallet/ticket_login_screen.dart'; + +class EmailClientScreen extends StatefulWidget { + const EmailClientScreen({super.key}); + + @override + State createState() => _EmailClientScreenState(); +} + +class _EmailClientScreenState extends State { + final EmailAuthService _emailAuthService = GetIt.instance(); + bool _isLoading = true; + bool _hasCredentials = false; + + @override + void initState() { + super.initState(); + _checkAuthStatus(); + } + + Future _checkAuthStatus() async { + try { + final hasCredentials = await _emailAuthService.hasValidCredentials(); + if (mounted) { + setState(() { + _hasCredentials = hasCredentials; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _hasCredentials = false; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (!_hasCredentials) { + // Navigate to login screen with callback to return to email + return TicketLoginScreen( + onTicketLoaded: () { + // After successful login, navigate to email client + if (mounted) { + setState(() { + _hasCredentials = true; + }); + } + }, + ); + } + + // User has credentials, show email client + return const EmailClientPage(); + } +} diff --git a/lib/pages/email_client/email_drawer.dart b/lib/pages/email_client/email_drawer.dart index 15f8b7b8..4685eea0 100644 --- a/lib/pages/email_client/email_drawer.dart +++ b/lib/pages/email_client/email_drawer.dart @@ -21,9 +21,9 @@ class EmailDrawer extends StatelessWidget { decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, ), - child: Column( + child: const Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ + children: [ CircleAvatar( radius: 25, child: Icon(Icons.person, size: 30), diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart index 77edc2df..6d80b67f 100644 --- a/lib/pages/email_client/email_drawer/drafts.dart +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -3,35 +3,138 @@ import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; -import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/compose_email_screen.dart'; -class DraftsPage extends StatelessWidget { +class DraftsPage extends StatefulWidget { const DraftsPage({super.key}); + @override + State createState() => _DraftsPageState(); +} + +class _DraftsPageState extends State { @override Widget build(BuildContext context) { - final emailService = Provider.of(context, listen: false); - final draftEmails = emailService.filterEmails('', EmailFolder.drafts); + final emailService = Provider.of(context); + final selectionController = emailService.selectionController; + final drafts = emailService.allEmails.where((e) => e.folder == EmailFolder.drafts).toList() + ..sort((a, b) => b.date.compareTo(a.date)); // newest first return Scaffold( - appBar: AppBar(title: const Text('Drafts')), - body: draftEmails.isEmpty - ? const Center(child: Text('No draft emails')) - : ListView.builder( - itemCount: draftEmails.length, - itemBuilder: (context, index) { - final email = draftEmails[index]; + appBar: _buildAppBar(selectionController, drafts, emailService), + body: drafts.isEmpty + ? _buildEmptyState() + : ListView.separated( + itemCount: drafts.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, index) { + final draft = drafts[index]; return EmailTile( - email: email, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => EmailView(email: email), - ), - ), + email: draft, + isSelected: selectionController.isSelected(draft), + onTap: () => _handleEmailTap(draft, selectionController), + onLongPress: () => _handleEmailLongPress(draft, selectionController), ); }, ), ); } + + PreferredSizeWidget _buildAppBar(selectionController, List drafts, EmailService emailService) { + if (selectionController.isSelecting) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => selectionController.clearSelection(), + ), + title: Text('${selectionController.selectionCount} selected'), + actions: [ + IconButton( + icon: const Icon(Icons.select_all), + onPressed: () => selectionController.selectAll(drafts), + ), + IconButton( + icon: const Icon(Icons.delete_forever), + onPressed: () => _showDeleteConfirmation(selectionController, emailService), + ), + ], + ); + } + + return AppBar( + title: const Text('Drafts'), + ); + } + + Widget _buildEmptyState() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.drafts_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'No drafts', + style: TextStyle( + fontSize: 18, + color: Colors.grey, + ), + ), + ], + ), + ); + } + + void _handleEmailTap(Email draft, selectionController) { + if (selectionController.isSelecting) { + selectionController.toggleSelection(draft); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ComposeEmailScreen(draft: draft), + ), + ); + } + } + + void _handleEmailLongPress(Email draft, selectionController) { + if (!selectionController.isSelecting) { + selectionController.toggleSelection(draft); + } + } + + void _showDeleteConfirmation(selectionController, EmailService emailService) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Drafts'), + content: Text( + 'Are you sure you want to permanently delete ${selectionController.selectionCount} draft(s)?\n\nThis action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + emailService.deleteEmailsPermanently(selectionController.selectedEmails); + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } + + bool _isDraftEmpty(Email draft) { + return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; + } } diff --git a/lib/pages/email_client/email_view.dart b/lib/pages/email_client/email_view.dart index 91a78958..f9808b41 100644 --- a/lib/pages/email_client/email_view.dart +++ b/lib/pages/email_client/email_view.dart @@ -106,7 +106,7 @@ class EmailView extends StatelessWidget { ], ), body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -194,8 +194,8 @@ class EmailView extends StatelessWidget { floatingActionButton: !isInTrash ? FloatingActionButton( onPressed: () => _handleReply(context), - child: const Icon(Icons.reply), tooltip: 'Reply', + child: const Icon(Icons.reply), ) : null, ); diff --git a/lib/pages/email_client/services/email_auth_service.dart b/lib/pages/email_client/services/email_auth_service.dart new file mode 100644 index 00000000..0f50fba1 --- /dev/null +++ b/lib/pages/email_client/services/email_auth_service.dart @@ -0,0 +1,50 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +//import 'package:get_it/get_it.dart'; + +class EmailAuthService { + final FlutterSecureStorage _secureStorage; + + EmailAuthService({required FlutterSecureStorage secureStorage}) : _secureStorage = secureStorage; + + /// Check if user has valid login credentials + Future hasValidCredentials() async { + try { + final String? loginId = await _secureStorage.read(key: 'loginId'); + final String? password = await _secureStorage.read(key: 'password'); + + return loginId != null && password != null && loginId.isNotEmpty && password.isNotEmpty; + } catch (e) { + return false; + } + } + + /// Get stored credentials + Future> getCredentials() async { + try { + final String? loginId = await _secureStorage.read(key: 'loginId'); + final String? password = await _secureStorage.read(key: 'password'); + + return { + 'loginId': loginId, + 'password': password, + }; + } catch (e) { + return { + 'loginId': null, + 'password': null, + }; + } + } + + /// Store credentials (if needed for email-specific auth) + Future storeCredentials(String loginId, String password) async { + await _secureStorage.write(key: 'loginId', value: loginId); + await _secureStorage.write(key: 'password', value: password); + } + + /// Clear credentials + Future clearCredentials() async { + await _secureStorage.delete(key: 'loginId'); + await _secureStorage.delete(key: 'password'); + } +} diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index 189b4141..0483530d 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -7,7 +7,7 @@ class EmailService extends ChangeNotifier { final EmailSelectionController _selectionController = EmailSelectionController(); // Modified constructor: Generates dummy emails internally - EmailService() : _allEmails = List.generate(10, (i) => Email.dummy(i)) { + EmailService() : _allEmails = List.generate(10, Email.dummy) { _selectionController.addListener(notifyListeners); } @@ -18,11 +18,12 @@ class EmailService extends ChangeNotifier { List filterEmails(String query, EmailFolder folder) { final filtered = _allEmails.where((e) => e.folder == folder).toList(); if (query.isEmpty) return filtered; - return filtered - .where((email) => - email.sender.toLowerCase().contains(query.toLowerCase()) || - email.subject.toLowerCase().contains(query.toLowerCase())) + .where( + (email) => + email.sender.toLowerCase().contains(query.toLowerCase()) || + email.subject.toLowerCase().contains(query.toLowerCase()), + ) .toList(); } @@ -44,12 +45,60 @@ class EmailService extends ChangeNotifier { notifyListeners(); } + //delete email permanently, possible if email is deleted and is in the trash section. void deleteEmailsPermanently(Iterable emails) { _allEmails.removeWhere((e) => emails.any((email) => email.id == e.id)); _selectionController.clearSelection(); notifyListeners(); } + // Add or update a draft with auto-delete logic + void saveOrUpdateDraft(Email draft) { + // Check if draft is empty and should be auto-deleted + if (_isDraftEmpty(draft)) { + // If it's an existing draft, remove it + final existingIndex = _allEmails.indexWhere((e) => e.id == draft.id); + if (existingIndex != -1) { + _allEmails.removeAt(existingIndex); + notifyListeners(); + } + return; // Don't save empty drafts + } + + final index = _allEmails.indexWhere((e) => e.id == draft.id); + if (index != -1) { + _allEmails[index] = draft.copyWith(folder: EmailFolder.drafts); + } else { + _allEmails.add(draft.copyWith(folder: EmailFolder.drafts)); + } + notifyListeners(); + } + + // Remove a draft (used when sending email) + void removeDraft(String draftId) { + _allEmails.removeWhere((e) => e.id == draftId && e.folder == EmailFolder.drafts); + notifyListeners(); + } + + // Auto-clean empty drafts - can be called periodically or on demand + void cleanEmptyDrafts() { + final emptyDrafts = _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).toList(); + + if (emptyDrafts.isNotEmpty) { + deleteEmailsPermanently(emptyDrafts); + } + } + + // Check if a draft is considered empty + bool _isDraftEmpty(Email draft) { + return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; + } + + // Get count of empty drafts (useful for UI) + int get emptyDraftsCount { + return _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).length; + } + @override void dispose() { _selectionController.dispose(); diff --git a/lib/pages/email_client/widgets/email_tile.dart b/lib/pages/email_client/widgets/email_tile.dart index 8de10e33..826d497d 100644 --- a/lib/pages/email_client/widgets/email_tile.dart +++ b/lib/pages/email_client/widgets/email_tile.dart @@ -96,7 +96,7 @@ class EmailTile extends StatelessWidget { fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, ), ), - if (email.isStarred) const Icon(Icons.star, color: Colors.amber, size: 16), + //if (email.isStarred) const Icon(Icons.star, color: Colors.amber, size: 16), ], ); } diff --git a/lib/pages/feed/widgets/feed_filter_popup.dart b/lib/pages/feed/widgets/feed_filter_popup.dart index fc2b6267..883557a7 100644 --- a/lib/pages/feed/widgets/feed_filter_popup.dart +++ b/lib/pages/feed/widgets/feed_filter_popup.dart @@ -6,6 +6,7 @@ import 'package:campus_app/core/settings.dart'; import 'package:campus_app/core/backend/entities/publisher_entity.dart'; import 'package:campus_app/utils/widgets/campus_filter_selection.dart'; import 'package:campus_app/utils/widgets/popup_sheet.dart'; +import 'package:snapping_sheet_2/snapping_sheet.dart' show SnappingSheet; /// This widget displays the filter options that are available for the /// personal news feed and is used in the [SnappingSheet] widget diff --git a/lib/pages/mensa/widgets/preferences_popup.dart b/lib/pages/mensa/widgets/preferences_popup.dart index 3579b199..e511976d 100644 --- a/lib/pages/mensa/widgets/preferences_popup.dart +++ b/lib/pages/mensa/widgets/preferences_popup.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:campus_app/core/themes.dart'; import 'package:campus_app/utils/widgets/popup_sheet.dart'; import 'package:campus_app/utils/widgets/campus_selection.dart'; +import 'package:snapping_sheet_2/snapping_sheet.dart' show SnappingSheet; /// This widget displays the preference options that are available for the mensa /// page and is used in the [SnappingSheet] widget. diff --git a/lib/pages/more/more_page.dart b/lib/pages/more/more_page.dart index bddc0f17..1ba7bdcf 100644 --- a/lib/pages/more/more_page.dart +++ b/lib/pages/more/more_page.dart @@ -1,5 +1,6 @@ import 'dart:io' show Platform; import 'package:campus_app/pages/email_client/email_client_page.dart'; +import 'package:campus_app/pages/email_client/email_client_screen.dart'; import 'package:campus_app/pages/more/privacy_policy_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -147,11 +148,6 @@ class MorePageState extends State with AutomaticKeepAliveClientMixin API - // UI und Backend separate, start with UI it is easier. - // flutter secure storage login daten, take it from there Container( margin: const EdgeInsets.only(bottom: 30), decoration: BoxDecoration( diff --git a/lib/utils/pages/mensa_utils.dart b/lib/utils/pages/mensa_utils.dart index 040a6e61..f2eca4a5 100644 --- a/lib/utils/pages/mensa_utils.dart +++ b/lib/utils/pages/mensa_utils.dart @@ -71,7 +71,9 @@ class MensaUtils { if (!(['V', 'VG', 'H'].any(filteredMensaPreferences.contains) && filteredMensaPreferences.any(dish.infos.contains)) && - filteredMensaPreferences.where((e) => e == 'V' || e == 'VG' || e == 'H').isNotEmpty) continue; + filteredMensaPreferences.where((e) => e == 'V' || e == 'VG' || e == 'H').isNotEmpty) { + continue; + } meals.add( MealItem( From 7c84550af0d72c4bf0f8b6b1ea6a1656e66233ee Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Fri, 30 May 2025 18:11:11 +0200 Subject: [PATCH 13/20] Made the login screen into a separate widget --- lib/core/injection.dart | 2 +- .../email_client/email_client_screen.dart | 71 ---- ...email_client_page.dart => email_page.dart} | 131 +++++- .../services/email_auth_service.dart | 132 +++++-- .../email_client/services/email_service.dart | 161 +++++++- lib/pages/more/more_page.dart | 5 +- lib/pages/wallet/ticket_login_screen.dart | 373 ++++++++---------- lib/pages/wallet/widgets/wallet.dart | 17 +- lib/utils/widgets/login_screen.dart | 336 ++++++++++++++++ 9 files changed, 895 insertions(+), 333 deletions(-) delete mode 100644 lib/pages/email_client/email_client_screen.dart rename lib/pages/email_client/{email_client_page.dart => email_page.dart} (66%) create mode 100644 lib/utils/widgets/login_screen.dart diff --git a/lib/core/injection.dart b/lib/core/injection.dart index 34252b6e..9bca3b0b 100644 --- a/lib/core/injection.dart +++ b/lib/core/injection.dart @@ -120,7 +120,7 @@ Future init() async { //! sl.registerLazySingleton( - () => EmailAuthService(secureStorage: sl()), + () => EmailAuthService(), ); //! diff --git a/lib/pages/email_client/email_client_screen.dart b/lib/pages/email_client/email_client_screen.dart deleted file mode 100644 index 965e3b9b..00000000 --- a/lib/pages/email_client/email_client_screen.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; -import 'package:campus_app/pages/email_client/email_client_page.dart'; -import 'package:campus_app/pages/wallet/ticket_login_screen.dart'; - -class EmailClientScreen extends StatefulWidget { - const EmailClientScreen({super.key}); - - @override - State createState() => _EmailClientScreenState(); -} - -class _EmailClientScreenState extends State { - final EmailAuthService _emailAuthService = GetIt.instance(); - bool _isLoading = true; - bool _hasCredentials = false; - - @override - void initState() { - super.initState(); - _checkAuthStatus(); - } - - Future _checkAuthStatus() async { - try { - final hasCredentials = await _emailAuthService.hasValidCredentials(); - if (mounted) { - setState(() { - _hasCredentials = hasCredentials; - _isLoading = false; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _hasCredentials = false; - _isLoading = false; - }); - } - } - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return const Scaffold( - body: Center( - child: CircularProgressIndicator(), - ), - ); - } - - if (!_hasCredentials) { - // Navigate to login screen with callback to return to email - return TicketLoginScreen( - onTicketLoaded: () { - // After successful login, navigate to email client - if (mounted) { - setState(() { - _hasCredentials = true; - }); - } - }, - ); - } - - // User has credentials, show email client - return const EmailClientPage(); - } -} diff --git a/lib/pages/email_client/email_client_page.dart b/lib/pages/email_client/email_page.dart similarity index 66% rename from lib/pages/email_client/email_client_page.dart rename to lib/pages/email_client/email_page.dart index 0a5eac86..c7e882b4 100644 --- a/lib/pages/email_client/email_client_page.dart +++ b/lib/pages/email_client/email_page.dart @@ -1,20 +1,27 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:campus_app/core/injection.dart'; +import 'package:campus_app/utils/widgets/login_screen.dart'; import 'package:campus_app/pages/email_client/email_drawer.dart'; import 'package:campus_app/pages/email_client/email_view.dart'; import 'package:campus_app/pages/email_client/compose_email_screen.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; import 'package:campus_app/pages/email_client/widgets/select_email.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; -class EmailClientPage extends StatelessWidget { - const EmailClientPage({super.key}); +class EmailPage extends StatelessWidget { + const EmailPage({super.key}); @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => EmailService(), + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => EmailService()), + ChangeNotifierProvider(create: (_) => EmailAuthService()), + ], child: const _EmailClientContent(), ); } @@ -30,13 +37,22 @@ class _EmailClientContent extends StatefulWidget { class _EmailClientContentState extends State<_EmailClientContent> { final GlobalKey _scaffoldKey = GlobalKey(); final TextEditingController _searchController = TextEditingController(); + final FlutterSecureStorage secureStorage = sl(); bool _isSearching = false; + bool _isLoading = true; + bool _isAuthenticated = false; late EmailSelectionController _selectionController; @override void initState() { super.initState(); + _initializeEmailClient(); + } + + Future _initializeEmailClient() async { + final emailAuthService = Provider.of(context, listen: false); final emailService = Provider.of(context, listen: false); + _selectionController = EmailSelectionController( onDelete: (emails) async { emailService.moveEmailsToFolder(emails, EmailFolder.trash); @@ -51,6 +67,22 @@ class _EmailClientContentState extends State<_EmailClientContent> { _search(); }, )..addListener(_onSelectionChanged); + + // Check if user is already authenticated + final isAuthenticated = await emailAuthService.isAuthenticated(); + + if (isAuthenticated) { + // Initialize email service if authenticated + await emailService.initialize(); + setState(() { + _isAuthenticated = true; + _isLoading = false; + }); + } else { + setState(() { + _isLoading = false; + }); + } } void _onSelectionChanged() => setState(() {}); @@ -59,6 +91,42 @@ class _EmailClientContentState extends State<_EmailClientContent> { setState(() {}); } + Future _handleLogin() async { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoginScreen( + loginType: LoginType.email, + customTitle: 'RubMail Login', + customDescription: 'Melde dich mit deinen RUB-Daten an, um auf deine E-Mails zuzugreifen.', + onLogin: (username, password) async { + final emailAuthService = Provider.of(context, listen: false); + await emailAuthService.authenticate(username, password); + }, + onLoginSuccess: () async { + final emailService = Provider.of(context, listen: false); + await emailService.initialize(); + setState(() { + _isAuthenticated = true; + }); + }, + ), + ), + ); + } + + Future _handleLogout() async { + final emailAuthService = Provider.of(context, listen: false); + final emailService = Provider.of(context, listen: false); + + await emailAuthService.logout(); + emailService.clear(); + + setState(() { + _isAuthenticated = false; + }); + } + Future _handlePop(BuildContext context) async { if (_selectionController.isSelecting) { _selectionController.clearSelection(); @@ -88,6 +156,50 @@ class _EmailClientContentState extends State<_EmailClientContent> { @override Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + + if (!_isAuthenticated) { + return Scaffold( + appBar: AppBar( + title: const Text('RubMail'), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.email, + size: 64, + color: Theme.of(context).primaryColor, + ), + const SizedBox(height: 24), + Text( + 'Willkommen bei RubMail', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Text( + 'Melde dich an, um auf deine E-Mails zuzugreifen', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _handleLogin, + child: const Text('Anmelden'), + ), + ], + ), + ), + ); + } + final emailService = Provider.of(context); final filteredEmails = emailService.filterEmails(_searchController.text, EmailFolder.inbox); @@ -103,7 +215,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { controller: _searchController, autofocus: true, decoration: const InputDecoration( - hintText: 'Search emails...', + hintText: 'E-Mails durchsuchen...', border: InputBorder.none, ), onChanged: (_) => _search(), @@ -136,6 +248,11 @@ class _EmailClientContentState extends State<_EmailClientContent> { onPressed: _selectionController.clearSelection, ), ], + if (!_isSearching && !_selectionController.isSelecting) + IconButton( + icon: const Icon(Icons.logout), + onPressed: _handleLogout, + ), if (!_isSearching && !_selectionController.isSelecting) Builder( builder: (context) => IconButton( @@ -151,7 +268,8 @@ class _EmailClientContentState extends State<_EmailClientContent> { onTap: _selectionController.isSelecting ? _selectionController.clearSelection : null, child: RefreshIndicator( onRefresh: () async { - await Future.delayed(const Duration(seconds: 1)); + final emailService = Provider.of(context, listen: false); + await emailService.refreshEmails(); _search(); }, child: ListView.separated( @@ -206,7 +324,6 @@ class _EmailClientContentState extends State<_EmailClientContent> { ], ) : FloatingActionButton( - //button to compose emails onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const ComposeEmailScreen()), diff --git a/lib/pages/email_client/services/email_auth_service.dart b/lib/pages/email_client/services/email_auth_service.dart index 0f50fba1..f2cb43b9 100644 --- a/lib/pages/email_client/services/email_auth_service.dart +++ b/lib/pages/email_client/services/email_auth_service.dart @@ -1,50 +1,128 @@ +import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -//import 'package:get_it/get_it.dart'; +import 'package:campus_app/core/injection.dart'; +import 'package:campus_app/core/exceptions.dart'; -class EmailAuthService { - final FlutterSecureStorage _secureStorage; +class EmailAuthService extends ChangeNotifier { + final FlutterSecureStorage _secureStorage = sl(); - EmailAuthService({required FlutterSecureStorage secureStorage}) : _secureStorage = secureStorage; + static const String _emailUsernameKey = 'email_loginId'; + static const String _emailPasswordKey = 'email_password'; + static const String _isAuthenticatedKey = 'email_is_authenticated'; - /// Check if user has valid login credentials - Future hasValidCredentials() async { + bool _isAuthenticated = false; + String? _currentUsername; + String? _currentPassword; + + bool get isAuthenticatedSync => _isAuthenticated; + String? get currentUsername => _currentUsername; + + Future isAuthenticated() async { try { - final String? loginId = await _secureStorage.read(key: 'loginId'); - final String? password = await _secureStorage.read(key: 'password'); + final authStatus = await _secureStorage.read(key: _isAuthenticatedKey); + final username = await _secureStorage.read(key: _emailUsernameKey); + final password = await _secureStorage.read(key: _emailPasswordKey); + + _isAuthenticated = authStatus == 'true' && username != null && password != null; - return loginId != null && password != null && loginId.isNotEmpty && password.isNotEmpty; + if (_isAuthenticated) { + _currentUsername = username; + _currentPassword = password; + } + + notifyListeners(); + return _isAuthenticated; } catch (e) { + _isAuthenticated = false; + notifyListeners(); return false; } } - /// Get stored credentials - Future> getCredentials() async { + Future authenticate(String username, String password) async { try { - final String? loginId = await _secureStorage.read(key: 'loginId'); - final String? password = await _secureStorage.read(key: 'password'); + // Validate credentials format (basic validation) + if (username.isEmpty || password.isEmpty) { + throw InvalidLoginIDAndPasswordException(); // Removed message + } - return { - 'loginId': loginId, - 'password': password, - }; + // Simulate API call to RUB email service + await _validateEmailCredentials(username, password); + + // Store credentials securely + await _secureStorage.write(key: _emailUsernameKey, value: username); + await _secureStorage.write(key: _emailPasswordKey, value: password); + await _secureStorage.write(key: _isAuthenticatedKey, value: 'true'); + + _currentUsername = username; + _currentPassword = password; + _isAuthenticated = true; + + notifyListeners(); } catch (e) { + await logout(); + rethrow; + } + } + + Future _validateEmailCredentials(String username, String password) async { + await Future.delayed(const Duration(seconds: 1)); + + if (username.length < 3) { + throw InvalidLoginIDAndPasswordException(); // Removed message + } + + if (password.length < 6) { + throw InvalidLoginIDAndPasswordException(); // Removed message + } + } + + Future?> getCredentials() async { + if (!_isAuthenticated) { + await isAuthenticated(); + } + + if (_isAuthenticated && _currentUsername != null && _currentPassword != null) { return { - 'loginId': null, - 'password': null, + 'username': _currentUsername!, + 'password': _currentPassword!, }; } + + return null; } - /// Store credentials (if needed for email-specific auth) - Future storeCredentials(String loginId, String password) async { - await _secureStorage.write(key: 'loginId', value: loginId); - await _secureStorage.write(key: 'password', value: password); + Future logout() async { + try { + await _secureStorage.delete(key: _emailUsernameKey); + await _secureStorage.delete(key: _emailPasswordKey); + await _secureStorage.delete(key: _isAuthenticatedKey); + } catch (e) { + debugPrint('Error clearing credentials: $e'); + } + + _currentUsername = null; + _currentPassword = null; + _isAuthenticated = false; + + notifyListeners(); } - /// Clear credentials - Future clearCredentials() async { - await _secureStorage.delete(key: 'loginId'); - await _secureStorage.delete(key: 'password'); + Future refresh() async { + await isAuthenticated(); + } + + Future validateCurrentCredentials() async { + if (!_isAuthenticated || _currentUsername == null || _currentPassword == null) { + return false; + } + + try { + await _validateEmailCredentials(_currentUsername!, _currentPassword!); + return true; + } catch (e) { + await logout(); + return false; + } } } diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index 0483530d..a7aca0e6 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -1,10 +1,16 @@ import 'package:flutter/foundation.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/widgets/select_email.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/core/injection.dart'; class EmailService extends ChangeNotifier { final List _allEmails; final EmailSelectionController _selectionController = EmailSelectionController(); + final EmailAuthService _authService = sl(); + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; // Modified constructor: Generates dummy emails internally EmailService() : _allEmails = List.generate(10, Email.dummy) { @@ -15,6 +21,151 @@ class EmailService extends ChangeNotifier { List get allEmails => List.unmodifiable(_allEmails); EmailSelectionController get selectionController => _selectionController; + /// Initialize the email service with authenticated credentials + Future initialize() async { + try { + final credentials = await _authService.getCredentials(); + if (credentials == null) { + throw Exception('No valid credentials found'); + } + + // Initialize your email client connection here + // For example, connect to IMAP server with credentials + await _connectToEmailServer(credentials['username']!, credentials['password']!); + + // Load initial emails + await refreshEmails(); + + _isInitialized = true; + notifyListeners(); + } catch (e) { + _isInitialized = false; + notifyListeners(); + rethrow; + } + } + + /// Connect to the email server (IMAP/POP3) + Future _connectToEmailServer(String username, String password) async { + // Implement your email server connection logic here + // This would typically involve: + // 1. Setting up IMAP/POP3 connection to RUB's email server + // 2. Authenticating with the provided credentials + // 3. Setting up folder/mailbox access + + // Simulate connection delay + await Future.delayed(const Duration(seconds: 1)); + + // Example connection setup (pseudo-code): + // _imapClient = ImapClient(); + // await _imapClient.connectToServer('imap.rub.de', 993, isSecure: true); + // await _imapClient.login(username, password); + } + + /// Refresh emails from server + Future refreshEmails() async { + if (!_isInitialized) { + throw Exception('Email service not initialized'); + } + + try { + // Fetch emails from server + await _fetchEmailsFromServer(); + notifyListeners(); + } catch (e) { + // Handle authentication errors + if (e.toString().contains('authentication') || e.toString().contains('credentials')) { + // Credentials might be invalid, logout user + await _authService.logout(); + _isInitialized = false; + notifyListeners(); + } + rethrow; + } + } + + /// Fetch emails from the server + Future _fetchEmailsFromServer() async { + // Implement your email fetching logic here + // This would typically involve: + // 1. Selecting the appropriate mailbox/folder + // 2. Fetching email headers and metadata + // 3. Updating your local email list + + // Simulate fetching delay + await Future.delayed(const Duration(milliseconds: 500)); + + // Example fetching logic (pseudo-code): + // final messages = await _imapClient.fetchMessages(count: 50); + // _emails = messages.map((msg) => Email.fromImapMessage(msg)).toList(); + } + + /// Clear all email data (called on logout) + void clear() { + // Clear all email data + _allEmails.clear(); + _isInitialized = false; + notifyListeners(); + } + + /// Send email (requires authentication) + Future sendEmail({ + required String to, + required String subject, + required String body, + String? cc, + String? bcc, + }) async { + if (!_isInitialized) { + throw Exception('Email service not initialized'); + } + + final credentials = await _authService.getCredentials(); + if (credentials == null) { + throw Exception('No valid credentials found'); + } + + // Implement email sending logic here + await _sendEmailViaSmtp( + username: credentials['username']!, + password: credentials['password']!, + to: to, + subject: subject, + body: body, + cc: cc, + bcc: bcc, + ); + } + + /// Send email via SMTP + Future _sendEmailViaSmtp({ + required String username, + required String password, + required String to, + required String subject, + required String body, + String? cc, + String? bcc, + }) async { + // Implement SMTP sending logic here + // Example (pseudo-code): + // final smtpClient = SmtpClient(); + // await smtpClient.connect('smtp.rub.de', 587); + // await smtpClient.authenticate(username, password); + // await smtpClient.sendMessage(message); + + // Simulate sending delay + await Future.delayed(const Duration(seconds: 1)); + } + + /// Check if service needs re-authentication + Future needsReAuthentication() async { + if (!_isInitialized) return true; + + return !(await _authService.validateCurrentCredentials()); + } + + // Existing methods from original EmailService List filterEmails(String query, EmailFolder folder) { final filtered = _allEmails.where((e) => e.folder == folder).toList(); if (query.isEmpty) return filtered; @@ -45,24 +196,20 @@ class EmailService extends ChangeNotifier { notifyListeners(); } - //delete email permanently, possible if email is deleted and is in the trash section. void deleteEmailsPermanently(Iterable emails) { _allEmails.removeWhere((e) => emails.any((email) => email.id == e.id)); _selectionController.clearSelection(); notifyListeners(); } - // Add or update a draft with auto-delete logic void saveOrUpdateDraft(Email draft) { - // Check if draft is empty and should be auto-deleted if (_isDraftEmpty(draft)) { - // If it's an existing draft, remove it final existingIndex = _allEmails.indexWhere((e) => e.id == draft.id); if (existingIndex != -1) { _allEmails.removeAt(existingIndex); notifyListeners(); } - return; // Don't save empty drafts + return; } final index = _allEmails.indexWhere((e) => e.id == draft.id); @@ -74,13 +221,11 @@ class EmailService extends ChangeNotifier { notifyListeners(); } - // Remove a draft (used when sending email) void removeDraft(String draftId) { _allEmails.removeWhere((e) => e.id == draftId && e.folder == EmailFolder.drafts); notifyListeners(); } - // Auto-clean empty drafts - can be called periodically or on demand void cleanEmptyDrafts() { final emptyDrafts = _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).toList(); @@ -89,12 +234,10 @@ class EmailService extends ChangeNotifier { } } - // Check if a draft is considered empty bool _isDraftEmpty(Email draft) { return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; } - // Get count of empty drafts (useful for UI) int get emptyDraftsCount { return _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).length; } diff --git a/lib/pages/more/more_page.dart b/lib/pages/more/more_page.dart index 1ba7bdcf..13de23bc 100644 --- a/lib/pages/more/more_page.dart +++ b/lib/pages/more/more_page.dart @@ -1,6 +1,5 @@ import 'dart:io' show Platform; -import 'package:campus_app/pages/email_client/email_client_page.dart'; -import 'package:campus_app/pages/email_client/email_client_screen.dart'; +import 'package:campus_app/pages/email_client/email_page.dart'; import 'package:campus_app/pages/more/privacy_policy_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -165,7 +164,7 @@ class MorePageState extends State with AutomaticKeepAliveClientMixin const EmailClientScreen(), + builder: (context) => const EmailPage(), ), ); }, diff --git a/lib/pages/wallet/ticket_login_screen.dart b/lib/pages/wallet/ticket_login_screen.dart index 7422113f..96062e27 100644 --- a/lib/pages/wallet/ticket_login_screen.dart +++ b/lib/pages/wallet/ticket_login_screen.dart @@ -1,228 +1,177 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:provider/provider.dart'; import 'package:campus_app/core/injection.dart'; -import 'package:campus_app/core/themes.dart'; -import 'package:campus_app/core/exceptions.dart'; +//import 'package:campus_app/core/exceptions.dart'; import 'package:campus_app/pages/wallet/ticket/ticket_repository.dart'; import 'package:campus_app/utils/pages/wallet_utils.dart'; -import 'package:campus_app/utils/widgets/campus_icon_button.dart'; -import 'package:campus_app/utils/widgets/campus_textfield.dart'; -import 'package:campus_app/utils/widgets/campus_button.dart'; +import 'package:campus_app/utils/widgets/login_screen.dart'; -class TicketLoginScreen extends StatefulWidget { - final void Function() onTicketLoaded; - const TicketLoginScreen({super.key, required this.onTicketLoaded}); - - @override - State createState() => _TicketLoginScreenState(); -} - -class _TicketLoginScreenState extends State { +class TicketCredentialManager { final TicketRepository ticketRepository = sl(); final FlutterSecureStorage secureStorage = sl(); final WalletUtils walletUtils = sl(); - final TextEditingController usernameController = TextEditingController(); - final TextEditingController passwordController = TextEditingController(); - final TextEditingController submitButtonController = TextEditingController(); - - bool showErrorMessage = false; - String errorMessage = ''; - - bool loading = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Provider.of(context).currentThemeData.colorScheme.surface, - body: Padding( - padding: const EdgeInsets.only(top: 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Back button - Padding( - padding: const EdgeInsets.only(bottom: 12, left: 20, right: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CampusIconButton( - iconPath: 'assets/img/icons/arrow-left.svg', - onTap: () { - Navigator.pop(context); - }, - ), - ], - ), - ), - const Padding(padding: EdgeInsets.only(top: 10)), - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - 'assets/img/icons/rub-link.png', - color: Provider.of(context).currentTheme == AppThemes.light - ? const Color.fromRGBO(0, 53, 96, 1) - : Colors.white, - width: 80, - filterQuality: FilterQuality.high, - ), - const Padding(padding: EdgeInsets.only(top: 30)), - CampusTextField( - textFieldController: usernameController, - textFieldText: 'RUB LoginID', - onTap: () { - setState(() { - showErrorMessage = false; - }); - }, - ), - const Padding(padding: EdgeInsets.only(top: 10)), - CampusTextField( - textFieldController: passwordController, - obscuredInput: true, - textFieldText: 'RUB Passwort', - onTap: () { - setState(() { - showErrorMessage = false; - }); - }, - ), - const Padding(padding: EdgeInsets.only(top: 15)), - if (showErrorMessage) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - 'assets/img/icons/error.svg', - colorFilter: const ColorFilter.mode( - Colors.redAccent, - BlendMode.srcIn, - ), - width: 18, - ), - const Padding( - padding: EdgeInsets.only(left: 5), - ), - Text( - errorMessage, - style: Provider.of(context).currentThemeData.textTheme.labelSmall!.copyWith( - color: Colors.redAccent, - ), - ), - ], - ), - ], - const Padding(padding: EdgeInsets.only(top: 15)), - CampusButton( - text: 'Login', - onTap: () async { - final NavigatorState navigator = Navigator.of(context); - - if (usernameController.text.isEmpty || passwordController.text.isEmpty) { - setState(() { - errorMessage = 'Bitte fülle beide Felder aus!'; - showErrorMessage = true; - }); - return; - } - - if (await walletUtils.hasNetwork() == false) { - setState(() { - errorMessage = 'Überprüfe deine Internetverbindung!'; - showErrorMessage = true; - }); - return; - } - - setState(() { - showErrorMessage = false; - loading = true; - }); - - final previousLoginId = await secureStorage.read(key: 'loginId'); - final previousPassword = await secureStorage.read(key: 'password'); - - await secureStorage.write(key: 'loginId', value: usernameController.text); - await secureStorage.write(key: 'password', value: passwordController.text); - - try { - await ticketRepository.loadTicket(); - widget.onTicketLoaded(); - navigator.pop(); - } catch (e) { - if (e is InvalidLoginIDAndPasswordException) { - setState(() { - errorMessage = 'Falsche LoginID und/oder Passwort!'; - showErrorMessage = true; - }); - } else { - setState(() { - errorMessage = 'Fehler beim Laden des Tickets!'; - showErrorMessage = true; - }); - } - - if (previousLoginId != null && previousPassword != null) { - await secureStorage.write(key: 'loginId', value: previousLoginId); - await secureStorage.write(key: 'password', value: previousPassword); - } - } - setState(() { - loading = false; - }); - }, - ), - const Padding(padding: EdgeInsets.only(top: 25)), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - 'assets/img/icons/info.svg', - colorFilter: ColorFilter.mode( - Provider.of(context).currentTheme == AppThemes.light - ? Colors.black - : const Color.fromRGBO(184, 186, 191, 1), - BlendMode.srcIn, - ), - width: 18, - ), - const Padding( - padding: EdgeInsets.only(left: 8), - ), - SizedBox( - width: 320, - child: Text( - 'Deine Daten werden verschlüsselt auf deinem Gerät gespeichert und nur bei der Anmeldung an die RUB gesendet.', - style: Provider.of(context).currentThemeData.textTheme.labelSmall!.copyWith( - color: Provider.of(context).currentTheme == AppThemes.light - ? Colors.black - : const Color.fromRGBO(184, 186, 191, 1), - ), - overflow: TextOverflow.clip, - ), - ), - ], - ), - const Padding(padding: EdgeInsets.only(top: 25)), - if (loading) ...[ - CircularProgressIndicator( - backgroundColor: Provider.of(context).currentThemeData.cardColor, - color: Provider.of(context).currentThemeData.primaryColor, - strokeWidth: 3, - ), - ], - ], - ), - ), - ], + static const String _loginIdKey = 'loginId'; + static const String _passwordKey = 'password'; + + /// Attempts to load ticket with existing credentials, or shows login screen if needed + Future loadTicketWithCredentialCheck( + BuildContext context, { + void Function()? onTicketLoaded, + void Function(String error)? onError, + }) async { + try { + // First check if we have saved credentials + final savedUsername = await secureStorage.read(key: _loginIdKey); + final savedPassword = await secureStorage.read(key: _passwordKey); + + if (savedUsername != null && savedPassword != null) { + // Try to load ticket with existing credentials + await ticketRepository.loadTicket(); + onTicketLoaded?.call(); + } else { + // No credentials found, show login screen + _showTicketLoginScreen(context, onTicketLoaded: onTicketLoaded, onError: onError); + } + } catch (e) { + // If existing credentials fail, show login screen + _showTicketLoginScreen(context, onTicketLoaded: onTicketLoaded, onError: onError); + } + } + + /// Forces the login screen to appear (e.g., for re-authentication) + Future showTicketLoginScreen( + BuildContext context, { + void Function()? onTicketLoaded, + void Function(String error)? onError, + }) async { + _showTicketLoginScreen(context, onTicketLoaded: onTicketLoaded, onError: onError); + } + + /// Internal method to show the login screen + void _showTicketLoginScreen( + BuildContext context, { + void Function()? onTicketLoaded, + void Function(String error)? onError, + }) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoginScreen( + loginType: LoginType.ticket, + onLogin: (username, password) async { + // This is where the actual ticket loading happens + await _performTicketLogin(username, password); + }, + onLoginSuccess: () { + // Called when login is successful + onTicketLoaded?.call(); + }, ), ), ); } + + /// Performs the actual ticket login with the provided credentials + Future _performTicketLogin(String username, String password) async { + // Check network connectivity + if (await walletUtils.hasNetwork() == false) { + throw Exception('Überprüfe deine Internetverbindung!'); + } + await secureStorage.write(key: _loginIdKey, value: username); + await secureStorage.write(key: _passwordKey, value: password); + // Attempt to load the ticket + await ticketRepository.loadTicket(); + } + + /// Checks if ticket credentials are stored + Future hasStoredCredentials() async { + final username = await secureStorage.read(key: _loginIdKey); + final password = await secureStorage.read(key: _passwordKey); + return username != null && password != null; + } + + /// Clears stored ticket credentials (for logout) + Future clearCredentials() async { + await secureStorage.delete(key: _loginIdKey); + await secureStorage.delete(key: _passwordKey); + } + + /// Gets the stored username (if any) + Future getStoredUsername() async { + return await secureStorage.read(key: _loginIdKey); + } + + /// Validates credentials without saving them + Future validateCredentials(String username, String password) async { + try { + // Store current credentials temporarily + final currentUsername = await secureStorage.read(key: _loginIdKey); + final currentPassword = await secureStorage.read(key: _passwordKey); + + // Set the new credentials temporarily + await secureStorage.write(key: _loginIdKey, value: username); + await secureStorage.write(key: _passwordKey, value: password); + + // Try to load ticket + await ticketRepository.loadTicket(); + + // Restore original credentials + if (currentUsername != null && currentPassword != null) { + await secureStorage.write(key: _loginIdKey, value: currentUsername); + await secureStorage.write(key: _passwordKey, value: currentPassword); + } + + return true; + } catch (e) { + return false; + } + } +} + +// Extension or utility class for easy access +class TicketManager { + static final TicketCredentialManager _credentialManager = TicketCredentialManager(); + + /// Main method to load ticket - handles credential checking automatically + static Future loadTicket( + BuildContext context, { + void Function()? onSuccess, + void Function(String error)? onError, + }) async { + await _credentialManager.loadTicketWithCredentialCheck( + context, + onTicketLoaded: onSuccess, + onError: onError, + ); + } + + /// Force login screen to appear + static Future login( + BuildContext context, { + void Function()? onSuccess, + void Function(String error)? onError, + }) async { + await _credentialManager.showTicketLoginScreen( + context, + onTicketLoaded: onSuccess, + onError: onError, + ); + } + + /// Logout (clear credentials) + static Future logout() async { + await _credentialManager.clearCredentials(); + } + + /// Check if user is logged in + static Future isLoggedIn() async { + return await _credentialManager.hasStoredCredentials(); + } + + /// Get current username + static Future getCurrentUsername() async { + return await _credentialManager.getStoredUsername(); + } } diff --git a/lib/pages/wallet/widgets/wallet.dart b/lib/pages/wallet/widgets/wallet.dart index 42fe457f..50112184 100644 --- a/lib/pages/wallet/widgets/wallet.dart +++ b/lib/pages/wallet/widgets/wallet.dart @@ -11,10 +11,11 @@ import 'package:campus_app/core/settings.dart'; import 'package:campus_app/core/themes.dart'; import 'package:campus_app/pages/wallet/ticket/ticket_repository.dart'; import 'package:campus_app/pages/wallet/ticket/ticket_usecases.dart'; -import 'package:campus_app/pages/wallet/ticket_login_screen.dart'; import 'package:campus_app/pages/wallet/ticket_fullscreen.dart'; import 'package:campus_app/pages/wallet/widgets/stacked_card_carousel.dart'; import 'package:campus_app/utils/widgets/custom_button.dart'; +import 'package:campus_app/utils/widgets/login_screen.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class CampusWallet extends StatelessWidget { const CampusWallet({super.key}); @@ -79,8 +80,18 @@ class _BogestraTicketState extends State with AutomaticKeepAlive await Navigator.push( context, MaterialPageRoute( - builder: (context) => TicketLoginScreen( - onTicketLoaded: () async { + builder: (context) => LoginScreen( + loginType: LoginType.ticket, + onLogin: (username, password) async { + // Store credentials first, then load ticket + final secureStorage = sl(); + await secureStorage.write(key: 'loginId', value: username); + await secureStorage.write(key: 'password', value: password); + + // Load ticket with the stored credentials + await ticketRepository.loadTicket(); + }, + onLoginSuccess: () async { await renderTicket(); }, ), diff --git a/lib/utils/widgets/login_screen.dart b/lib/utils/widgets/login_screen.dart new file mode 100644 index 00000000..d58aacb6 --- /dev/null +++ b/lib/utils/widgets/login_screen.dart @@ -0,0 +1,336 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:provider/provider.dart'; + +import 'package:campus_app/core/injection.dart'; +import 'package:campus_app/core/themes.dart'; +import 'package:campus_app/core/exceptions.dart'; +import 'package:campus_app/utils/pages/wallet_utils.dart'; +import 'package:campus_app/utils/widgets/campus_icon_button.dart'; +import 'package:campus_app/utils/widgets/campus_textfield.dart'; +import 'package:campus_app/utils/widgets/campus_button.dart'; + +enum LoginType { ticket, email } + +class LoginScreen extends StatefulWidget { + final LoginType loginType; + final Future Function(String username, String password) onLogin; + final void Function()? onLoginSuccess; + final String? customTitle; + final String? customDescription; + + const LoginScreen({ + super.key, + required this.loginType, + required this.onLogin, + this.onLoginSuccess, + this.customTitle, + this.customDescription, + }); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final FlutterSecureStorage secureStorage = sl(); + final WalletUtils walletUtils = sl(); + + final TextEditingController usernameController = TextEditingController(); + final TextEditingController passwordController = TextEditingController(); + + bool showErrorMessage = false; + String errorMessage = ''; + bool loading = false; + bool _disposed = false; + + String get _getDescription { + if (widget.customDescription != null) return widget.customDescription!; + return 'Deine Daten werden verschlüsselt auf deinem Gerät gespeichert und nur bei der Anmeldung an die RUB gesendet.'; + } + + String get _getUsernameLabel { + switch (widget.loginType) { + case LoginType.ticket: + return 'RUB LoginID'; + case LoginType.email: + return 'RUB LoginID'; + } + } + + String get _getPasswordLabel { + switch (widget.loginType) { + case LoginType.ticket: + return 'RUB Passwort'; + case LoginType.email: + return 'RUB Passwort'; + } + } + + String get _getStorageKeyPrefix { + switch (widget.loginType) { + case LoginType.ticket: + return 'ticket_'; + case LoginType.email: + return 'email_'; + } + } + + @override + void initState() { + super.initState(); + _loadSavedCredentials(); + } + + Future _loadSavedCredentials() async { + try { + final savedUsername = await secureStorage.read(key: '${_getStorageKeyPrefix}loginId'); + final savedPassword = await secureStorage.read(key: '${_getStorageKeyPrefix}password'); + + if (!_disposed && savedUsername != null) { + usernameController.text = savedUsername; + } + if (!_disposed && savedPassword != null) { + passwordController.text = savedPassword; + } + } catch (e) { + debugPrint('Error loading credentials: $e'); + } + } + + Future _saveCredentials(String username, String password) async { + try { + await secureStorage.write(key: '${_getStorageKeyPrefix}loginId', value: username); + await secureStorage.write(key: '${_getStorageKeyPrefix}password', value: password); + } catch (e) { + debugPrint('Error saving credentials: $e'); + } + } + + Future _restorePreviousCredentials(String? previousUsername, String? previousPassword) async { + try { + if (previousUsername != null && previousPassword != null) { + await secureStorage.write(key: '${_getStorageKeyPrefix}loginId', value: previousUsername); + await secureStorage.write(key: '${_getStorageKeyPrefix}password', value: previousPassword); + } + } catch (e) { + debugPrint('Error restoring credentials: $e'); + } + } + + Future _handleLogin() async { + if (_disposed) return; + + final navigator = Navigator.of(context); + final username = usernameController.text.trim(); + final password = passwordController.text.trim(); + + // Validate inputs + if (username.isEmpty || password.isEmpty) { + _showError('Bitte fülle beide Felder aus!'); + return; + } + + // Check network + final hasNetwork = await walletUtils.hasNetwork(); + if (!hasNetwork) { + _showError('Überprüfe deine Internetverbindung!'); + return; + } + + // Store previous credentials + final previousLoginId = await secureStorage.read(key: '${_getStorageKeyPrefix}loginId'); + final previousPassword = await secureStorage.read(key: '${_getStorageKeyPrefix}password'); + + // Save new credentials + await _saveCredentials(username, password); + + setState(() { + loading = true; + showErrorMessage = false; + }); + + try { + // Add timeout for the login operation + await widget.onLogin(username, password).timeout(const Duration(seconds: 30)); + + if (!_disposed) { + widget.onLoginSuccess?.call(); + navigator.pop(); + } + } on TimeoutException { + _showError('Server antwortet nicht - bitte später versuchen'); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } on SocketException { + _showError('Netzwerkfehler - Verbindung prüfen'); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } on InvalidLoginIDAndPasswordException { + _showError('Falsche LoginID und/oder Passwort!'); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } catch (e) { + debugPrint('Login error type: ${e.runtimeType}, message: $e'); + _showError(_getGenericErrorMessage()); + await _restorePreviousCredentials(previousLoginId, previousPassword); + } finally { + if (!_disposed) { + setState(() => loading = false); + } + } + } + + void _showError(String message) { + if (!_disposed) { + setState(() { + errorMessage = message; + showErrorMessage = true; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Provider.of(context); + final themeData = theme.currentThemeData; + final isLightTheme = theme.currentTheme == AppThemes.light; + + return Scaffold( + backgroundColor: themeData.colorScheme.surface, + body: Padding( + padding: const EdgeInsets.only(top: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back button + Padding( + padding: const EdgeInsets.only(bottom: 12, left: 20, right: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CampusIconButton( + iconPath: 'assets/img/icons/arrow-left.svg', + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + const SizedBox(height: 10), + Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/img/icons/rub-link.png', + color: isLightTheme ? const Color.fromRGBO(0, 53, 96, 1) : Colors.white, + width: 80, + filterQuality: FilterQuality.high, + ), + const SizedBox(height: 30), + CampusTextField( + textFieldController: usernameController, + textFieldText: _getUsernameLabel, + onTap: () => setState(() => showErrorMessage = false), + ), + const SizedBox(height: 10), + CampusTextField( + textFieldController: passwordController, + obscuredInput: true, + textFieldText: _getPasswordLabel, + onTap: () => setState(() => showErrorMessage = false), + ), + if (showErrorMessage) ...[ + const SizedBox(height: 15), + _buildErrorWidget(themeData), + ], + const SizedBox(height: 15), + CampusButton( + text: 'Login', + onTap: _handleLogin, + ), + const SizedBox(height: 25), + _buildInfoWidget(themeData, isLightTheme), + if (loading) ...[ + const SizedBox(height: 25), + CircularProgressIndicator( + backgroundColor: themeData.cardColor, + color: themeData.primaryColor, + strokeWidth: 3, + ), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildErrorWidget(ThemeData themeData) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + 'assets/img/icons/error.svg', + colorFilter: const ColorFilter.mode(Colors.redAccent, BlendMode.srcIn), + width: 18, + ), + const SizedBox(width: 5), + Text( + errorMessage, + style: themeData.textTheme.labelSmall?.copyWith(color: Colors.redAccent), + ), + ], + ); + } + + Widget _buildInfoWidget(ThemeData themeData, bool isLightTheme) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + 'assets/img/icons/info.svg', + colorFilter: ColorFilter.mode( + isLightTheme ? Colors.black : const Color.fromRGBO(184, 186, 191, 1), + BlendMode.srcIn, + ), + width: 18, + ), + const SizedBox(width: 8), + SizedBox( + width: 320, + child: Text( + _getDescription, + style: themeData.textTheme.labelSmall?.copyWith( + color: isLightTheme ? Colors.black : const Color.fromRGBO(184, 186, 191, 1), + ), + overflow: TextOverflow.clip, + ), + ), + ], + ); + } + + String _getGenericErrorMessage() { + switch (widget.loginType) { + case LoginType.ticket: + return 'Fehler beim Laden des Tickets! Bitte versuche es später erneut.'; + case LoginType.email: + return 'Email-Login ist aktuell nicht verfügbar. Bitte versuche es später.'; + } + } + + @override + void dispose() { + _disposed = true; + usernameController.dispose(); + passwordController.dispose(); + super.dispose(); + } +} From 844fae6ebbb79c48a7a9722b6608964084e409e3 Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Mon, 16 Jun 2025 17:59:46 +0200 Subject: [PATCH 14/20] IMAP implemented --- lib/core/injection.dart | 47 +++- lib/main.dart | 12 +- .../email_client/email_drawer/archives.dart | 2 +- .../email_client/email_drawer/drafts.dart | 2 +- lib/pages/email_client/email_drawer/sent.dart | 2 +- .../email_client/email_drawer/trash.dart | 2 +- .../compose_email_Screen.dart | 3 +- .../{ => email_pages}/email_drawer.dart | 0 .../{ => email_pages}/email_page.dart | 18 +- .../{ => email_pages}/email_view.dart | 2 +- lib/pages/email_client/models/email.dart | 18 +- .../repositories/email_repository.dart | 63 +++++ .../repositories/imap_email_repository.dart | 84 ++++++ .../email_client/services/email_service.dart | 262 +++++++++++++----- .../services/imap_email_service.dart | 245 ++++++++++++++++ lib/pages/more/more_page.dart | 2 +- pubspec.lock | 56 ++++ pubspec.yaml | 1 + 18 files changed, 707 insertions(+), 114 deletions(-) rename lib/pages/email_client/{ => email_pages}/compose_email_Screen.dart (99%) rename lib/pages/email_client/{ => email_pages}/email_drawer.dart (100%) rename lib/pages/email_client/{ => email_pages}/email_page.dart (94%) rename lib/pages/email_client/{ => email_pages}/email_view.dart (98%) create mode 100644 lib/pages/email_client/repositories/email_repository.dart create mode 100644 lib/pages/email_client/repositories/imap_email_repository.dart create mode 100644 lib/pages/email_client/services/imap_email_service.dart diff --git a/lib/core/injection.dart b/lib/core/injection.dart index 9bca3b0b..5bb57189 100644 --- a/lib/core/injection.dart +++ b/lib/core/injection.dart @@ -27,7 +27,13 @@ import 'package:campus_app/utils/pages/main_utils.dart'; import 'package:campus_app/utils/dio_utils.dart'; import 'package:campus_app/utils/constants.dart'; import 'package:native_dio_adapter/native_dio_adapter.dart'; + +// Email-related imports import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/pages/email_client/repositories/imap_email_repository.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; final sl = GetIt.instance; // service locator @@ -36,7 +42,6 @@ Future init() async { //! Datasources //! - //! Datasources sl.registerSingletonAsync(() async { final client = Dio(); client.httpClientAdapter = NativeAdapter(); @@ -65,9 +70,8 @@ Future init() async { }); //! - //! Repositories + //! Repositories (non-email) //! - sl.registerLazySingleton(() { final Client client = Client().setEndpoint(appwrite).setProject('campus_app'); return BackendRepository(client: client); @@ -92,6 +96,32 @@ Future init() async { () => TicketRepository(ticketDataSource: sl(), secureStorage: sl()), ); + //! + //! Email dependencies (reordered) + //! + + // 1. FlutterSecureStorage is already registered below in “External” + + // 2. EmailAuthService (needs secure storage) + sl.registerLazySingleton( + () => EmailAuthService(), + ); + + // 3. ImapEmailService (low-level IMAP/SMTP) + sl.registerLazySingleton( + () => ImapEmailService(), + ); + + // 4. EmailRepository (depends on ImapEmailService) + sl.registerLazySingleton( + () => ImapEmailRepository(sl()), + ); + + // 5. EmailService (business logic, depends on EmailRepository) + sl.registerLazySingleton( + () => EmailService(sl()), + ); + //! //! Usecases //! @@ -100,29 +130,18 @@ Future init() async { () => NewsUsecases(newsRepository: sl()), dependsOn: [NewsRepository], ); - sl.registerSingletonWithDependencies( () => CalendarUsecases(calendarRepository: sl()), dependsOn: [CalendarRepository], ); - sl.registerSingletonWithDependencies( () => MensaUsecases(mensaRepository: sl()), dependsOn: [MensaRepository], ); - sl.registerLazySingleton( () => TicketUsecases(ticketRepository: sl()), ); - //! - //! Services - //! - - sl.registerLazySingleton( - () => EmailAuthService(), - ); - //! //! Utils //! diff --git a/lib/main.dart b/lib/main.dart index f068abda..70f204d5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -31,6 +31,10 @@ import 'package:campus_app/utils/pages/main_utils.dart'; import 'package:campus_app/utils/pages/mensa_utils.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/pages/email_client/repositories/imap_email_repository.dart'; Future main() async { final WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -68,7 +72,9 @@ Future main() async { // Initializes the provider that handles the app-theme, authentication and other things ChangeNotifierProvider(create: (_) => SettingsHandler()), ChangeNotifierProvider(create: (_) => ThemesNotifier()), - ChangeNotifierProvider(create: (_) => EmailService()), + ChangeNotifierProvider(create: (_) => EmailAuthService()), + Provider(create: (_) => ImapEmailRepository(ImapEmailService())), + ChangeNotifierProvider(create: (ctx) => EmailService(ctx.read())) ], child: CampusApp( key: campusAppKey, @@ -83,7 +89,9 @@ Future main() async { // Initializes the provider that handles the app-theme, authentication and other things ChangeNotifierProvider(create: (_) => SettingsHandler()), ChangeNotifierProvider(create: (_) => ThemesNotifier()), - ChangeNotifierProvider(create: (_) => EmailService()), + ChangeNotifierProvider(create: (_) => EmailAuthService()), + Provider(create: (_) => ImapEmailRepository(ImapEmailService())), + ChangeNotifierProvider(create: (ctx) => EmailService(ctx.read())) ], child: CampusApp( key: campusAppKey, diff --git a/lib/pages/email_client/email_drawer/archives.dart b/lib/pages/email_client/email_drawer/archives.dart index 96ad940a..6dc626f8 100644 --- a/lib/pages/email_client/email_drawer/archives.dart +++ b/lib/pages/email_client/email_drawer/archives.dart @@ -3,7 +3,7 @@ import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; -import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; class ArchivesPage extends StatelessWidget { const ArchivesPage({super.key}); diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart index 6d80b67f..29491ad2 100644 --- a/lib/pages/email_client/email_drawer/drafts.dart +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -3,7 +3,7 @@ import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; -import 'package:campus_app/pages/email_client/compose_email_screen.dart'; +import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; class DraftsPage extends StatefulWidget { const DraftsPage({super.key}); diff --git a/lib/pages/email_client/email_drawer/sent.dart b/lib/pages/email_client/email_drawer/sent.dart index b831519a..592e8e9c 100644 --- a/lib/pages/email_client/email_drawer/sent.dart +++ b/lib/pages/email_client/email_drawer/sent.dart @@ -3,7 +3,7 @@ import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; -import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; class SentPage extends StatelessWidget { const SentPage({super.key}); diff --git a/lib/pages/email_client/email_drawer/trash.dart b/lib/pages/email_client/email_drawer/trash.dart index d07eac16..0b9954d4 100644 --- a/lib/pages/email_client/email_drawer/trash.dart +++ b/lib/pages/email_client/email_drawer/trash.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:campus_app/pages/email_client/email_view.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; diff --git a/lib/pages/email_client/compose_email_Screen.dart b/lib/pages/email_client/email_pages/compose_email_Screen.dart similarity index 99% rename from lib/pages/email_client/compose_email_Screen.dart rename to lib/pages/email_client/email_pages/compose_email_Screen.dart index 396938c8..02e92e6d 100644 --- a/lib/pages/email_client/compose_email_Screen.dart +++ b/lib/pages/email_client/email_pages/compose_email_Screen.dart @@ -58,6 +58,7 @@ class _ComposeEmailScreenState extends State { } // Check if the current composition is empty + /* bool _isCompositionEmpty() { return _toController.text.trim().isEmpty && _ccController.text.trim().isEmpty && @@ -65,7 +66,7 @@ class _ComposeEmailScreenState extends State { _subjectController.text.trim().isEmpty && _bodyController.text.trim().isEmpty && _attachments.isEmpty; - } + } */ // Check if the composition has any meaningful content bool _hasContent() { diff --git a/lib/pages/email_client/email_drawer.dart b/lib/pages/email_client/email_pages/email_drawer.dart similarity index 100% rename from lib/pages/email_client/email_drawer.dart rename to lib/pages/email_client/email_pages/email_drawer.dart diff --git a/lib/pages/email_client/email_page.dart b/lib/pages/email_client/email_pages/email_page.dart similarity index 94% rename from lib/pages/email_client/email_page.dart rename to lib/pages/email_client/email_pages/email_page.dart index c7e882b4..535fed23 100644 --- a/lib/pages/email_client/email_page.dart +++ b/lib/pages/email_client/email_pages/email_page.dart @@ -3,9 +3,9 @@ import 'package:provider/provider.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:campus_app/core/injection.dart'; import 'package:campus_app/utils/widgets/login_screen.dart'; -import 'package:campus_app/pages/email_client/email_drawer.dart'; -import 'package:campus_app/pages/email_client/email_view.dart'; -import 'package:campus_app/pages/email_client/compose_email_screen.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_drawer.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; +import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; @@ -17,13 +17,7 @@ class EmailPage extends StatelessWidget { @override Widget build(BuildContext context) { - return MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => EmailService()), - ChangeNotifierProvider(create: (_) => EmailAuthService()), - ], - child: const _EmailClientContent(), - ); + return const _EmailClientContent(); } } @@ -338,10 +332,6 @@ class _EmailClientContentState extends State<_EmailClientContent> { // TODO: Sent, Archives, Drafts // TODO: Settings: I am unsure what to add in here. -// Check: 'package:flutter_secure_storage/flutter_secure_storage.dart'; -// Check: ticket_datasource(pages/wallet/ticket/) and injection.dart (in lib/core) -// Use Login, make it go to Email - // Check IMAP plugins (for dart/flutter): enough_mail?s // SMPT und IMAP client => API // UI und Backend separate, start with UI it is easier. diff --git a/lib/pages/email_client/email_view.dart b/lib/pages/email_client/email_pages/email_view.dart similarity index 98% rename from lib/pages/email_client/email_view.dart rename to lib/pages/email_client/email_pages/email_view.dart index f9808b41..6e73d18a 100644 --- a/lib/pages/email_client/email_view.dart +++ b/lib/pages/email_client/email_pages/email_view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; -import 'package:campus_app/pages/email_client/compose_email_screen.dart'; +import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; class EmailView extends StatelessWidget { final Email email; diff --git a/lib/pages/email_client/models/email.dart b/lib/pages/email_client/models/email.dart index 256dcf94..e491cf70 100644 --- a/lib/pages/email_client/models/email.dart +++ b/lib/pages/email_client/models/email.dart @@ -13,6 +13,9 @@ class Email { final List attachments; final EmailFolder folder; + // Added for IMAP operations + final int uid; // IMAP UID for server operations + const Email({ required this.id, required this.sender, @@ -25,6 +28,7 @@ class Email { this.isStarred = false, this.attachments = const [], this.folder = EmailFolder.inbox, + this.uid = 0, // Default to 0 for local/dummy emails }); // Updated dummy constructor @@ -41,6 +45,7 @@ class Email { isUnread: index % 2 == 0, isStarred: index % 3 == 0, attachments: index % 4 == 0 ? ['document$index.pdf', 'image$index.jpg'] : [], + uid: 0, // Dummy emails don't have IMAP UIDs ); // JSON serialization @@ -56,6 +61,7 @@ class Email { 'isStarred': isStarred, 'attachments': attachments, 'folder': folder.name, + 'uid': uid, }; factory Email.fromJson(Map json) => Email( @@ -70,6 +76,7 @@ class Email { isStarred: json['isStarred'], attachments: List.from(json['attachments']), folder: EmailFolder.values.byName(json['folder']), + uid: json['uid'] ?? 0, ); Email copyWith({ @@ -84,6 +91,8 @@ class Email { bool? isStarred, List? attachments, EmailFolder? folder, + int? uid, + bool? isRead, }) => Email( id: id ?? this.id, @@ -93,15 +102,22 @@ class Email { subject: subject ?? this.subject, body: body ?? this.body, date: date ?? this.date, - isUnread: isUnread ?? this.isUnread, + isUnread: isRead != null ? !isRead : (isUnread ?? this.isUnread), isStarred: isStarred ?? this.isStarred, attachments: attachments ?? this.attachments, folder: folder ?? this.folder, + uid: uid ?? this.uid, ); String get preview { return body.length > 50 ? '${body.substring(0, 50)}...' : body; } + + // Convenience getters for compatibility with IMAP service + bool get isRead => !isUnread; + bool get hasAttachments => attachments.isNotEmpty; + String get senderName => sender; + DateTime get timestamp => date; } enum EmailFolder { diff --git a/lib/pages/email_client/repositories/email_repository.dart b/lib/pages/email_client/repositories/email_repository.dart new file mode 100644 index 00000000..1effded3 --- /dev/null +++ b/lib/pages/email_client/repositories/email_repository.dart @@ -0,0 +1,63 @@ +// 1. ABSTRACT EMAIL REPOSITORY (Define the contract) +// ============================================================================ + +import 'package:campus_app/pages/email_client/models/email.dart'; + +abstract class EmailRepository { + Future connect(String username, String password); + Future disconnect(); + Future> fetchEmails({required String mailboxName, int count = 50}); + Future sendEmail({ + required String to, + required String subject, + required String body, + List? cc, + List? bcc, + }); + Future markAsRead(int uid); + Future markAsUnread(int uid); + Future deleteEmail(int uid, {String mailboxName}); + Future moveEmail(int uid, String targetMailbox); + Future> searchEmails({ + String? query, + String? from, + String? subject, + bool unreadOnly = false, + String mailboxName = 'INBOX', + }); + bool get isConnected; +} + + + +// ============================================================================ +// USAGE EXAMPLE +/* + +class EmailController { + final EmailService _emailService = sl(); + + Future initializeEmail() async { + try { + await _emailService.initialize(); + } catch (e) { + // Handle initialization error + print('Failed to initialize email: $e'); + } + } + + Future sendTestEmail() async { + try { + await _emailService.sendEmail( + to: 'test@example.com', + subject: 'Test Email', + body: 'This is a test email', + ); + } catch (e) { + // Handle send error + print('Failed to send email: $e'); + } + } +} + +*/ \ No newline at end of file diff --git a/lib/pages/email_client/repositories/imap_email_repository.dart b/lib/pages/email_client/repositories/imap_email_repository.dart new file mode 100644 index 00000000..c8c27503 --- /dev/null +++ b/lib/pages/email_client/repositories/imap_email_repository.dart @@ -0,0 +1,84 @@ +//2. IMAP IMPLEMENTATION OF EMAIL REPOSITORY +// ============================================================================ + +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; +import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; + +class ImapEmailRepository implements EmailRepository { + final ImapEmailService _imapService; + + ImapEmailRepository(this._imapService); + + @override + Future connect(String username, String password) { + return _imapService.connect(username, password); + } + + @override + Future disconnect() { + return _imapService.disconnect(); + } + + @override + Future> fetchEmails({required String mailboxName, int count = 50}) { + return _imapService.fetchEmails(mailboxName: mailboxName, count: count); + } + + @override + Future sendEmail({ + required String to, + required String subject, + required String body, + List? cc, + List? bcc, + }) { + return _imapService.sendEmail( + to: to, + subject: subject, + body: body, + cc: cc, + bcc: bcc, + ); + } + + @override + Future markAsRead(int uid) { + return _imapService.markAsRead(uid); + } + + @override + Future markAsUnread(int uid) { + return _imapService.markAsUnread(uid); + } + + @override + Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) { + return _imapService.deleteEmail(uid, mailboxName: mailboxName); + } + + @override + Future moveEmail(int uid, String targetMailbox) { + return _imapService.moveEmail(uid, targetMailbox); + } + + @override + Future> searchEmails({ + String? query, + String? from, + String? subject, + bool unreadOnly = false, + String mailboxName = 'INBOX', + }) { + return _imapService.searchEmails( + query: query, + from: from, + subject: subject, + unreadOnly: unreadOnly, + mailboxName: mailboxName, + ); + } + + @override + bool get isConnected => _imapService.isConnected; +} diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index a7aca0e6..2bcb67c3 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -1,23 +1,27 @@ +// REFACTORED EMAIL SERVICE (Business Logic Only) +// ============================================================================ + import 'package:flutter/foundation.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/widgets/select_email.dart'; import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; import 'package:campus_app/core/injection.dart'; class EmailService extends ChangeNotifier { - final List _allEmails; + final List _allEmails = []; final EmailSelectionController _selectionController = EmailSelectionController(); final EmailAuthService _authService = sl(); + final EmailRepository _emailRepository; bool _isInitialized = false; bool get isInitialized => _isInitialized; - // Modified constructor: Generates dummy emails internally - EmailService() : _allEmails = List.generate(10, Email.dummy) { + EmailService(this._emailRepository) { _selectionController.addListener(notifyListeners); } - // Public API (unchanged) + // Public API List get allEmails => List.unmodifiable(_allEmails); EmailSelectionController get selectionController => _selectionController; @@ -29,15 +33,11 @@ class EmailService extends ChangeNotifier { throw Exception('No valid credentials found'); } - // Initialize your email client connection here - // For example, connect to IMAP server with credentials await _connectToEmailServer(credentials['username']!, credentials['password']!); - // Load initial emails - await refreshEmails(); - _isInitialized = true; notifyListeners(); + await refreshEmails(); } catch (e) { _isInitialized = false; notifyListeners(); @@ -45,21 +45,12 @@ class EmailService extends ChangeNotifier { } } - /// Connect to the email server (IMAP/POP3) + /// Connect to the email server Future _connectToEmailServer(String username, String password) async { - // Implement your email server connection logic here - // This would typically involve: - // 1. Setting up IMAP/POP3 connection to RUB's email server - // 2. Authenticating with the provided credentials - // 3. Setting up folder/mailbox access - - // Simulate connection delay - await Future.delayed(const Duration(seconds: 1)); - - // Example connection setup (pseudo-code): - // _imapClient = ImapClient(); - // await _imapClient.connectToServer('imap.rub.de', 993, isSecure: true); - // await _imapClient.login(username, password); + final success = await _emailRepository.connect(username, password); + if (!success) { + throw Exception('Failed to connect to email server'); + } } /// Refresh emails from server @@ -69,15 +60,13 @@ class EmailService extends ChangeNotifier { } try { - // Fetch emails from server await _fetchEmailsFromServer(); notifyListeners(); } catch (e) { - // Handle authentication errors if (e.toString().contains('authentication') || e.toString().contains('credentials')) { - // Credentials might be invalid, logout user await _authService.logout(); _isInitialized = false; + await _emailRepository.disconnect(); notifyListeners(); } rethrow; @@ -86,29 +75,53 @@ class EmailService extends ChangeNotifier { /// Fetch emails from the server Future _fetchEmailsFromServer() async { - // Implement your email fetching logic here - // This would typically involve: - // 1. Selecting the appropriate mailbox/folder - // 2. Fetching email headers and metadata - // 3. Updating your local email list + _allEmails.clear(); + + // Define folder mappings with fallbacks + final folderMappings = { + EmailFolder.inbox: ['INBOX'], + EmailFolder.sent: ['Sent', 'INBOX.Sent', 'INBOX/Sent'], + EmailFolder.drafts: ['Drafts', 'INBOX.Drafts', 'INBOX/Drafts'], + EmailFolder.trash: ['Trash', 'INBOX.Trash', 'INBOX/Trash', 'Deleted Messages'], + }; - // Simulate fetching delay - await Future.delayed(const Duration(milliseconds: 500)); + for (final entry in folderMappings.entries) { + final folder = entry.key; + final folderNames = entry.value; - // Example fetching logic (pseudo-code): - // final messages = await _imapClient.fetchMessages(count: 50); - // _emails = messages.map((msg) => Email.fromImapMessage(msg)).toList(); + await _fetchEmailsForFolder(folder, folderNames); + } } - /// Clear all email data (called on logout) + Future _fetchEmailsForFolder(EmailFolder folder, List folderNames) async { + for (final folderName in folderNames) { + try { + final count = folder == EmailFolder.inbox ? 50 : 30; + final emails = await _emailRepository.fetchEmails(mailboxName: folderName, count: count); + + for (final email in emails) { + _allEmails.add(email.copyWith(folder: folder)); + } + return; // Success, no need to try other folder names + } catch (e) { + continue; // Try next folder name + } + } + + if (folder != EmailFolder.inbox) { + print('Could not fetch ${folder.name} emails from any of: ${folderNames.join(', ')}'); + } + } + + /// Clear all email data void clear() { - // Clear all email data _allEmails.clear(); _isInitialized = false; + _emailRepository.disconnect(); notifyListeners(); } - /// Send email (requires authentication) + /// Send email Future sendEmail({ required String to, required String subject, @@ -120,61 +133,120 @@ class EmailService extends ChangeNotifier { throw Exception('Email service not initialized'); } - final credentials = await _authService.getCredentials(); - if (credentials == null) { - throw Exception('No valid credentials found'); - } - - // Implement email sending logic here - await _sendEmailViaSmtp( - username: credentials['username']!, - password: credentials['password']!, + final success = await _emailRepository.sendEmail( to: to, subject: subject, body: body, - cc: cc, - bcc: bcc, + cc: cc?.split(',').map((e) => e.trim()).toList(), + bcc: bcc?.split(',').map((e) => e.trim()).toList(), ); + + if (!success) { + throw Exception('Failed to send email'); + } + + await _refreshSentEmails(); + notifyListeners(); } - /// Send email via SMTP - Future _sendEmailViaSmtp({ - required String username, - required String password, - required String to, - required String subject, - required String body, - String? cc, - String? bcc, + /// Refresh sent emails only + Future _refreshSentEmails() async { + _allEmails.removeWhere((e) => e.folder == EmailFolder.sent); + await _fetchEmailsForFolder(EmailFolder.sent, ['Sent', 'INBOX.Sent', 'INBOX/Sent']); + } + + /// Mark email as read + Future markAsRead(Email email) async { + if (!_isInitialized || email.uid == 0) return; + + final success = await _emailRepository.markAsRead(email.uid); + if (success) { + updateEmail(email.copyWith(isRead: true)); + } + } + + /// Mark email as unread + Future markAsUnread(Email email) async { + if (!_isInitialized || email.uid == 0) return; + + final success = await _emailRepository.markAsUnread(email.uid); + if (success) { + updateEmail(email.copyWith(isRead: false)); + } + } + + /// Delete email (move to trash or permanently delete) + Future deleteEmail(Email email) async { + if (!_isInitialized || email.uid == 0) return; + + if (email.folder == EmailFolder.trash) { + final success = await _emailRepository.deleteEmail(email.uid, mailboxName: 'Trash'); + if (success) { + _allEmails.removeWhere((e) => e.id == email.id); + notifyListeners(); + } + } else { + final success = await _emailRepository.moveEmail(email.uid, 'Trash'); + if (success) { + updateEmail(email.copyWith(folder: EmailFolder.trash)); + } + } + } + + /// Search emails + Future> searchEmails({ + String? query, + String? from, + String? subject, + EmailFolder? folder, + bool unreadOnly = false, }) async { - // Implement SMTP sending logic here - // Example (pseudo-code): - // final smtpClient = SmtpClient(); - // await smtpClient.connect('smtp.rub.de', 587); - // await smtpClient.authenticate(username, password); - // await smtpClient.sendMessage(message); + if (!_isInitialized) return []; - // Simulate sending delay - await Future.delayed(const Duration(seconds: 1)); + final mailboxName = _getMailboxNameForFolder(folder ?? EmailFolder.inbox); + final results = await _emailRepository.searchEmails( + query: query, + from: from, + subject: subject, + unreadOnly: unreadOnly, + mailboxName: mailboxName, + ); + + return results.map((email) => email.copyWith(folder: folder ?? EmailFolder.inbox)).toList(); + } + + String _getMailboxNameForFolder(EmailFolder folder) { + switch (folder) { + case EmailFolder.sent: + return 'Sent'; + case EmailFolder.drafts: + return 'Drafts'; + case EmailFolder.trash: + return 'Trash'; + default: + return 'INBOX'; + } } /// Check if service needs re-authentication Future needsReAuthentication() async { if (!_isInitialized) return true; - + if (!_emailRepository.isConnected) return true; return !(await _authService.validateCurrentCredentials()); } - // Existing methods from original EmailService + // ======================================================================== + // LOCAL DATA MANAGEMENT METHODS + // ======================================================================== + List filterEmails(String query, EmailFolder folder) { final filtered = _allEmails.where((e) => e.folder == folder).toList(); if (query.isEmpty) return filtered; + return filtered - .where( - (email) => - email.sender.toLowerCase().contains(query.toLowerCase()) || - email.subject.toLowerCase().contains(query.toLowerCase()), - ) + .where((email) => + email.sender.toLowerCase().contains(query.toLowerCase()) || + email.subject.toLowerCase().contains(query.toLowerCase())) .toList(); } @@ -188,6 +260,13 @@ class EmailService extends ChangeNotifier { void moveEmailsToFolder(Iterable emails, EmailFolder folder) { for (final email in emails) { + if (_isInitialized && email.uid != 0) { + final targetMailbox = _getMailboxNameForFolder(folder); + _emailRepository.moveEmail(email.uid, targetMailbox).catchError((e) { + print('Error moving email on server: $e'); + }); + } + final index = _allEmails.indexWhere((e) => e.id == email.id); if (index != -1) { _allEmails[index] = email.copyWith(folder: folder); @@ -197,6 +276,14 @@ class EmailService extends ChangeNotifier { } void deleteEmailsPermanently(Iterable emails) { + for (final email in emails) { + if (_isInitialized && email.uid != 0) { + _emailRepository.deleteEmail(email.uid).catchError((e) { + print('Error deleting email permanently: $e'); + }); + } + } + _allEmails.removeWhere((e) => emails.any((email) => email.id == e.id)); _selectionController.clearSelection(); notifyListeners(); @@ -213,15 +300,33 @@ class EmailService extends ChangeNotifier { } final index = _allEmails.indexWhere((e) => e.id == draft.id); + final updatedDraft = draft.copyWith(folder: EmailFolder.drafts); + if (index != -1) { - _allEmails[index] = draft.copyWith(folder: EmailFolder.drafts); + _allEmails[index] = updatedDraft; } else { - _allEmails.add(draft.copyWith(folder: EmailFolder.drafts)); + _allEmails.add(updatedDraft); } + notifyListeners(); } void removeDraft(String draftId) { + Email? draft; + try { + draft = _allEmails.firstWhere( + (e) => e.id == draftId && e.folder == EmailFolder.drafts, + ); + } catch (e) { + draft = null; // Email not found + } + + if (draft != null && _isInitialized && draft.uid != 0) { + _emailRepository.deleteEmail(draft.uid, mailboxName: 'Drafts').catchError((e) { + print('Error removing draft from server: $e'); + }); + } + _allEmails.removeWhere((e) => e.id == draftId && e.folder == EmailFolder.drafts); notifyListeners(); } @@ -242,9 +347,14 @@ class EmailService extends ChangeNotifier { return _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).length; } + int get unreadCount { + return _allEmails.where((e) => e.folder == EmailFolder.inbox && !e.isRead).length; + } + @override void dispose() { _selectionController.dispose(); + _emailRepository.disconnect(); super.dispose(); } } diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart new file mode 100644 index 00000000..5b44a542 --- /dev/null +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:enough_mail/enough_mail.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; + +class ImapEmailService { + ImapClient? _imapClient; + SmtpClient? _smtpClient; + String? _username; + String? _password; + + // RUB email server configuration + static const String _imapHost = 'mail.ruhr-uni-bochum.de'; + static const int _imapPort = 993; + static const String _smtpHost = 'mail.ruhr-uni-bochum.de'; + static const int _smtpPort = 587; + + bool get isConnected => _imapClient?.isConnected ?? false; + + Future connect(String username, String password) async { + try { + _username = username; + _password = password; + _imapClient = ImapClient(isLogEnabled: false); + await _imapClient!.connectToServer(_imapHost, _imapPort, isSecure: true); + await _imapClient!.login(_username!, _password!); + print('Successfully connected to RUB email server'); + return true; + } catch (e) { + print('Failed to connect to email server: $e'); + return false; + } + } + + Future disconnect() async { + try { + await _imapClient?.disconnect(); + await _smtpClient?.disconnect(); + _imapClient = null; + _smtpClient = null; + } catch (e) { + print('Error disconnecting: $e'); + } + } + + Future> fetchEmails({ + String mailboxName = 'INBOX', + int count = 50, + int page = 1, + }) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to email server'); + } + + final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); + final total = mailbox.messagesExists; + final start = total - (page * count) + 1; + final end = total - ((page - 1) * count); + if (start <= 0) return []; + + final fetchResult = await _imapClient!.fetchMessages( + MessageSequence.fromRange(math.max(1, start), math.min(total, end)), + 'BODY.PEEK[HEADER] BODY.PEEK[TEXT]', + ); + + return fetchResult.messages.map(_convertMimeMessageToEmail).toList().reversed.toList(); + } + + Future fetchEmailByUid(int uid, {String mailboxName = 'INBOX'}) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to email server'); + } + + await _imapClient!.selectMailboxByPath(mailboxName); + final result = await _imapClient!.uidFetchMessage(uid, 'BODY[]'); + if (result.messages.isNotEmpty) { + return _convertMimeMessageToEmail(result.messages.first); + } + return null; + } + + Future sendEmail({ + required String to, + required String subject, + required String body, + List? cc, + List? bcc, + List? attachments, + }) async { + try { + // Initialize SMTP client + if (_smtpClient == null) { + _smtpClient = SmtpClient('RUB-Flutter-Client', isLogEnabled: false); + await _smtpClient!.connectToServer(_smtpHost, _smtpPort, isSecure: false); + await _smtpClient!.ehlo(); + await _smtpClient!.startTls(); + await _smtpClient!.authenticate(_username!, _password!, AuthMechanism.login); + } + + // Use the builder API instead of buildSimpleTextMessage: + final builder = MessageBuilder.prepareMultipartAlternativeMessage( + plainText: body, + ) + ..from = [MailAddress('', _username!)] + ..to = [MailAddress('', to)] + ..subject = subject; + + if (cc != null && cc.isNotEmpty) { + builder.cc = cc.map((e) => MailAddress('', e)).toList(); + } + if (bcc != null && bcc.isNotEmpty) { + builder.bcc = bcc.map((e) => MailAddress('', e)).toList(); + } + + // (You can re-add attachments here once you've inspected the new attachment API.) + + final message = builder.buildMimeMessage(); + await _smtpClient!.sendMessage(message); + return true; + } catch (e) { + print('Error sending email: $e'); + return false; + } + } + + Future markAsRead(int uid, {String mailboxName = 'INBOX'}) { + return _updateEmailFlags(uid, [MessageFlags.seen], mailboxName: mailboxName); + } + + Future markAsUnread(int uid, {String mailboxName = 'INBOX'}) { + return _updateEmailFlags( + uid, + [MessageFlags.seen], + remove: true, + mailboxName: mailboxName, + ); + } + + Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) async { + try { + await _imapClient!.selectMailboxByPath(mailboxName); + await _imapClient!.uidStore(MessageSequence.fromId(uid), [MessageFlags.deleted]); + await _imapClient!.expunge(); + return true; + } catch (e) { + print('Error deleting email: $e'); + return false; + } + } + + Future moveEmail( + int uid, + String targetMailbox, { + String sourceMailbox = 'INBOX', + }) async { + try { + await _imapClient!.selectMailboxByPath(sourceMailbox); + await _imapClient!.selectMailboxByPath(targetMailbox); + await _imapClient!.uidMove(MessageSequence.fromId(uid)); + return true; + } catch (e) { + print('Error moving email: $e'); + return false; + } + } + + Future> getMailboxes() async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to email server'); + } + final boxes = await _imapClient!.listMailboxes(); + return boxes.map((m) => m.name).toList(); + } + + Future> searchEmails({ + String? query, + String? from, + String? subject, + DateTime? since, + bool unreadOnly = false, + String mailboxName = 'INBOX', + }) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to email server'); + } + + // build IMAP SEARCH criteria + final criteria = []; + if (query?.isNotEmpty ?? false) criteria.add('TEXT "$query"'); + if (from?.isNotEmpty ?? false) criteria.add('FROM "$from"'); + if (subject?.isNotEmpty ?? false) criteria.add('SUBJECT "$subject"'); + if (since != null) { + criteria.add('SINCE ${DateCodec.encodeSearchDate(since)}'); + } + if (unreadOnly) criteria.add('UNSEEN'); + + final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); + final total = mailbox.messagesExists; + + // fetchRecentMessages accepts a named `criteria` string + final fetchResult = await _imapClient!.fetchRecentMessages( + messageCount: total, + criteria: criteria.join(' '), + ); + + return fetchResult.messages.map(_convertMimeMessageToEmail).toList(); + } + + Future _updateEmailFlags( + int uid, + List flags, { + bool remove = false, + String mailboxName = 'INBOX', + }) async { + try { + await _imapClient!.selectMailboxByPath(mailboxName); + await _imapClient!.uidStore( + MessageSequence.fromId(uid), + flags, + action: remove ? StoreAction.remove : StoreAction.add, + ); + return true; + } catch (e) { + print('Error updating email flags: $e'); + return false; + } + } + + Email _convertMimeMessageToEmail(MimeMessage msg) { + return Email( + id: msg.uid?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), + subject: msg.decodeSubject() ?? 'No Subject', + body: msg.decodeTextPlainPart() ?? msg.decodeTextHtmlPart() ?? '', + sender: msg.from?.first.personalName ?? msg.from?.first.email ?? 'Unknown', + senderEmail: msg.from?.first.email ?? 'unknown@example.com', + recipients: msg.to?.map((addr) => addr.email).toList() ?? [], + date: msg.decodeDate() ?? DateTime.now(), + isUnread: !msg.isSeen, + isStarred: msg.isFlagged, + // stubbed; re-add once you’ve inspected MimePart’s new API: + attachments: [], + uid: msg.uid ?? 0, + ); + } +} diff --git a/lib/pages/more/more_page.dart b/lib/pages/more/more_page.dart index 13de23bc..65a974cc 100644 --- a/lib/pages/more/more_page.dart +++ b/lib/pages/more/more_page.dart @@ -1,5 +1,5 @@ import 'dart:io' show Platform; -import 'package:campus_app/pages/email_client/email_page.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_page.dart'; import 'package:campus_app/pages/more/privacy_policy_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/pubspec.lock b/pubspec.lock index adc22cf1..5f9629be 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -102,6 +102,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "0511d6be23b007e95105ae023db599aea731df604608978dada7f9faf2637623" + url: "https://pub.dev" + source: hosted + version: "1.6.4" async: dependency: transitive description: @@ -110,6 +118,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.12.0" + 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: @@ -398,6 +414,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + enough_convert: + dependency: transitive + description: + name: enough_convert + sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + enough_mail: + dependency: "direct main" + description: + name: enough_mail + sha256: a88d8c56907caeffdebc4cf34f3a5665c09a8d7496ef5e09b3166f41cf409f81 + url: "https://pub.dev" + source: hosted + version: "2.1.6" envied: dependency: "direct main" description: @@ -422,6 +462,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" fake_async: dependency: transitive description: @@ -1212,6 +1260,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dccb73b5..0e4e04c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: image: ^4.3.0 dismissible_page: ^1.0.2 app_links: ^6.4.0 + enough_mail: ^2.1.5 dev_dependencies: flutter_test: From 15ac0c874bbe7bea6fbff1251b20d01a67e087fe Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Mon, 16 Jun 2025 18:43:35 +0200 Subject: [PATCH 15/20] Fixed Emails not showing up --- .../email_client/services/email_service.dart | 6 +- .../services/imap_email_service.dart | 84 ++++++++++--------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index 2bcb67c3..b64c5429 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -80,9 +80,9 @@ class EmailService extends ChangeNotifier { // Define folder mappings with fallbacks final folderMappings = { EmailFolder.inbox: ['INBOX'], - EmailFolder.sent: ['Sent', 'INBOX.Sent', 'INBOX/Sent'], - EmailFolder.drafts: ['Drafts', 'INBOX.Drafts', 'INBOX/Drafts'], - EmailFolder.trash: ['Trash', 'INBOX.Trash', 'INBOX/Trash', 'Deleted Messages'], + EmailFolder.sent: ['Sent'], + EmailFolder.drafts: ['Drafts'], + EmailFolder.trash: ['Trash'], }; for (final entry in folderMappings.entries) { diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart index 5b44a542..115b5e41 100644 --- a/lib/pages/email_client/services/imap_email_service.dart +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:enough_mail/enough_mail.dart'; +import 'package:intl/intl.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; class ImapEmailService { @@ -9,7 +10,6 @@ class ImapEmailService { String? _username; String? _password; - // RUB email server configuration static const String _imapHost = 'mail.ruhr-uni-bochum.de'; static const int _imapPort = 993; static const String _smtpHost = 'mail.ruhr-uni-bochum.de'; @@ -18,13 +18,16 @@ class ImapEmailService { bool get isConnected => _imapClient?.isConnected ?? false; Future connect(String username, String password) async { + _imapClient = ImapClient(isLogEnabled: true); try { _username = username; _password = password; - _imapClient = ImapClient(isLogEnabled: false); await _imapClient!.connectToServer(_imapHost, _imapPort, isSecure: true); await _imapClient!.login(_username!, _password!); print('Successfully connected to RUB email server'); + + final boxes = await _imapClient!.listMailboxes(); + print('Available mailboxes: ${boxes.map((m) => m.name).toList()}'); return true; } catch (e) { print('Failed to connect to email server: $e'); @@ -36,10 +39,13 @@ class ImapEmailService { try { await _imapClient?.disconnect(); await _smtpClient?.disconnect(); - _imapClient = null; - _smtpClient = null; } catch (e) { print('Error disconnecting: $e'); + } finally { + _imapClient = null; + _smtpClient = null; + _username = null; + _password = null; } } @@ -52,17 +58,29 @@ class ImapEmailService { throw Exception('Not connected to email server'); } + // 1. select mailbox final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); + + // 2. how many messages there? final total = mailbox.messagesExists; - final start = total - (page * count) + 1; - final end = total - ((page - 1) * count); - if (start <= 0) return []; + print('DEBUG: $mailboxName has $total messages'); + + // 3. bail out only if truly empty + if (total == 0) return []; + + // 4. clamp page-range into [1..total] + final start = math.max(1, total - (page * count) + 1); + final end = math.min(total, total - ((page - 1) * count)); + // 5. fetch with parentheses around the item list final fetchResult = await _imapClient!.fetchMessages( - MessageSequence.fromRange(math.max(1, start), math.min(total, end)), - 'BODY.PEEK[HEADER] BODY.PEEK[TEXT]', + MessageSequence.fromRange(start, end), + '(BODY.PEEK[HEADER] BODY.PEEK[TEXT])', ); + print('DEBUG: fetched ${fetchResult.messages.length} ' + 'messages from $mailboxName'); + // 6. map & reverse (newest-first) return fetchResult.messages.map(_convertMimeMessageToEmail).toList().reversed.toList(); } @@ -70,7 +88,6 @@ class ImapEmailService { if (_imapClient == null || !_imapClient!.isConnected) { throw Exception('Not connected to email server'); } - await _imapClient!.selectMailboxByPath(mailboxName); final result = await _imapClient!.uidFetchMessage(uid, 'BODY[]'); if (result.messages.isNotEmpty) { @@ -88,7 +105,6 @@ class ImapEmailService { List? attachments, }) async { try { - // Initialize SMTP client if (_smtpClient == null) { _smtpClient = SmtpClient('RUB-Flutter-Client', isLogEnabled: false); await _smtpClient!.connectToServer(_smtpHost, _smtpPort, isSecure: false); @@ -97,22 +113,19 @@ class ImapEmailService { await _smtpClient!.authenticate(_username!, _password!, AuthMechanism.login); } - // Use the builder API instead of buildSimpleTextMessage: - final builder = MessageBuilder.prepareMultipartAlternativeMessage( - plainText: body, - ) + final builder = MessageBuilder.prepareMultipartAlternativeMessage(plainText: body) ..from = [MailAddress('', _username!)] ..to = [MailAddress('', to)] ..subject = subject; - if (cc != null && cc.isNotEmpty) { - builder.cc = cc.map((e) => MailAddress('', e)).toList(); + if (cc?.isNotEmpty ?? false) { + builder.cc = cc!.map((e) => MailAddress('', e)).toList(); } - if (bcc != null && bcc.isNotEmpty) { - builder.bcc = bcc.map((e) => MailAddress('', e)).toList(); + if (bcc?.isNotEmpty ?? false) { + builder.bcc = bcc!.map((e) => MailAddress('', e)).toList(); } - // (You can re-add attachments here once you've inspected the new attachment API.) + // TODO: attachments if needed final message = builder.buildMimeMessage(); await _smtpClient!.sendMessage(message); @@ -123,18 +136,15 @@ class ImapEmailService { } } - Future markAsRead(int uid, {String mailboxName = 'INBOX'}) { - return _updateEmailFlags(uid, [MessageFlags.seen], mailboxName: mailboxName); - } + Future markAsRead(int uid, {String mailboxName = 'INBOX'}) => + _updateEmailFlags(uid, [MessageFlags.seen], mailboxName: mailboxName); - Future markAsUnread(int uid, {String mailboxName = 'INBOX'}) { - return _updateEmailFlags( - uid, - [MessageFlags.seen], - remove: true, - mailboxName: mailboxName, - ); - } + Future markAsUnread(int uid, {String mailboxName = 'INBOX'}) => _updateEmailFlags( + uid, + [MessageFlags.seen], + remove: true, + mailboxName: mailboxName, + ); Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) async { try { @@ -173,36 +183,35 @@ class ImapEmailService { } Future> searchEmails({ + String mailboxName = 'INBOX', String? query, String? from, String? subject, DateTime? since, bool unreadOnly = false, - String mailboxName = 'INBOX', }) async { if (_imapClient == null || !_imapClient!.isConnected) { throw Exception('Not connected to email server'); } - // build IMAP SEARCH criteria final criteria = []; if (query?.isNotEmpty ?? false) criteria.add('TEXT "$query"'); if (from?.isNotEmpty ?? false) criteria.add('FROM "$from"'); if (subject?.isNotEmpty ?? false) criteria.add('SUBJECT "$subject"'); if (since != null) { - criteria.add('SINCE ${DateCodec.encodeSearchDate(since)}'); + final formatted = DateFormat('dd-MMM-yyyy').format(since).toUpperCase(); + criteria.add('SINCE $formatted'); } if (unreadOnly) criteria.add('UNSEEN'); + if (criteria.isEmpty) criteria.add('ALL'); final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); final total = mailbox.messagesExists; - // fetchRecentMessages accepts a named `criteria` string final fetchResult = await _imapClient!.fetchRecentMessages( messageCount: total, criteria: criteria.join(' '), ); - return fetchResult.messages.map(_convertMimeMessageToEmail).toList(); } @@ -237,8 +246,7 @@ class ImapEmailService { date: msg.decodeDate() ?? DateTime.now(), isUnread: !msg.isSeen, isStarred: msg.isFlagged, - // stubbed; re-add once you’ve inspected MimePart’s new API: - attachments: [], + attachments: [], // implement if needed uid: msg.uid ?? 0, ); } From 56fc928a9ce7e3d8f6b0e2ef10d6fc0868e17e57 Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Mon, 23 Jun 2025 18:09:45 +0200 Subject: [PATCH 16/20] updated enough_mail, fixed related issues. Email body now accepts Plaintext and html. Not all emails show body --- .../email_pages/compose_email_Screen.dart | 6 ++--- .../email_client/email_pages/email_view.dart | 11 +++++---- lib/pages/email_client/models/email.dart | 6 +++++ .../services/imap_email_service.dart | 24 ++++++++++++------- pubspec.yaml | 2 +- 5 files changed, 33 insertions(+), 16 deletions(-) diff --git a/lib/pages/email_client/email_pages/compose_email_Screen.dart b/lib/pages/email_client/email_pages/compose_email_Screen.dart index 02e92e6d..f46bf4ba 100644 --- a/lib/pages/email_client/email_pages/compose_email_Screen.dart +++ b/lib/pages/email_client/email_pages/compose_email_Screen.dart @@ -35,15 +35,15 @@ class _ComposeEmailScreenState extends State { if (widget.draft != null) { _toController.text = widget.draft!.recipients.join(', '); _subjectController.text = widget.draft!.subject; - _bodyController.text = widget.draft!.body; + _bodyController.text = widget.draft!.htmlBody ?? widget.draft!.body; _attachments.addAll(widget.draft!.attachments); } else if (widget.replyTo != null) { _toController.text = widget.replyTo!.senderEmail; _subjectController.text = 'Re: ${widget.replyTo!.subject}'; - _bodyController.text = '\n\n----------\n${widget.replyTo!.body}'; + _bodyController.text = '\n\n----------\n${widget.replyTo!.htmlBody ?? widget.replyTo!.body}'; } else if (widget.forwardFrom != null) { _subjectController.text = 'Fwd: ${widget.forwardFrom!.subject}'; - _bodyController.text = '\n\n----------\n${widget.forwardFrom!.body}'; + _bodyController.text = '\n\n----------\n${widget.forwardFrom!.htmlBody ?? widget.forwardFrom!.body}'; } } diff --git a/lib/pages/email_client/email_pages/email_view.dart b/lib/pages/email_client/email_pages/email_view.dart index 6e73d18a..c24e3661 100644 --- a/lib/pages/email_client/email_pages/email_view.dart +++ b/lib/pages/email_client/email_pages/email_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; +import 'package:flutter_html/flutter_html.dart'; class EmailView extends StatelessWidget { final Email email; @@ -148,10 +149,12 @@ class EmailView extends StatelessWidget { const SizedBox(height: 16), // Body - Text( - email.body, - style: theme.textTheme.bodyLarge, - ), + email.htmlBody != null && email.htmlBody!.isNotEmpty + ? Html(data: email.htmlBody!) + : Text( + email.body, + style: theme.textTheme.bodyLarge, + ), // Attachments if (email.attachments.isNotEmpty) ...[ diff --git a/lib/pages/email_client/models/email.dart b/lib/pages/email_client/models/email.dart index e491cf70..883a1980 100644 --- a/lib/pages/email_client/models/email.dart +++ b/lib/pages/email_client/models/email.dart @@ -7,6 +7,7 @@ class Email { final List recipients; final String subject; final String body; + final String? htmlBody; final DateTime date; final bool isUnread; final bool isStarred; @@ -23,6 +24,7 @@ class Email { required this.recipients, required this.subject, required this.body, + this.htmlBody, required this.date, this.isUnread = false, this.isStarred = false, @@ -56,6 +58,7 @@ class Email { 'recipients': recipients, 'subject': subject, 'body': body, + 'htmlBody': htmlBody, 'date': date.toIso8601String(), 'isRead': !isUnread, 'isStarred': isStarred, @@ -71,6 +74,7 @@ class Email { recipients: List.from(json['recipients']), subject: json['subject'], body: json['body'], + htmlBody: json['htmlBody'], date: DateTime.parse(json['date']), isUnread: !json['isRead'], isStarred: json['isStarred'], @@ -86,6 +90,7 @@ class Email { List? recipients, String? subject, String? body, + String? htmlBody, DateTime? date, bool? isUnread, bool? isStarred, @@ -101,6 +106,7 @@ class Email { recipients: recipients ?? this.recipients, subject: subject ?? this.subject, body: body ?? this.body, + htmlBody: htmlBody ?? this.htmlBody, date: date ?? this.date, isUnread: isRead != null ? !isRead : (isUnread ?? this.isUnread), isStarred: isStarred ?? this.isStarred, diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart index 115b5e41..0f88a754 100644 --- a/lib/pages/email_client/services/imap_email_service.dart +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:enough_mail/enough_mail.dart'; import 'package:intl/intl.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +import 'dart:convert'; class ImapEmailService { ImapClient? _imapClient; @@ -81,7 +82,7 @@ class ImapEmailService { 'messages from $mailboxName'); // 6. map & reverse (newest-first) - return fetchResult.messages.map(_convertMimeMessageToEmail).toList().reversed.toList(); + return (await Future.wait(fetchResult.messages.map(_convertMimeMessageToEmail))).reversed.toList(); } Future fetchEmailByUid(int uid, {String mailboxName = 'INBOX'}) async { @@ -91,7 +92,7 @@ class ImapEmailService { await _imapClient!.selectMailboxByPath(mailboxName); final result = await _imapClient!.uidFetchMessage(uid, 'BODY[]'); if (result.messages.isNotEmpty) { - return _convertMimeMessageToEmail(result.messages.first); + return await _convertMimeMessageToEmail(result.messages.first); } return null; } @@ -212,7 +213,7 @@ class ImapEmailService { messageCount: total, criteria: criteria.join(' '), ); - return fetchResult.messages.map(_convertMimeMessageToEmail).toList(); + return await Future.wait(fetchResult.messages.map(_convertMimeMessageToEmail)); } Future _updateEmailFlags( @@ -235,18 +236,25 @@ class ImapEmailService { } } - Email _convertMimeMessageToEmail(MimeMessage msg) { + Future _convertMimeMessageToEmail(MimeMessage msg) async { + final plain = msg.decodeTextPlainPart(); + final html = msg.decodeTextHtmlPart(); + + print('FIXED DECODE plain=$plain'); + print('FIXED DECODE html=$html'); + return Email( id: msg.uid?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), subject: msg.decodeSubject() ?? 'No Subject', - body: msg.decodeTextPlainPart() ?? msg.decodeTextHtmlPart() ?? '', + body: plain ?? html ?? '', + htmlBody: html, sender: msg.from?.first.personalName ?? msg.from?.first.email ?? 'Unknown', - senderEmail: msg.from?.first.email ?? 'unknown@example.com', - recipients: msg.to?.map((addr) => addr.email).toList() ?? [], + senderEmail: msg.from?.first.email ?? '', + recipients: msg.to?.map((a) => a.email).toList() ?? [], date: msg.decodeDate() ?? DateTime.now(), isUnread: !msg.isSeen, isStarred: msg.isFlagged, - attachments: [], // implement if needed + attachments: [], uid: msg.uid ?? 0, ); } diff --git a/pubspec.yaml b/pubspec.yaml index 0e4e04c0..2a018b7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,7 +62,7 @@ dependencies: image: ^4.3.0 dismissible_page: ^1.0.2 app_links: ^6.4.0 - enough_mail: ^2.1.5 + enough_mail: ^2.1.6 dev_dependencies: flutter_test: From a862b1c7a2d0028961f77bd46086537ca43fb59c Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Tue, 24 Jun 2025 18:20:05 +0200 Subject: [PATCH 17/20] UI theme match. Added Spam folder. Some minor code improvements --- .../email_client/email_drawer/drafts.dart | 5 +- lib/pages/email_client/email_drawer/spam.dart | 57 +++++++ .../email_client/email_drawer/trash.dart | 5 +- .../email_pages/compose_email_Screen.dart | 37 ++++- .../email_pages/email_drawer.dart | 111 +++++++++----- .../email_client/email_pages/email_page.dart | 18 +-- .../email_client/email_pages/email_view.dart | 17 +- lib/pages/email_client/models/email.dart | 1 + .../email_client/services/email_service.dart | 145 ++++++------------ .../services/imap_email_service.dart | 10 +- .../email_client/widgets/email_tile.dart | 74 +++++---- .../email_client/widgets/select_email.dart | 36 +++-- 12 files changed, 313 insertions(+), 203 deletions(-) create mode 100644 lib/pages/email_client/email_drawer/spam.dart diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart index 29491ad2..56e4ee71 100644 --- a/lib/pages/email_client/email_drawer/drafts.dart +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -26,7 +26,10 @@ class _DraftsPageState extends State { ? _buildEmptyState() : ListView.separated( itemCount: drafts.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, __) => Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), itemBuilder: (_, index) { final draft = drafts[index]; return EmailTile( diff --git a/lib/pages/email_client/email_drawer/spam.dart b/lib/pages/email_client/email_drawer/spam.dart new file mode 100644 index 00000000..0a00584c --- /dev/null +++ b/lib/pages/email_client/email_drawer/spam.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:campus_app/pages/email_client/models/email.dart'; +import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +import 'package:campus_app/pages/email_client/email_pages/email_view.dart'; + +class SpamPage extends StatelessWidget { + const SpamPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final emailService = Provider.of(context); + final spamEmails = emailService.allEmails.where((email) => email.folder == EmailFolder.spam).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Spam'), + ), + body: RefreshIndicator( + onRefresh: () => emailService.refreshEmails(), + child: spamEmails.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.4), + Center( + child: Text( + 'No spam emails', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ) + : ListView.builder( + itemCount: spamEmails.length, + itemBuilder: (context, index) { + final email = spamEmails[index]; + return EmailTile( + email: email, + isSelected: false, + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => EmailView(email: email), + ), + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/pages/email_client/email_drawer/trash.dart b/lib/pages/email_client/email_drawer/trash.dart index 0b9954d4..42dfb2d9 100644 --- a/lib/pages/email_client/email_drawer/trash.dart +++ b/lib/pages/email_client/email_drawer/trash.dart @@ -47,7 +47,10 @@ class TrashPage extends StatelessWidget { ? const Center(child: Text('Trash is empty.')) : ListView.separated( itemCount: trashEmails.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, __) => Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), itemBuilder: (_, index) { final email = trashEmails[index]; return EmailTile( diff --git a/lib/pages/email_client/email_pages/compose_email_Screen.dart b/lib/pages/email_client/email_pages/compose_email_Screen.dart index f46bf4ba..67f6a3e3 100644 --- a/lib/pages/email_client/email_pages/compose_email_Screen.dart +++ b/lib/pages/email_client/email_pages/compose_email_Screen.dart @@ -171,14 +171,24 @@ class _ComposeEmailScreenState extends State { // To Field TextFormField( controller: _toController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'To', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).dividerColor), + ), ), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter recipient'; } + + final emails = value.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty); + for (final email in emails) { + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(email)) { + return 'Invalid email: $email'; + } + } + return null; }, ), @@ -189,6 +199,9 @@ class _ComposeEmailScreenState extends State { alignment: Alignment.centerRight, child: TextButton( onPressed: () => setState(() => _showCcBcc = !_showCcBcc), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), child: Text(_showCcBcc ? 'Hide CC/BCC' : 'Add CC/BCC'), ), ), @@ -197,9 +210,11 @@ class _ComposeEmailScreenState extends State { if (_showCcBcc) ...[ TextFormField( controller: _ccController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'CC', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).dividerColor), + ), ), ), const SizedBox(height: 8), @@ -209,9 +224,11 @@ class _ComposeEmailScreenState extends State { if (_showCcBcc) ...[ TextFormField( controller: _bccController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'BCC', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).dividerColor), + ), ), ), const SizedBox(height: 8), @@ -220,9 +237,11 @@ class _ComposeEmailScreenState extends State { // Subject Field TextFormField( controller: _subjectController, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: 'Subject', - border: OutlineInputBorder(), + border: OutlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).dividerColor), + ), ), ), const SizedBox(height: 8), @@ -236,6 +255,8 @@ class _ComposeEmailScreenState extends State { itemCount: _attachments.length, itemBuilder: (context, index) => Chip( label: Text(_attachments[index]), + backgroundColor: Theme.of(context).chipTheme.backgroundColor, + deleteIconColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), deleteIcon: const Icon(Icons.close, size: 18), onDeleted: () => setState(() => _attachments.removeAt(index)), ), diff --git a/lib/pages/email_client/email_pages/email_drawer.dart b/lib/pages/email_client/email_pages/email_drawer.dart index 4685eea0..d545f38a 100644 --- a/lib/pages/email_client/email_pages/email_drawer.dart +++ b/lib/pages/email_client/email_pages/email_drawer.dart @@ -1,8 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:campus_app/pages/email_client/email_drawer/archives.dart'; import 'package:campus_app/pages/email_client/email_drawer/drafts.dart'; import 'package:campus_app/pages/email_client/email_drawer/sent.dart'; import 'package:campus_app/pages/email_client/email_drawer/trash.dart'; +import 'package:campus_app/pages/email_client/services/email_auth_service.dart'; +import 'package:campus_app/pages/email_client/services/email_service.dart'; +// TODO: Create this page and import it +import 'package:campus_app/pages/email_client/email_drawer/spam.dart'; class EmailDrawer extends StatelessWidget { const EmailDrawer({super.key}); @@ -17,79 +22,75 @@ class EmailDrawer extends StatelessWidget { child: ListView( padding: EdgeInsets.zero, children: [ + // === Drawer header with user info === DrawerHeader( decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, + color: theme.colorScheme.surfaceVariant, ), - child: const Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( + const CircleAvatar( radius: 25, child: Icon(Icons.person, size: 30), ), - SizedBox(height: 10), + const SizedBox(height: 10), Text( 'Your Name', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), Text( 'you@example.com', - style: TextStyle(fontSize: 14), + style: theme.textTheme.bodySmall, ), ], ), ), - // Inbox without navigation for example: + // === Drawer navigation options === ListTile( - leading: const Icon(Icons.inbox), - title: const Text('Inbox'), + leading: Icon(Icons.inbox, color: theme.iconTheme.color), + title: Text('Inbox', style: theme.textTheme.bodyLarge), onTap: () => Navigator.pop(context), ), + _buildDrawerItem(context, icon: Icons.send, title: 'Sent', page: const SentPage()), + _buildDrawerItem(context, icon: Icons.archive, title: 'Archives', page: const ArchivesPage()), + _buildDrawerItem(context, icon: Icons.drafts, title: 'Drafts', page: const DraftsPage()), + _buildDrawerItem(context, icon: Icons.delete, title: 'Trash', page: const TrashPage()), - // Using helper method to reduce repetition: + // === NEW: Spam folder === _buildDrawerItem( context, - icon: Icons.send, - title: 'Sent', - page: const SentPage(), - ), - _buildDrawerItem( - context, - icon: Icons.archive, - title: 'Archives', - page: const ArchivesPage(), - ), - _buildDrawerItem( - context, - icon: Icons.drafts, - title: 'Drafts', - page: const DraftsPage(), - ), - _buildDrawerItem( - context, - icon: Icons.delete, - title: 'Trash', - page: const TrashPage(), + icon: Icons.report_gmailerrorred, + title: 'Spam', + page: const SpamPage(), // Make sure you define this page ), const Divider(), + // === Settings option (placeholder) === ListTile( - leading: const Icon(Icons.settings), - title: const Text('Settings'), + leading: Icon(Icons.settings, color: theme.iconTheme.color), + title: Text('Settings', style: theme.textTheme.bodyLarge), onTap: () { Navigator.pop(context); - // TODO: Add SettingsPage() navigation here later + // TODO: Add SettingsPage navigation }, ), + + // === Logout with confirmation === + ListTile( + leading: Icon(Icons.logout, color: theme.colorScheme.error), + title: Text('Logout', style: TextStyle(color: theme.colorScheme.error)), + onTap: () => _confirmLogout(context), + ), ], ), ), ); } + /// Helper to create drawer items with consistent styling and navigation Widget _buildDrawerItem( BuildContext context, { required IconData icon, @@ -97,12 +98,10 @@ class EmailDrawer extends StatelessWidget { required Widget page, }) { return ListTile( - leading: Icon(icon), - title: Text(title), + leading: Icon(icon, color: Theme.of(context).iconTheme.color), + title: Text(title, style: Theme.of(context).textTheme.bodyLarge), onTap: () { - Navigator.pop(context); // close the drawer first - - // Delay navigation until after drawer closes + Navigator.pop(context); // close drawer first WidgetsBinding.instance.addPostFrameCallback((_) { Navigator.push( context, @@ -112,4 +111,38 @@ class EmailDrawer extends StatelessWidget { }, ); } + + /// Show confirmation dialog before logging the user out + void _confirmLogout(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Logout'), + content: const Text('Are you sure you want to logout?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.pop(ctx); // Close dialog + Navigator.pop(context); // Close drawer + + // Call logout logic from EmailAuthService and EmailService + final emailAuthService = context.read(); + final emailService = context.read(); + + await emailAuthService.logout(); + emailService.clear(); + }, + child: Text( + 'Logout', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); + } } diff --git a/lib/pages/email_client/email_pages/email_page.dart b/lib/pages/email_client/email_pages/email_page.dart index 535fed23..9de9d068 100644 --- a/lib/pages/email_client/email_pages/email_page.dart +++ b/lib/pages/email_client/email_pages/email_page.dart @@ -170,7 +170,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { Icon( Icons.email, size: 64, - color: Theme.of(context).primaryColor, + color: Theme.of(context).colorScheme.primary, ), const SizedBox(height: 24), Text( @@ -242,11 +242,6 @@ class _EmailClientContentState extends State<_EmailClientContent> { onPressed: _selectionController.clearSelection, ), ], - if (!_isSearching && !_selectionController.isSelecting) - IconButton( - icon: const Icon(Icons.logout), - onPressed: _handleLogout, - ), if (!_isSearching && !_selectionController.isSelecting) Builder( builder: (context) => IconButton( @@ -268,7 +263,10 @@ class _EmailClientContentState extends State<_EmailClientContent> { }, child: ListView.separated( itemCount: filteredEmails.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, __) => Divider( + height: 1, + color: Theme.of(context).dividerColor, + ), itemBuilder: (_, index) { final email = filteredEmails[index]; return EmailTile( @@ -307,13 +305,13 @@ class _EmailClientContentState extends State<_EmailClientContent> { FloatingActionButton( heroTag: 'delete', onPressed: () => _selectionController.onDelete?.call(_selectionController.selectedEmails), - child: const Icon(Icons.delete), + child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onPrimary), ), const SizedBox(width: 16), FloatingActionButton( heroTag: 'archive', onPressed: () => _selectionController.onArchive?.call(_selectionController.selectedEmails), - child: const Icon(Icons.archive), + child: Icon(Icons.archive, color: Theme.of(context).colorScheme.onPrimary), ), ], ) @@ -322,7 +320,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { context, MaterialPageRoute(builder: (_) => const ComposeEmailScreen()), ), - child: const Icon(Icons.edit), + child: Icon(Icons.edit, color: Theme.of(context).colorScheme.onPrimary), ), ), ); diff --git a/lib/pages/email_client/email_pages/email_view.dart b/lib/pages/email_client/email_pages/email_view.dart index c24e3661..ec31d64f 100644 --- a/lib/pages/email_client/email_pages/email_view.dart +++ b/lib/pages/email_client/email_pages/email_view.dart @@ -48,7 +48,10 @@ class EmailView extends StatelessWidget { const SnackBar(content: Text('Email permanently deleted')), ); }, - child: const Text('Delete', style: TextStyle(color: Colors.red)), + child: Text( + 'Delete', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), ), ], ), @@ -128,14 +131,18 @@ class EmailView extends StatelessWidget { if (email.senderEmail.isNotEmpty) Text( email.senderEmail, - style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), ), ], ), ), Text( timeText, - style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey[600]), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), ), ], ), @@ -173,13 +180,13 @@ class EmailView extends StatelessWidget { width: 80, margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( - border: Border.all(color: Colors.grey), + border: Border.all(color: theme.dividerColor), borderRadius: BorderRadius.circular(8), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.insert_drive_file, size: 30), + Icon(Icons.insert_drive_file, size: 30, color: theme.iconTheme.color), const SizedBox(height: 4), Text( 'File ${index + 1}', diff --git a/lib/pages/email_client/models/email.dart b/lib/pages/email_client/models/email.dart index 883a1980..af0f6833 100644 --- a/lib/pages/email_client/models/email.dart +++ b/lib/pages/email_client/models/email.dart @@ -132,4 +132,5 @@ enum EmailFolder { drafts, trash, archives, + spam, } diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index b64c5429..0f7b4df9 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -1,6 +1,3 @@ -// REFACTORED EMAIL SERVICE (Business Logic Only) -// ============================================================================ - import 'package:flutter/foundation.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/widgets/select_email.dart'; @@ -9,7 +6,7 @@ import 'package:campus_app/pages/email_client/repositories/email_repository.dart import 'package:campus_app/core/injection.dart'; class EmailService extends ChangeNotifier { - final List _allEmails = []; + final List _allEmails = []; // Local email cache final EmailSelectionController _selectionController = EmailSelectionController(); final EmailAuthService _authService = sl(); final EmailRepository _emailRepository; @@ -21,20 +18,15 @@ class EmailService extends ChangeNotifier { _selectionController.addListener(notifyListeners); } - // Public API List get allEmails => List.unmodifiable(_allEmails); EmailSelectionController get selectionController => _selectionController; - /// Initialize the email service with authenticated credentials + /// Attempt to initialize email connection and pull inbox Future initialize() async { try { final credentials = await _authService.getCredentials(); - if (credentials == null) { - throw Exception('No valid credentials found'); - } - + if (credentials == null) throw Exception('No valid credentials found'); await _connectToEmailServer(credentials['username']!, credentials['password']!); - _isInitialized = true; notifyListeners(); await refreshEmails(); @@ -45,25 +37,20 @@ class EmailService extends ChangeNotifier { } } - /// Connect to the email server + /// Connects using provided credentials Future _connectToEmailServer(String username, String password) async { final success = await _emailRepository.connect(username, password); - if (!success) { - throw Exception('Failed to connect to email server'); - } + if (!success) throw Exception('Failed to connect to email server'); } - /// Refresh emails from server + /// Refreshes all mailbox folders Future refreshEmails() async { - if (!_isInitialized) { - throw Exception('Email service not initialized'); - } - + if (!_isInitialized) throw Exception('Email service not initialized'); try { await _fetchEmailsFromServer(); notifyListeners(); } catch (e) { - if (e.toString().contains('authentication') || e.toString().contains('credentials')) { + if (e.toString().contains('authentication')) { await _authService.logout(); _isInitialized = false; await _emailRepository.disconnect(); @@ -73,26 +60,26 @@ class EmailService extends ChangeNotifier { } } - /// Fetch emails from the server + /// Fetches emails from all folders including spam Future _fetchEmailsFromServer() async { _allEmails.clear(); - // Define folder mappings with fallbacks final folderMappings = { EmailFolder.inbox: ['INBOX'], EmailFolder.sent: ['Sent'], EmailFolder.drafts: ['Drafts'], EmailFolder.trash: ['Trash'], + EmailFolder.spam: ['UCE-TMP'], // 👈 NEW: Add spam mapping }; for (final entry in folderMappings.entries) { final folder = entry.key; final folderNames = entry.value; - await _fetchEmailsForFolder(folder, folderNames); } } + /// Attempts to fetch from each alias for a folder Future _fetchEmailsForFolder(EmailFolder folder, List folderNames) async { for (final folderName in folderNames) { try { @@ -102,18 +89,17 @@ class EmailService extends ChangeNotifier { for (final email in emails) { _allEmails.add(email.copyWith(folder: folder)); } - return; // Success, no need to try other folder names - } catch (e) { - continue; // Try next folder name + return; + } catch (_) { + continue; } } if (folder != EmailFolder.inbox) { - print('Could not fetch ${folder.name} emails from any of: ${folderNames.join(', ')}'); + print('Could not fetch ${folder.name} emails from: ${folderNames.join(', ')}'); } } - /// Clear all email data void clear() { _allEmails.clear(); _isInitialized = false; @@ -121,7 +107,7 @@ class EmailService extends ChangeNotifier { notifyListeners(); } - /// Send email + /// Sends a new email and refreshes sent list Future sendEmail({ required String to, required String subject, @@ -129,9 +115,7 @@ class EmailService extends ChangeNotifier { String? cc, String? bcc, }) async { - if (!_isInitialized) { - throw Exception('Email service not initialized'); - } + if (!_isInitialized) throw Exception('Email service not initialized'); final success = await _emailRepository.sendEmail( to: to, @@ -141,41 +125,31 @@ class EmailService extends ChangeNotifier { bcc: bcc?.split(',').map((e) => e.trim()).toList(), ); - if (!success) { - throw Exception('Failed to send email'); - } + if (!success) throw Exception('Failed to send email'); await _refreshSentEmails(); notifyListeners(); } - /// Refresh sent emails only + /// Clears and refetches sent folder only Future _refreshSentEmails() async { _allEmails.removeWhere((e) => e.folder == EmailFolder.sent); await _fetchEmailsForFolder(EmailFolder.sent, ['Sent', 'INBOX.Sent', 'INBOX/Sent']); } - /// Mark email as read Future markAsRead(Email email) async { if (!_isInitialized || email.uid == 0) return; - final success = await _emailRepository.markAsRead(email.uid); - if (success) { - updateEmail(email.copyWith(isRead: true)); - } + if (success) updateEmail(email.copyWith(isRead: true)); } - /// Mark email as unread Future markAsUnread(Email email) async { if (!_isInitialized || email.uid == 0) return; - final success = await _emailRepository.markAsUnread(email.uid); - if (success) { - updateEmail(email.copyWith(isRead: false)); - } + if (success) updateEmail(email.copyWith(isRead: false)); } - /// Delete email (move to trash or permanently delete) + /// Deletes or moves to trash depending on current folder Future deleteEmail(Email email) async { if (!_isInitialized || email.uid == 0) return; @@ -187,13 +161,11 @@ class EmailService extends ChangeNotifier { } } else { final success = await _emailRepository.moveEmail(email.uid, 'Trash'); - if (success) { - updateEmail(email.copyWith(folder: EmailFolder.trash)); - } + if (success) updateEmail(email.copyWith(folder: EmailFolder.trash)); } } - /// Search emails + /// Server-side search Future> searchEmails({ String? query, String? from, @@ -212,9 +184,10 @@ class EmailService extends ChangeNotifier { mailboxName: mailboxName, ); - return results.map((email) => email.copyWith(folder: folder ?? EmailFolder.inbox)).toList(); + return results.map((e) => e.copyWith(folder: folder ?? EmailFolder.inbox)).toList(); } + /// Maps folder enum to IMAP mailbox name String _getMailboxNameForFolder(EmailFolder folder) { switch (folder) { case EmailFolder.sent: @@ -223,31 +196,29 @@ class EmailService extends ChangeNotifier { return 'Drafts'; case EmailFolder.trash: return 'Trash'; + case EmailFolder.spam: + return 'UCE-TMP'; default: return 'INBOX'; } } - /// Check if service needs re-authentication Future needsReAuthentication() async { if (!_isInitialized) return true; if (!_emailRepository.isConnected) return true; return !(await _authService.validateCurrentCredentials()); } - // ======================================================================== - // LOCAL DATA MANAGEMENT METHODS - // ======================================================================== + // === Local Data Helpers === List filterEmails(String query, EmailFolder folder) { final filtered = _allEmails.where((e) => e.folder == folder).toList(); if (query.isEmpty) return filtered; - return filtered - .where((email) => - email.sender.toLowerCase().contains(query.toLowerCase()) || - email.subject.toLowerCase().contains(query.toLowerCase())) - .toList(); + return filtered.where((email) { + return email.sender.toLowerCase().contains(query.toLowerCase()) || + email.subject.toLowerCase().contains(query.toLowerCase()); + }).toList(); } void updateEmail(Email updatedEmail) { @@ -262,9 +233,7 @@ class EmailService extends ChangeNotifier { for (final email in emails) { if (_isInitialized && email.uid != 0) { final targetMailbox = _getMailboxNameForFolder(folder); - _emailRepository.moveEmail(email.uid, targetMailbox).catchError((e) { - print('Error moving email on server: $e'); - }); + _emailRepository.moveEmail(email.uid, targetMailbox).catchError(print); } final index = _allEmails.indexWhere((e) => e.id == email.id); @@ -278,9 +247,7 @@ class EmailService extends ChangeNotifier { void deleteEmailsPermanently(Iterable emails) { for (final email in emails) { if (_isInitialized && email.uid != 0) { - _emailRepository.deleteEmail(email.uid).catchError((e) { - print('Error deleting email permanently: $e'); - }); + _emailRepository.deleteEmail(email.uid).catchError(print); } } @@ -291,17 +258,13 @@ class EmailService extends ChangeNotifier { void saveOrUpdateDraft(Email draft) { if (_isDraftEmpty(draft)) { - final existingIndex = _allEmails.indexWhere((e) => e.id == draft.id); - if (existingIndex != -1) { - _allEmails.removeAt(existingIndex); - notifyListeners(); - } + _allEmails.removeWhere((e) => e.id == draft.id); + notifyListeners(); return; } - final index = _allEmails.indexWhere((e) => e.id == draft.id); final updatedDraft = draft.copyWith(folder: EmailFolder.drafts); - + final index = _allEmails.indexWhere((e) => e.id == draft.id); if (index != -1) { _allEmails[index] = updatedDraft; } else { @@ -312,19 +275,14 @@ class EmailService extends ChangeNotifier { } void removeDraft(String draftId) { - Email? draft; - try { - draft = _allEmails.firstWhere( - (e) => e.id == draftId && e.folder == EmailFolder.drafts, - ); - } catch (e) { - draft = null; // Email not found - } + final draft = _allEmails.firstWhere( + (e) => e.id == draftId && e.folder == EmailFolder.drafts, + orElse: () => + Email(id: '', sender: '', senderEmail: '', recipients: [], subject: '', body: '', date: DateTime.now()), + ); - if (draft != null && _isInitialized && draft.uid != 0) { - _emailRepository.deleteEmail(draft.uid, mailboxName: 'Drafts').catchError((e) { - print('Error removing draft from server: $e'); - }); + if (_isInitialized && draft.uid != 0 && draft.id.isNotEmpty) { + _emailRepository.deleteEmail(draft.uid, mailboxName: 'Drafts').catchError(print); } _allEmails.removeWhere((e) => e.id == draftId && e.folder == EmailFolder.drafts); @@ -333,23 +291,16 @@ class EmailService extends ChangeNotifier { void cleanEmptyDrafts() { final emptyDrafts = _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).toList(); - - if (emptyDrafts.isNotEmpty) { - deleteEmailsPermanently(emptyDrafts); - } + if (emptyDrafts.isNotEmpty) deleteEmailsPermanently(emptyDrafts); } bool _isDraftEmpty(Email draft) { return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; } - int get emptyDraftsCount { - return _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).length; - } + int get emptyDraftsCount => _allEmails.where((e) => e.folder == EmailFolder.drafts && _isDraftEmpty(e)).length; - int get unreadCount { - return _allEmails.where((e) => e.folder == EmailFolder.inbox && !e.isRead).length; - } + int get unreadCount => _allEmails.where((e) => e.folder == EmailFolder.inbox && !e.isRead).length; @override void dispose() { diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart index 0f88a754..9c3d8732 100644 --- a/lib/pages/email_client/services/imap_email_service.dart +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -64,7 +64,7 @@ class ImapEmailService { // 2. how many messages there? final total = mailbox.messagesExists; - print('DEBUG: $mailboxName has $total messages'); + //print('DEBUG: $mailboxName has $total messages'); // 3. bail out only if truly empty if (total == 0) return []; @@ -78,8 +78,8 @@ class ImapEmailService { MessageSequence.fromRange(start, end), '(BODY.PEEK[HEADER] BODY.PEEK[TEXT])', ); - print('DEBUG: fetched ${fetchResult.messages.length} ' - 'messages from $mailboxName'); + //print('DEBUG: fetched ${fetchResult.messages.length} ' + // 'messages from $mailboxName'); // 6. map & reverse (newest-first) return (await Future.wait(fetchResult.messages.map(_convertMimeMessageToEmail))).reversed.toList(); @@ -240,8 +240,8 @@ class ImapEmailService { final plain = msg.decodeTextPlainPart(); final html = msg.decodeTextHtmlPart(); - print('FIXED DECODE plain=$plain'); - print('FIXED DECODE html=$html'); + //print('FIXED DECODE plain=$plain'); + //print('FIXED DECODE html=$html'); return Email( id: msg.uid?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), diff --git a/lib/pages/email_client/widgets/email_tile.dart b/lib/pages/email_client/widgets/email_tile.dart index 826d497d..11096a82 100644 --- a/lib/pages/email_client/widgets/email_tile.dart +++ b/lib/pages/email_client/widgets/email_tile.dart @@ -1,81 +1,99 @@ import 'package:flutter/material.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +/// A tile representing a single email in the inbox list. class EmailTile extends StatelessWidget { final Email email; final bool isSelected; final VoidCallback onTap; - final VoidCallback? onLongPress; // Made optional + final VoidCallback? onLongPress; const EmailTile({ super.key, required this.email, required this.onTap, - this.onLongPress, // Now optional + this.onLongPress, this.isSelected = false, }); @override Widget build(BuildContext context) { - final bgColor = isSelected - ? Colors.lightBlue.withOpacity(0.2) + final theme = Theme.of(context); + + // Set background color based on state: + // - selected emails use a translucent primary color + // - unread emails use surfaceVariant (highlight) + // - read emails use regular surface + final Color bgColor = isSelected + ? theme.colorScheme.primary.withOpacity(0.1) : email.isUnread - ? Colors.blue[50] - : Theme.of(context).canvasColor; + ? theme.colorScheme.surfaceVariant + : theme.colorScheme.surface; return InkWell( onTap: onTap, - onLongPress: onLongPress, // Will be null if not provided + onLongPress: onLongPress, child: Container( color: bgColor, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildLeadingIcon(), + _buildLeadingIcon(theme), // Avatar or selection indicator const SizedBox(width: 16), - _buildEmailContent(), - _buildTrailingInfo(), + _buildEmailContent(theme), // Sender, subject, and preview + _buildTrailingInfo(theme), // Timestamp ], ), ), ); } - Widget _buildLeadingIcon() { + /// Displays a selection icon if selected, otherwise a generic avatar. + Widget _buildLeadingIcon(ThemeData theme) { return isSelected - ? const Icon(Icons.check_circle, color: Colors.blue) - : const CircleAvatar( + ? Icon(Icons.check_circle, color: theme.colorScheme.primary) + : CircleAvatar( radius: 20, - backgroundColor: Colors.grey, - child: Icon(Icons.person, color: Colors.white), + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer), ); } - Widget _buildEmailContent() { + /// Builds the main email content: sender, subject, and preview line. + Widget _buildEmailContent(ThemeData theme) { + final bool isUnread = email.isUnread; + return Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Sender name Text( email.sender, - style: TextStyle( - fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, ), ), + const SizedBox(height: 4), + + // Email subject Text( email.subject, - style: TextStyle( - fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, ), ), + const SizedBox(height: 4), + + // Email preview (first line of body) Text( email.preview, - style: TextStyle( - color: Colors.grey[600], - fontWeight: email.isUnread ? FontWeight.w500 : FontWeight.normal, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + fontWeight: isUnread ? FontWeight.w500 : FontWeight.normal, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -85,18 +103,18 @@ class EmailTile extends StatelessWidget { ); } - Widget _buildTrailingInfo() { + /// Displays the time of the email (e.g., 14:05). + Widget _buildTrailingInfo(ThemeData theme) { return Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}', - style: TextStyle( - color: Colors.grey, - fontSize: 12, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), fontWeight: email.isUnread ? FontWeight.bold : FontWeight.normal, ), ), - //if (email.isStarred) const Icon(Icons.star, color: Colors.amber, size: 16), ], ); } diff --git a/lib/pages/email_client/widgets/select_email.dart b/lib/pages/email_client/widgets/select_email.dart index 05bf979e..e27d4df2 100644 --- a/lib/pages/email_client/widgets/select_email.dart +++ b/lib/pages/email_client/widgets/select_email.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +/// Manages selection state and batch actions for emails (e.g. archive, delete, mark as read). class EmailSelectionController extends ChangeNotifier { final Set _selectedEmails = {}; + + // Optional async handlers for batch actions final Future Function(Set)? onDelete; final Future Function(Set)? onArchive; - final Future Function(Email)? onEmailUpdated; // Changed to Future + final Future Function(Email)? onEmailUpdated; EmailSelectionController({ this.onDelete, @@ -13,55 +16,70 @@ class EmailSelectionController extends ChangeNotifier { this.onEmailUpdated, }); - // Public API (unchanged) + // ==== Public Accessors ==== + + /// Currently selected emails (read-only) Set get selectedEmails => Set.unmodifiable(_selectedEmails); + + /// Returns true if any email is selected bool get isSelecting => _selectedEmails.isNotEmpty; + + /// Checks if a specific email is selected bool isSelected(Email email) => _selectedEmails.contains(email); + + /// Number of selected emails int get selectionCount => _selectedEmails.length; - // Selection management (unchanged) + // ==== Selection Management ==== + + /// Selects or deselects an email void toggleSelection(Email email) { _selectedEmails.contains(email) ? _selectedEmails.remove(email) : _selectedEmails.add(email); notifyListeners(); } + /// Selects all given emails void selectAll(Iterable emails) { _selectedEmails.addAll(emails); notifyListeners(); } + /// Clears all selected emails void clearSelection() { _selectedEmails.clear(); notifyListeners(); } - // Updated async methods + // ==== Async Update Operations ==== + + /// Marks all selected emails as read Future markAsReadSelected() async { for (final email in _selectedEmails) { final updatedEmail = email.copyWith(isUnread: false); - await onEmailUpdated?.call(updatedEmail); // Added await + await onEmailUpdated?.call(updatedEmail); } notifyListeners(); } + /// Marks all selected emails as unread Future markAsUnreadSelected() async { for (final email in _selectedEmails) { final updatedEmail = email.copyWith(isUnread: true); - await onEmailUpdated?.call(updatedEmail); // Added await + await onEmailUpdated?.call(updatedEmail); } notifyListeners(); } + /// Toggles read/unread state for all selected emails Future toggleReadState() async { - // Made async final allUnread = _selectedEmails.every((e) => e.isUnread); for (final email in _selectedEmails) { - await onEmailUpdated?.call(email.copyWith(isUnread: !allUnread)); // Added await + await onEmailUpdated?.call(email.copyWith(isUnread: !allUnread)); } notifyListeners(); } - // New method for batch operations + /// Applies a custom async operation to each selected email Future performBatchOperation(Future Function(Email) operation) async { for (final email in _selectedEmails) { await operation(email); From 8343eed59594f55d11f49b8796d058921dd8c1b0 Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Wed, 25 Jun 2025 17:15:07 +0200 Subject: [PATCH 18/20] Fix SMTP handshake/sender & save sent/drafts now synced with the server --- .../email_pages/compose_email_Screen.dart | 109 ++++------ .../email_client/email_pages/email_page.dart | 18 +- .../repositories/email_repository.dart | 39 +--- .../repositories/imap_email_repository.dart | 9 +- .../email_client/services/email_service.dart | 84 +++++--- .../services/imap_email_service.dart | 199 +++++++++++------- 6 files changed, 244 insertions(+), 214 deletions(-) diff --git a/lib/pages/email_client/email_pages/compose_email_Screen.dart b/lib/pages/email_client/email_pages/compose_email_Screen.dart index 67f6a3e3..75c320b7 100644 --- a/lib/pages/email_client/email_pages/compose_email_Screen.dart +++ b/lib/pages/email_client/email_pages/compose_email_Screen.dart @@ -57,18 +57,6 @@ class _ComposeEmailScreenState extends State { super.dispose(); } - // Check if the current composition is empty - /* - bool _isCompositionEmpty() { - return _toController.text.trim().isEmpty && - _ccController.text.trim().isEmpty && - _bccController.text.trim().isEmpty && - _subjectController.text.trim().isEmpty && - _bodyController.text.trim().isEmpty && - _attachments.isEmpty; - } */ - - // Check if the composition has any meaningful content bool _hasContent() { return _toController.text.trim().isNotEmpty || _subjectController.text.trim().isNotEmpty || @@ -76,15 +64,12 @@ class _ComposeEmailScreenState extends State { _attachments.isNotEmpty; } - // Save or update the draft only if it has content void _saveDraft(EmailService emailService) { - // Only save if there's actual content if (!_hasContent()) { - // If this was an existing draft that's now empty, remove it if (widget.draft != null) { emailService.removeDraft(widget.draft!.id); } - return; // Don't save empty compositions + return; } final newDraft = Email( @@ -107,25 +92,44 @@ class _ComposeEmailScreenState extends State { ); } - // Send email and remove draft if exists - void _sendEmail() { - if (_formKey.currentState!.validate()) { - final emailService = Provider.of(context, listen: false); - if (widget.draft != null) { - emailService.removeDraft(widget.draft!.id); - } + Future _sendEmail() async { + if (!_formKey.currentState!.validate()) return; - // TODO: Implement actual sending logic here + final emailService = Provider.of(context, listen: false); + + // Remove the old draft if we're editing one + if (widget.draft != null) { + emailService.removeDraft(widget.draft!.id); + } + + try { + await emailService.sendEmail( + to: _toController.text.trim(), + subject: _subjectController.text.trim(), + body: _bodyController.text, + // Pass cc/bcc as String? (the service will split internally) + cc: _ccController.text.trim().isEmpty + ? null + : _ccController.text.trim(), // <<< changed: String? instead of List? + bcc: _bccController.text.trim().isEmpty ? null : _bccController.text.trim(), // <<< changed here as well + ); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Email sent')), + const SnackBar( + content: Text('Email sent'), + duration: Duration(seconds: 2), + ), ); Navigator.pop(context); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to send email: $e')), + ); } } Future _attachFile() async { - // TODO: Implement file attachment + // TODO: implement real file picker setState(() { _attachments.add('file_${_attachments.length + 1}.pdf'); }); @@ -153,12 +157,10 @@ class _ComposeEmailScreenState extends State { IconButton( icon: const Icon(Icons.attach_file), onPressed: _attachFile, - tooltip: 'Attach file', ), IconButton( icon: const Icon(Icons.send), onPressed: _sendEmail, - tooltip: 'Send', ), ], ), @@ -168,7 +170,7 @@ class _ComposeEmailScreenState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ - // To Field + // To field TextFormField( controller: _toController, decoration: InputDecoration( @@ -181,91 +183,68 @@ class _ComposeEmailScreenState extends State { if (value == null || value.isEmpty) { return 'Please enter recipient'; } - - final emails = value.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty); + final emails = value.split(',').map((e) => e.trim()); for (final email in emails) { if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(email)) { return 'Invalid email: $email'; } } - return null; }, ), const SizedBox(height: 8), - // CC/BCC Toggle + // CC/BCC toggle Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () => setState(() => _showCcBcc = !_showCcBcc), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), child: Text(_showCcBcc ? 'Hide CC/BCC' : 'Add CC/BCC'), ), ), - // CC Field (conditional) + // CC field if (_showCcBcc) ...[ TextFormField( controller: _ccController, - decoration: InputDecoration( - labelText: 'CC', - border: OutlineInputBorder( - borderSide: BorderSide(color: Theme.of(context).dividerColor), - ), - ), + decoration: const InputDecoration(labelText: 'CC'), ), const SizedBox(height: 8), ], - // BCC Field (conditional) + // BCC field if (_showCcBcc) ...[ TextFormField( controller: _bccController, - decoration: InputDecoration( - labelText: 'BCC', - border: OutlineInputBorder( - borderSide: BorderSide(color: Theme.of(context).dividerColor), - ), - ), + decoration: const InputDecoration(labelText: 'BCC'), ), const SizedBox(height: 8), ], - // Subject Field + // Subject TextFormField( controller: _subjectController, - decoration: InputDecoration( - labelText: 'Subject', - border: OutlineInputBorder( - borderSide: BorderSide(color: Theme.of(context).dividerColor), - ), - ), + decoration: const InputDecoration(labelText: 'Subject'), ), const SizedBox(height: 8), - // Attachments + // Attachments preview if (_attachments.isNotEmpty) ...[ SizedBox( height: 50, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: _attachments.length, - itemBuilder: (context, index) => Chip( - label: Text(_attachments[index]), - backgroundColor: Theme.of(context).chipTheme.backgroundColor, - deleteIconColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - deleteIcon: const Icon(Icons.close, size: 18), - onDeleted: () => setState(() => _attachments.removeAt(index)), + itemBuilder: (_, i) => Chip( + label: Text(_attachments[i]), + onDeleted: () => setState(() => _attachments.removeAt(i)), ), ), ), const SizedBox(height: 8), ], - // Email Body + // Body Expanded( child: TextFormField( controller: _bodyController, diff --git a/lib/pages/email_client/email_pages/email_page.dart b/lib/pages/email_client/email_pages/email_page.dart index 9de9d068..72499bda 100644 --- a/lib/pages/email_client/email_pages/email_page.dart +++ b/lib/pages/email_client/email_pages/email_page.dart @@ -109,6 +109,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { ); } +/* Future _handleLogout() async { final emailAuthService = Provider.of(context, listen: false); final emailService = Provider.of(context, listen: false); @@ -119,7 +120,8 @@ class _EmailClientContentState extends State<_EmailClientContent> { setState(() { _isAuthenticated = false; }); - } + } + */ Future _handlePop(BuildContext context) async { if (_selectionController.isSelecting) { @@ -326,11 +328,11 @@ class _EmailClientContentState extends State<_EmailClientContent> { ); } } -// Trash is fully implemented so far(not anymore) -// TODO: Sent, Archives, Drafts -// TODO: Settings: I am unsure what to add in here. +/* +NOTES: +- changes on the email client only appear on the app not in the actual Email. +- Email inbox only loads a certain number of emails, + it takes too long to load so needs some efficiency improvements +- Composed Emails don't actually send. -// Check IMAP plugins (for dart/flutter): enough_mail?s - // SMPT und IMAP client => API - // UI und Backend separate, start with UI it is easier. - // flutter secure storage login daten, take it from there \ No newline at end of file +*/ diff --git a/lib/pages/email_client/repositories/email_repository.dart b/lib/pages/email_client/repositories/email_repository.dart index 1effded3..3c91c216 100644 --- a/lib/pages/email_client/repositories/email_repository.dart +++ b/lib/pages/email_client/repositories/email_repository.dart @@ -1,6 +1,3 @@ -// 1. ABSTRACT EMAIL REPOSITORY (Define the contract) -// ============================================================================ - import 'package:campus_app/pages/email_client/models/email.dart'; abstract class EmailRepository { @@ -26,38 +23,10 @@ abstract class EmailRepository { String mailboxName = 'INBOX', }); bool get isConnected; -} - + /// Save or update a draft on the server + Future saveDraft(Email draft); -// ============================================================================ -// USAGE EXAMPLE -/* - -class EmailController { - final EmailService _emailService = sl(); - - Future initializeEmail() async { - try { - await _emailService.initialize(); - } catch (e) { - // Handle initialization error - print('Failed to initialize email: $e'); - } - } - - Future sendTestEmail() async { - try { - await _emailService.sendEmail( - to: 'test@example.com', - subject: 'Test Email', - body: 'This is a test email', - ); - } catch (e) { - // Handle send error - print('Failed to send email: $e'); - } - } + /// Fetch drafts from the server-side “Drafts” folder + Future> fetchDrafts({int count = 50}); } - -*/ \ No newline at end of file diff --git a/lib/pages/email_client/repositories/imap_email_repository.dart b/lib/pages/email_client/repositories/imap_email_repository.dart index c8c27503..7207816e 100644 --- a/lib/pages/email_client/repositories/imap_email_repository.dart +++ b/lib/pages/email_client/repositories/imap_email_repository.dart @@ -1,6 +1,3 @@ -//2. IMAP IMPLEMENTATION OF EMAIL REPOSITORY -// ============================================================================ - import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; @@ -81,4 +78,10 @@ class ImapEmailRepository implements EmailRepository { @override bool get isConnected => _imapService.isConnected; + + @override + Future saveDraft(Email draft) => _imapService.appendDraft(draft); + + @override + Future> fetchDrafts({int count = 50}) => _imapService.fetchEmails(mailboxName: 'Drafts', count: count); } diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index 0f7b4df9..b7913074 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -21,7 +21,7 @@ class EmailService extends ChangeNotifier { List get allEmails => List.unmodifiable(_allEmails); EmailSelectionController get selectionController => _selectionController; - /// Attempt to initialize email connection and pull inbox + /// Initialize connection and pull all folders (including drafts). Future initialize() async { try { final credentials = await _authService.getCredentials(); @@ -29,6 +29,8 @@ class EmailService extends ChangeNotifier { await _connectToEmailServer(credentials['username']!, credentials['password']!); _isInitialized = true; notifyListeners(); + + // Immediately load all folders, including drafts on the server await refreshEmails(); } catch (e) { _isInitialized = false; @@ -37,13 +39,12 @@ class EmailService extends ChangeNotifier { } } - /// Connects using provided credentials Future _connectToEmailServer(String username, String password) async { final success = await _emailRepository.connect(username, password); if (!success) throw Exception('Failed to connect to email server'); } - /// Refreshes all mailbox folders + /// Refreshes all mailbox folders, including server drafts. Future refreshEmails() async { if (!_isInitialized) throw Exception('Email service not initialized'); try { @@ -60,38 +61,48 @@ class EmailService extends ChangeNotifier { } } - /// Fetches emails from all folders including spam + /// Fetches inbox, sent, drafts (server-side), trash, spam. Future _fetchEmailsFromServer() async { _allEmails.clear(); final folderMappings = { EmailFolder.inbox: ['INBOX'], EmailFolder.sent: ['Sent'], - EmailFolder.drafts: ['Drafts'], + EmailFolder.drafts: ['Drafts'], // ← We’ll fetch server drafts separately below EmailFolder.trash: ['Trash'], - EmailFolder.spam: ['UCE-TMP'], // 👈 NEW: Add spam mapping + EmailFolder.spam: ['UCE-TMP'], }; + // 1) Fetch standard folders for (final entry in folderMappings.entries) { final folder = entry.key; final folderNames = entry.value; await _fetchEmailsForFolder(folder, folderNames); } + + // 2) Fetch server-side drafts and merge + try { + final serverDrafts = await _emailRepository.fetchDrafts(count: 50); + for (final draft in serverDrafts) { + _allEmails.add(draft.copyWith(folder: EmailFolder.drafts)); + } + } catch (e) { + print('Could not fetch server drafts: $e'); + } } - /// Attempts to fetch from each alias for a folder + /// Helper to try all mailbox name aliases for a given folder. Future _fetchEmailsForFolder(EmailFolder folder, List folderNames) async { for (final folderName in folderNames) { try { final count = folder == EmailFolder.inbox ? 50 : 30; final emails = await _emailRepository.fetchEmails(mailboxName: folderName, count: count); - for (final email in emails) { _allEmails.add(email.copyWith(folder: folder)); } return; } catch (_) { - continue; + // try next alias } } @@ -107,7 +118,7 @@ class EmailService extends ChangeNotifier { notifyListeners(); } - /// Sends a new email and refreshes sent list + /// Sends a new email over SMTP and refreshes the Sent folder. Future sendEmail({ required String to, required String subject, @@ -124,14 +135,12 @@ class EmailService extends ChangeNotifier { cc: cc?.split(',').map((e) => e.trim()).toList(), bcc: bcc?.split(',').map((e) => e.trim()).toList(), ); - if (!success) throw Exception('Failed to send email'); await _refreshSentEmails(); notifyListeners(); } - /// Clears and refetches sent folder only Future _refreshSentEmails() async { _allEmails.removeWhere((e) => e.folder == EmailFolder.sent); await _fetchEmailsForFolder(EmailFolder.sent, ['Sent', 'INBOX.Sent', 'INBOX/Sent']); @@ -149,7 +158,6 @@ class EmailService extends ChangeNotifier { if (success) updateEmail(email.copyWith(isRead: false)); } - /// Deletes or moves to trash depending on current folder Future deleteEmail(Email email) async { if (!_isInitialized || email.uid == 0) return; @@ -165,7 +173,6 @@ class EmailService extends ChangeNotifier { } } - /// Server-side search Future> searchEmails({ String? query, String? from, @@ -187,7 +194,6 @@ class EmailService extends ChangeNotifier { return results.map((e) => e.copyWith(folder: folder ?? EmailFolder.inbox)).toList(); } - /// Maps folder enum to IMAP mailbox name String _getMailboxNameForFolder(EmailFolder folder) { switch (folder) { case EmailFolder.sent: @@ -214,7 +220,6 @@ class EmailService extends ChangeNotifier { List filterEmails(String query, EmailFolder folder) { final filtered = _allEmails.where((e) => e.folder == folder).toList(); if (query.isEmpty) return filtered; - return filtered.where((email) { return email.sender.toLowerCase().contains(query.toLowerCase()) || email.subject.toLowerCase().contains(query.toLowerCase()); @@ -235,7 +240,6 @@ class EmailService extends ChangeNotifier { final targetMailbox = _getMailboxNameForFolder(folder); _emailRepository.moveEmail(email.uid, targetMailbox).catchError(print); } - final index = _allEmails.indexWhere((e) => e.id == email.id); if (index != -1) { _allEmails[index] = email.copyWith(folder: folder); @@ -250,35 +254,53 @@ class EmailService extends ChangeNotifier { _emailRepository.deleteEmail(email.uid).catchError(print); } } - - _allEmails.removeWhere((e) => emails.any((email) => email.id == e.id)); + _allEmails.removeWhere((e) => emails.any((d) => d.id == e.id)); _selectionController.clearSelection(); notifyListeners(); } - void saveOrUpdateDraft(Email draft) { + /// Save or update a draft both locally and on the IMAP server + Future saveOrUpdateDraft(Email draft) async { + // 1) Local cache update if (_isDraftEmpty(draft)) { _allEmails.removeWhere((e) => e.id == draft.id); notifyListeners(); - return; - } - - final updatedDraft = draft.copyWith(folder: EmailFolder.drafts); - final index = _allEmails.indexWhere((e) => e.id == draft.id); - if (index != -1) { - _allEmails[index] = updatedDraft; } else { - _allEmails.add(updatedDraft); + final updatedDraft = draft.copyWith(folder: EmailFolder.drafts); + final index = _allEmails.indexWhere((e) => e.id == draft.id); + if (index != -1) { + _allEmails[index] = updatedDraft; + } else { + _allEmails.add(updatedDraft); + } + notifyListeners(); } - notifyListeners(); + // 2) Push to server + try { + final success = await _emailRepository.saveDraft(draft); + if (!success) { + print('Failed to save draft on server'); + // Optionally, show a user-facing error here + } + } catch (e) { + print('Error while saving draft: $e'); + // Optionally, rollback local change + } } void removeDraft(String draftId) { final draft = _allEmails.firstWhere( (e) => e.id == draftId && e.folder == EmailFolder.drafts, - orElse: () => - Email(id: '', sender: '', senderEmail: '', recipients: [], subject: '', body: '', date: DateTime.now()), + orElse: () => Email( + id: '', + sender: '', + senderEmail: '', + recipients: [], + subject: '', + body: '', + date: DateTime.now(), + ), ); if (_isInitialized && draft.uid != 0 && draft.id.isNotEmpty) { diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart index 9c3d8732..81380fad 100644 --- a/lib/pages/email_client/services/imap_email_service.dart +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -11,6 +11,7 @@ class ImapEmailService { String? _username; String? _password; + // IMAP/SMTP server configuration static const String _imapHost = 'mail.ruhr-uni-bochum.de'; static const int _imapPort = 993; static const String _smtpHost = 'mail.ruhr-uni-bochum.de'; @@ -18,6 +19,7 @@ class ImapEmailService { bool get isConnected => _imapClient?.isConnected ?? false; + /// Connects to the IMAP server and logs in. Future connect(String username, String password) async { _imapClient = ImapClient(isLogEnabled: true); try { @@ -25,23 +27,21 @@ class ImapEmailService { _password = password; await _imapClient!.connectToServer(_imapHost, _imapPort, isSecure: true); await _imapClient!.login(_username!, _password!); - print('Successfully connected to RUB email server'); - - final boxes = await _imapClient!.listMailboxes(); - print('Available mailboxes: ${boxes.map((m) => m.name).toList()}'); + print('IMAP: Connected as $_username'); return true; } catch (e) { - print('Failed to connect to email server: $e'); + print('IMAP: Connection/login failed: $e'); return false; } } + /// Disconnects both IMAP and SMTP clients cleanly. Future disconnect() async { try { await _imapClient?.disconnect(); await _smtpClient?.disconnect(); } catch (e) { - print('Error disconnecting: $e'); + print('Disconnect error: $e'); } finally { _imapClient = null; _smtpClient = null; @@ -50,53 +50,49 @@ class ImapEmailService { } } + /// Fetches [count] messages from [mailboxName], newest-first paging. Future> fetchEmails({ String mailboxName = 'INBOX', int count = 50, int page = 1, }) async { if (_imapClient == null || !_imapClient!.isConnected) { - throw Exception('Not connected to email server'); + throw Exception('Not connected to IMAP server'); } - - // 1. select mailbox + // 1) Select mailbox final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); - - // 2. how many messages there? final total = mailbox.messagesExists; - //print('DEBUG: $mailboxName has $total messages'); - - // 3. bail out only if truly empty if (total == 0) return []; - // 4. clamp page-range into [1..total] + // 2) Determine sequence range final start = math.max(1, total - (page * count) + 1); final end = math.min(total, total - ((page - 1) * count)); - // 5. fetch with parentheses around the item list - final fetchResult = await _imapClient!.fetchMessages( + // 3) Fetch headers and body peek + final result = await _imapClient!.fetchMessages( MessageSequence.fromRange(start, end), '(BODY.PEEK[HEADER] BODY.PEEK[TEXT])', ); - //print('DEBUG: fetched ${fetchResult.messages.length} ' - // 'messages from $mailboxName'); - // 6. map & reverse (newest-first) - return (await Future.wait(fetchResult.messages.map(_convertMimeMessageToEmail))).reversed.toList(); + // 4) Convert and reverse for newest-first order + final emails = await Future.wait( + result.messages.map(_convertMimeMessageToEmail), + ); + return emails.reversed.toList(); } + /// Fetches a single email by its UID. Future fetchEmailByUid(int uid, {String mailboxName = 'INBOX'}) async { if (_imapClient == null || !_imapClient!.isConnected) { - throw Exception('Not connected to email server'); + throw Exception('Not connected to IMAP server'); } await _imapClient!.selectMailboxByPath(mailboxName); final result = await _imapClient!.uidFetchMessage(uid, 'BODY[]'); - if (result.messages.isNotEmpty) { - return await _convertMimeMessageToEmail(result.messages.first); - } - return null; + if (result.messages.isEmpty) return null; + return await _convertMimeMessageToEmail(result.messages.first); } + /// Sends an email via SMTP, then appends it into the IMAP “Sent” folder. Future sendEmail({ required String to, required String subject, @@ -106,83 +102,105 @@ class ImapEmailService { List? attachments, }) async { try { - if (_smtpClient == null) { - _smtpClient = SmtpClient('RUB-Flutter-Client', isLogEnabled: false); + // ─── 1) Ensure SMTP connection + full STARTTLS handshake ───────────── + if (_smtpClient == null || !_smtpClient!.isConnected) { + _smtpClient = SmtpClient('RUB-Flutter-Client', isLogEnabled: true); + + // Connect without TLS await _smtpClient!.connectToServer(_smtpHost, _smtpPort, isSecure: false); + // Advertise capabilities await _smtpClient!.ehlo(); + // Upgrade to TLS await _smtpClient!.startTls(); + // Re-advertise capabilities after TLS + await _smtpClient!.ehlo(); + // Authenticate await _smtpClient!.authenticate(_username!, _password!, AuthMechanism.login); } + // ─── 2) Build the MIME message ──────────────────────────────────────── final builder = MessageBuilder.prepareMultipartAlternativeMessage(plainText: body) - ..from = [MailAddress('', _username!)] + ..from = [ + MailAddress( + '', + _username!.contains('@') ? _username! : '$_username@ruhr-uni-bochum.de', + ) + ] ..to = [MailAddress('', to)] ..subject = subject; if (cc?.isNotEmpty ?? false) { - builder.cc = cc!.map((e) => MailAddress('', e)).toList(); + builder.cc = cc!.map((addr) => MailAddress('', addr)).toList(); } if (bcc?.isNotEmpty ?? false) { - builder.bcc = bcc!.map((e) => MailAddress('', e)).toList(); + builder.bcc = bcc!.map((addr) => MailAddress('', addr)).toList(); } - // TODO: attachments if needed + // TODO: handle attachments if needed + + final mimeMessage = builder.buildMimeMessage(); + + // ─── 3) Send the message ─────────────────────────────────────────────── + await _smtpClient!.sendMessage(mimeMessage); + print('SMTP: Message sent'); + + // ─── 4) Append to IMAP “Sent” folder ────────────────────────────────── + if (_imapClient != null && _imapClient!.isConnected) { + try { + await _imapClient!.selectMailboxByPath('Sent'); + await _imapClient!.appendMessage( + mimeMessage, + flags: [MessageFlags.seen], // mark as read in Sent + ); + print('IMAP: Appended message to Sent'); + } catch (e) { + print('IMAP: Failed to append to Sent: $e'); + } + } - final message = builder.buildMimeMessage(); - await _smtpClient!.sendMessage(message); return true; } catch (e) { - print('Error sending email: $e'); + print('sendEmail error: $e'); return false; } } - Future markAsRead(int uid, {String mailboxName = 'INBOX'}) => - _updateEmailFlags(uid, [MessageFlags.seen], mailboxName: mailboxName); - - Future markAsUnread(int uid, {String mailboxName = 'INBOX'}) => _updateEmailFlags( - uid, - [MessageFlags.seen], - remove: true, - mailboxName: mailboxName, - ); - - Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) async { - try { - await _imapClient!.selectMailboxByPath(mailboxName); - await _imapClient!.uidStore(MessageSequence.fromId(uid), [MessageFlags.deleted]); - await _imapClient!.expunge(); - return true; - } catch (e) { - print('Error deleting email: $e'); - return false; + /// Appends (or updates) a draft in the IMAP “Drafts” folder. + Future appendDraft(Email draft) async { + if (_imapClient == null || !_imapClient!.isConnected) { + throw Exception('Not connected to IMAP server'); } - } + // Select the Drafts mailbox + await _imapClient!.selectMailboxByPath('Drafts'); + // Build draft MIME + final builder = MessageBuilder.prepareMultipartAlternativeMessage(plainText: draft.body) + ..from = [MailAddress('', _username!)] + ..to = draft.recipients.map((r) => MailAddress('', r)).toList() + ..subject = draft.subject; + final mime = builder.buildMimeMessage(); - Future moveEmail( - int uid, - String targetMailbox, { - String sourceMailbox = 'INBOX', - }) async { try { - await _imapClient!.selectMailboxByPath(sourceMailbox); - await _imapClient!.selectMailboxByPath(targetMailbox); - await _imapClient!.uidMove(MessageSequence.fromId(uid)); + await _imapClient!.appendMessage( + mime, + flags: [MessageFlags.draft], + ); return true; } catch (e) { - print('Error moving email: $e'); + print('appendDraft error: $e'); return false; } } + /// Lists all mailbox names on the server. Future> getMailboxes() async { if (_imapClient == null || !_imapClient!.isConnected) { - throw Exception('Not connected to email server'); + throw Exception('Not connected to IMAP server'); } final boxes = await _imapClient!.listMailboxes(); return boxes.map((m) => m.name).toList(); } + /// Searches emails in [mailboxName] matching optional criteria. Future> searchEmails({ String mailboxName = 'INBOX', String? query, @@ -192,9 +210,10 @@ class ImapEmailService { bool unreadOnly = false, }) async { if (_imapClient == null || !_imapClient!.isConnected) { - throw Exception('Not connected to email server'); + throw Exception('Not connected to IMAP server'); } + // Build IMAP search criteria final criteria = []; if (query?.isNotEmpty ?? false) criteria.add('TEXT "$query"'); if (from?.isNotEmpty ?? false) criteria.add('FROM "$from"'); @@ -206,16 +225,18 @@ class ImapEmailService { if (unreadOnly) criteria.add('UNSEEN'); if (criteria.isEmpty) criteria.add('ALL'); + // Execute search final mailbox = await _imapClient!.selectMailboxByPath(mailboxName); final total = mailbox.messagesExists; - - final fetchResult = await _imapClient!.fetchRecentMessages( + final result = await _imapClient!.fetchRecentMessages( messageCount: total, criteria: criteria.join(' '), ); - return await Future.wait(fetchResult.messages.map(_convertMimeMessageToEmail)); + + return Future.wait(result.messages.map(_convertMimeMessageToEmail)); } + /// Internal helper to add/remove flags (e.g., Seen). Future _updateEmailFlags( int uid, List flags, { @@ -236,13 +257,47 @@ class ImapEmailService { } } + Future markAsRead(int uid, {String mailboxName = 'INBOX'}) => + _updateEmailFlags(uid, [MessageFlags.seen], mailboxName: mailboxName); + + Future markAsUnread(int uid, {String mailboxName = 'INBOX'}) => + _updateEmailFlags(uid, [MessageFlags.seen], remove: true, mailboxName: mailboxName); + + /// Deletes a message (marks \Deleted + EXPUNGE). + Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) async { + try { + await _imapClient!.selectMailboxByPath(mailboxName); + await _imapClient!.uidStore(MessageSequence.fromId(uid), [MessageFlags.deleted]); + await _imapClient!.expunge(); + return true; + } catch (e) { + print('Error deleting email: $e'); + return false; + } + } + + /// Moves a message to [targetMailbox]. + Future moveEmail( + int uid, + String targetMailbox, { + String sourceMailbox = 'INBOX', + }) async { + try { + await _imapClient!.selectMailboxByPath(sourceMailbox); + await _imapClient!.selectMailboxByPath(targetMailbox); + await _imapClient!.uidMove(MessageSequence.fromId(uid)); + return true; + } catch (e) { + print('Error moving email: $e'); + return false; + } + } + + /// Converts a raw [MimeMessage] into your app’s [Email] model. Future _convertMimeMessageToEmail(MimeMessage msg) async { final plain = msg.decodeTextPlainPart(); final html = msg.decodeTextHtmlPart(); - //print('FIXED DECODE plain=$plain'); - //print('FIXED DECODE html=$html'); - return Email( id: msg.uid?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), subject: msg.decodeSubject() ?? 'No Subject', From d1fde8d9ad3d4e208e931de096a77802ea933385 Mon Sep 17 00:00:00 2001 From: Hiba Hajjo Date: Thu, 26 Jun 2025 16:37:50 +0200 Subject: [PATCH 19/20] Added comments and notes --- .../email_client/email_drawer/drafts.dart | 40 +++++++----- .../email_client/email_pages/email_page.dart | 65 ++++++++++++------- .../email_client/email_pages/email_view.dart | 42 ++++++------ lib/pages/email_client/models/email.dart | 48 +++++++------- .../repositories/email_repository.dart | 25 ++++++- .../repositories/imap_email_repository.dart | 19 +++++- .../services/email_auth_service.dart | 32 +++++---- .../email_client/services/email_service.dart | 16 ++--- .../services/imap_email_service.dart | 20 +++--- 9 files changed, 191 insertions(+), 116 deletions(-) diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart index 56e4ee71..d8a5a781 100644 --- a/lib/pages/email_client/email_drawer/drafts.dart +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -5,6 +5,7 @@ import 'package:campus_app/pages/email_client/services/email_service.dart'; import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; +// UI screen to display and manage email drafts class DraftsPage extends StatefulWidget { const DraftsPage({super.key}); @@ -15,15 +16,15 @@ class DraftsPage extends StatefulWidget { class _DraftsPageState extends State { @override Widget build(BuildContext context) { - final emailService = Provider.of(context); - final selectionController = emailService.selectionController; + final emailService = Provider.of(context); // Access the email service + final selectionController = emailService.selectionController; // For managing multi-selection final drafts = emailService.allEmails.where((e) => e.folder == EmailFolder.drafts).toList() - ..sort((a, b) => b.date.compareTo(a.date)); // newest first + ..sort((a, b) => b.date.compareTo(a.date)); // Sort drafts by newest first return Scaffold( - appBar: _buildAppBar(selectionController, drafts, emailService), + appBar: _buildAppBar(selectionController, drafts, emailService), // Show toolbar with actions body: drafts.isEmpty - ? _buildEmptyState() + ? _buildEmptyState() // Show message if no drafts : ListView.separated( itemCount: drafts.length, separatorBuilder: (_, __) => Divider( @@ -35,40 +36,43 @@ class _DraftsPageState extends State { return EmailTile( email: draft, isSelected: selectionController.isSelected(draft), - onTap: () => _handleEmailTap(draft, selectionController), - onLongPress: () => _handleEmailLongPress(draft, selectionController), + onTap: () => _handleEmailTap(draft, selectionController), // Tap to edit + onLongPress: () => _handleEmailLongPress(draft, selectionController), // Long press to select ); }, ), ); } + // Builds AppBar depending on whether selection mode is active PreferredSizeWidget _buildAppBar(selectionController, List drafts, EmailService emailService) { if (selectionController.isSelecting) { return AppBar( leading: IconButton( icon: const Icon(Icons.close), - onPressed: () => selectionController.clearSelection(), + onPressed: () => selectionController.clearSelection(), // Exit selection mode ), title: Text('${selectionController.selectionCount} selected'), actions: [ IconButton( icon: const Icon(Icons.select_all), - onPressed: () => selectionController.selectAll(drafts), + onPressed: () => selectionController.selectAll(drafts), // Select all drafts ), IconButton( icon: const Icon(Icons.delete_forever), - onPressed: () => _showDeleteConfirmation(selectionController, emailService), + onPressed: () => _showDeleteConfirmation(selectionController, emailService), // Confirm before deletion ), ], ); } + // Default AppBar when not selecting return AppBar( title: const Text('Drafts'), ); } + // Widget shown when there are no drafts Widget _buildEmptyState() { return const Center( child: Column( @@ -92,25 +96,28 @@ class _DraftsPageState extends State { ); } + // Handles tapping a draft: open for editing or toggle selection void _handleEmailTap(Email draft, selectionController) { if (selectionController.isSelecting) { - selectionController.toggleSelection(draft); + selectionController.toggleSelection(draft); // Toggle selected state } else { Navigator.push( context, MaterialPageRoute( - builder: (_) => ComposeEmailScreen(draft: draft), + builder: (_) => ComposeEmailScreen(draft: draft), // Navigate to compose screen with the draft ), ); } } + // Handles long press to enter selection mode void _handleEmailLongPress(Email draft, selectionController) { if (!selectionController.isSelecting) { - selectionController.toggleSelection(draft); + selectionController.toggleSelection(draft); // Start selecting } } + // Show confirmation dialog before permanently deleting selected drafts void _showDeleteConfirmation(selectionController, EmailService emailService) { showDialog( context: context, @@ -121,13 +128,13 @@ class _DraftsPageState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(context), // Cancel deletion child: const Text('Cancel'), ), TextButton( onPressed: () { - emailService.deleteEmailsPermanently(selectionController.selectedEmails); - Navigator.pop(context); + emailService.deleteEmailsPermanently(selectionController.selectedEmails); // Delete selected drafts + Navigator.pop(context); // Close dialog }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Delete'), @@ -137,6 +144,7 @@ class _DraftsPageState extends State { ); } + // Utility: check if a draft is empty (no subject, body, or recipients) bool _isDraftEmpty(Email draft) { return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; } diff --git a/lib/pages/email_client/email_pages/email_page.dart b/lib/pages/email_client/email_pages/email_page.dart index 72499bda..9737a35e 100644 --- a/lib/pages/email_client/email_pages/email_page.dart +++ b/lib/pages/email_client/email_pages/email_page.dart @@ -12,6 +12,7 @@ import 'package:campus_app/pages/email_client/widgets/email_tile.dart'; import 'package:campus_app/pages/email_client/widgets/select_email.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; +// Main entry widget for the email client screen class EmailPage extends StatelessWidget { const EmailPage({super.key}); @@ -21,6 +22,7 @@ class EmailPage extends StatelessWidget { } } +// Internal stateful widget that handles authentication, email loading, and UI behavior class _EmailClientContent extends StatefulWidget { const _EmailClientContent(); @@ -32,59 +34,66 @@ class _EmailClientContentState extends State<_EmailClientContent> { final GlobalKey _scaffoldKey = GlobalKey(); final TextEditingController _searchController = TextEditingController(); final FlutterSecureStorage secureStorage = sl(); - bool _isSearching = false; - bool _isLoading = true; - bool _isAuthenticated = false; - late EmailSelectionController _selectionController; + + bool _isSearching = false; // True when search bar is active + bool _isLoading = true; // True while authenticating or initializing + bool _isAuthenticated = false; // True after successful login + late EmailSelectionController _selectionController; // Handles multi-select actions @override void initState() { super.initState(); - _initializeEmailClient(); + _initializeEmailClient(); // Start setup on load } + // Initialize email services and authentication Future _initializeEmailClient() async { final emailAuthService = Provider.of(context, listen: false); final emailService = Provider.of(context, listen: false); + // Set up selection controller with callbacks _selectionController = EmailSelectionController( onDelete: (emails) async { - emailService.moveEmailsToFolder(emails, EmailFolder.trash); - _search(); + emailService.moveEmailsToFolder(emails, EmailFolder.trash); // Move to Trash + _search(); // Refresh view }, onArchive: (emails) async { - emailService.moveEmailsToFolder(emails, EmailFolder.archives); + emailService.moveEmailsToFolder(emails, EmailFolder.archives); // Move to Archives _search(); }, onEmailUpdated: (email) async { - emailService.updateEmail(email); + emailService.updateEmail(email); // Update state if email is modified _search(); }, - )..addListener(_onSelectionChanged); + )..addListener(_onSelectionChanged); // Listen for selection state changes - // Check if user is already authenticated + // Check stored credentials and try to authenticate final isAuthenticated = await emailAuthService.isAuthenticated(); if (isAuthenticated) { - // Initialize email service if authenticated + // If valid, initialize mailbox await emailService.initialize(); setState(() { _isAuthenticated = true; _isLoading = false; }); } else { + // Show login screen if not authenticated setState(() { _isLoading = false; }); } } + // Rebuild UI when selection changes void _onSelectionChanged() => setState(() {}); + // Rebuilds UI when a search is performed void _search() { setState(() {}); } + // Triggers the login screen and handles post-login setup Future _handleLogin() async { Navigator.push( context, @@ -123,6 +132,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { } */ + // Handles back/gesture navigation, exits selection/search/drawer as needed Future _handlePop(BuildContext context) async { if (_selectionController.isSelecting) { _selectionController.clearSelection(); @@ -152,6 +162,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { @override Widget build(BuildContext context) { + // Show loading spinner while initializing if (_isLoading) { return const Scaffold( body: Center( @@ -160,6 +171,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { ); } + // Show login prompt if not authenticated if (!_isAuthenticated) { return Scaffold( appBar: AppBar( @@ -197,11 +209,11 @@ class _EmailClientContentState extends State<_EmailClientContent> { } final emailService = Provider.of(context); - final filteredEmails = emailService.filterEmails(_searchController.text, EmailFolder.inbox); + final filteredEmails = emailService.filterEmails(_searchController.text, EmailFolder.inbox); // Apply search filter return PopScope( onPopInvoked: (didPop) async { - if (!didPop) await _handlePop(context); + if (!didPop) await _handlePop(context); // Custom pop behavior }, child: Scaffold( key: _scaffoldKey, @@ -214,7 +226,7 @@ class _EmailClientContentState extends State<_EmailClientContent> { hintText: 'E-Mails durchsuchen...', border: InputBorder.none, ), - onChanged: (_) => _search(), + onChanged: (_) => _search(), // Update search results ) : const Text('RubMail'), leading: _isSearching @@ -253,15 +265,15 @@ class _EmailClientContentState extends State<_EmailClientContent> { ), ], ), - endDrawer: const EmailDrawer(), + endDrawer: const EmailDrawer(), // Folder navigation drawer body: GestureDetector( behavior: HitTestBehavior.opaque, onTap: _selectionController.isSelecting ? _selectionController.clearSelection : null, child: RefreshIndicator( onRefresh: () async { final emailService = Provider.of(context, listen: false); - await emailService.refreshEmails(); - _search(); + await emailService.refreshEmails(); // Pull-to-refresh + _search(); // Re-apply search }, child: ListView.separated( itemCount: filteredEmails.length, @@ -328,11 +340,18 @@ class _EmailClientContentState extends State<_EmailClientContent> { ); } } + /* NOTES: -- changes on the email client only appear on the app not in the actual Email. -- Email inbox only loads a certain number of emails, - it takes too long to load so needs some efficiency improvements -- Composed Emails don't actually send. - +- some changes on the email client only appear on the app not in the actual Email. Like delete. +- Email inbox only loads a certain number of emails, loading takes a long time needs optimization. +- Drawer top needs to be fixed (name/Email display) +- Some Email bodies are not shown. +- sending emails and replying works. drafts also work. +- selection needs to be added to the drawer pages as well. the selection component is already implemented but + the use of options different than the inbox is needed. +- Setting need to be implemented +- Attachments need implementing as well. Some UI components for that are already implemented but these are only UI + as for the email view with attachments it needs to be further tested. +- Searching is implemented for the inbox but it should also be implemented for the drawer pages */ diff --git a/lib/pages/email_client/email_pages/email_view.dart b/lib/pages/email_client/email_pages/email_view.dart index ec31d64f..e5155b36 100644 --- a/lib/pages/email_client/email_pages/email_view.dart +++ b/lib/pages/email_client/email_pages/email_view.dart @@ -3,11 +3,12 @@ import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; import 'package:flutter_html/flutter_html.dart'; +// Displays a full view of an email, including sender info, subject, body, and actions (reply, delete, restore) class EmailView extends StatelessWidget { - final Email email; - final void Function(Email)? onDelete; - final void Function(Email)? onRestore; - final bool isInTrash; + final Email email; // The email being viewed + final void Function(Email)? onDelete; // Optional callback for deletion + final void Function(Email)? onRestore; // Optional callback for restoring from trash + final bool isInTrash; // Whether the email is currently in the trash folder const EmailView({ super.key, @@ -17,6 +18,7 @@ class EmailView extends StatelessWidget { this.isInTrash = false, }); + // Opens the compose screen with the current email as a reply void _handleReply(BuildContext context) { Navigator.push( context, @@ -26,6 +28,7 @@ class EmailView extends StatelessWidget { ); } + // Shows confirmation dialog before permanently deleting the email void _confirmPermanentDelete(BuildContext context) { showDialog( context: context, @@ -34,14 +37,14 @@ class EmailView extends StatelessWidget { content: const Text('This action is permanent. Are you sure?'), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), + onPressed: () => Navigator.pop(ctx), // Cancel action child: const Text('Cancel'), ), TextButton( onPressed: () { Navigator.pop(ctx); // Close dialog if (onDelete != null) { - onDelete!(email); + onDelete!(email); // Perform delete } Navigator.pop(context); // Close email view ScaffoldMessenger.of(context).showSnackBar( @@ -58,10 +61,11 @@ class EmailView extends StatelessWidget { ); } + // Handles restoring a trashed email void _handleRestore(BuildContext context) { if (onRestore != null) { onRestore!(email); - Navigator.pop(context); + Navigator.pop(context); // Close email view ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Email restored from trash')), ); @@ -71,7 +75,7 @@ class EmailView extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final timeText = '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}'; + final timeText = '${email.date.hour}:${email.date.minute.toString().padLeft(2, '0')}'; // Format time return Scaffold( appBar: AppBar( @@ -80,14 +84,14 @@ class EmailView extends StatelessWidget { if (!isInTrash) IconButton( icon: const Icon(Icons.reply), - onPressed: () => _handleReply(context), + onPressed: () => _handleReply(context), // Quick reply tooltip: 'Reply', ), if (!isInTrash && onDelete != null) IconButton( icon: const Icon(Icons.delete), onPressed: () { - onDelete!(email); + onDelete!(email); // Soft delete (to trash) Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Email moved to trash')), @@ -98,13 +102,13 @@ class EmailView extends StatelessWidget { if (isInTrash) IconButton( icon: const Icon(Icons.restore_from_trash), - onPressed: () => _handleRestore(context), + onPressed: () => _handleRestore(context), // Restore from trash tooltip: 'Restore', ), if (isInTrash) IconButton( icon: const Icon(Icons.delete_forever), - onPressed: () => _confirmPermanentDelete(context), + onPressed: () => _confirmPermanentDelete(context), // Permanent delete tooltip: 'Permanently Delete', ), ], @@ -114,7 +118,7 @@ class EmailView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header + // Header section with sender info and timestamp Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -139,7 +143,7 @@ class EmailView extends StatelessWidget { ), ), Text( - timeText, + timeText, // Display formatted time style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withOpacity(0.6), ), @@ -148,14 +152,14 @@ class EmailView extends StatelessWidget { ), const SizedBox(height: 16), - // Subject + // Subject line Text( email.subject, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 16), - // Body + // Email body (HTML if available, fallback to plain text) email.htmlBody != null && email.htmlBody!.isNotEmpty ? Html(data: email.htmlBody!) : Text( @@ -163,7 +167,7 @@ class EmailView extends StatelessWidget { style: theme.textTheme.bodyLarge, ), - // Attachments + // Attachments section if (email.attachments.isNotEmpty) ...[ const SizedBox(height: 24), Text( @@ -189,7 +193,7 @@ class EmailView extends StatelessWidget { Icon(Icons.insert_drive_file, size: 30, color: theme.iconTheme.color), const SizedBox(height: 4), Text( - 'File ${index + 1}', + 'File ${index + 1}', // Display file number style: theme.textTheme.labelSmall, ), ], @@ -203,7 +207,7 @@ class EmailView extends StatelessWidget { ), floatingActionButton: !isInTrash ? FloatingActionButton( - onPressed: () => _handleReply(context), + onPressed: () => _handleReply(context), // FAB for quick reply tooltip: 'Reply', child: const Icon(Icons.reply), ) diff --git a/lib/pages/email_client/models/email.dart b/lib/pages/email_client/models/email.dart index af0f6833..a19914f0 100644 --- a/lib/pages/email_client/models/email.dart +++ b/lib/pages/email_client/models/email.dart @@ -1,21 +1,21 @@ -// this files defines the data model of an email (structure) +// This file defines the data model of an email (structure) // by defining a data class that represents an email's properties class Email { - final String id; - final String sender; - final String senderEmail; - final List recipients; - final String subject; - final String body; - final String? htmlBody; - final DateTime date; - final bool isUnread; - final bool isStarred; - final List attachments; - final EmailFolder folder; + final String id; // Unique identifier for the email + final String sender; // Display name of the sender + final String senderEmail; // Sender's email address + final List recipients; // List of recipient email addresses + final String subject; // Subject line of the email + final String body; // Plain text body content + final String? htmlBody; // Optional HTML version of the body + final DateTime date; // Timestamp of when the email was sent + final bool isUnread; // Whether the email is unread + final bool isStarred; // Whether the email is marked as important/starred + final List attachments; // Filenames of any attachments + final EmailFolder folder; // The folder where this email is stored // Added for IMAP operations - final int uid; // IMAP UID for server operations + final int uid; // IMAP UID for server operations (used to identify emails remotely) const Email({ required this.id, @@ -33,7 +33,7 @@ class Email { this.uid = 0, // Default to 0 for local/dummy emails }); - // Updated dummy constructor + // Factory method for generating sample/mock emails (used for testing or UI previews) factory Email.dummy(int index) => Email( id: index.toString(), sender: 'Sender $index', @@ -50,7 +50,7 @@ class Email { uid: 0, // Dummy emails don't have IMAP UIDs ); - // JSON serialization + // Convert Email instance to a JSON map for storage or network transmission Map toJson() => { 'id': id, 'sender': sender, @@ -60,13 +60,14 @@ class Email { 'body': body, 'htmlBody': htmlBody, 'date': date.toIso8601String(), - 'isRead': !isUnread, + 'isRead': !isUnread, // Stored as "isRead" for clarity 'isStarred': isStarred, 'attachments': attachments, 'folder': folder.name, 'uid': uid, }; + // Create an Email instance from a JSON map factory Email.fromJson(Map json) => Email( id: json['id'], sender: json['sender'], @@ -83,6 +84,7 @@ class Email { uid: json['uid'] ?? 0, ); + // Create a modified copy of the current Email instance Email copyWith({ String? id, String? sender, @@ -97,7 +99,7 @@ class Email { List? attachments, EmailFolder? folder, int? uid, - bool? isRead, + bool? isRead, // Optional override using isRead instead of isUnread }) => Email( id: id ?? this.id, @@ -115,17 +117,19 @@ class Email { uid: uid ?? this.uid, ); + // Shortened preview text of the email body String get preview { return body.length > 50 ? '${body.substring(0, 50)}...' : body; } - // Convenience getters for compatibility with IMAP service - bool get isRead => !isUnread; + // Convenience getters for easier access in UI and logic + bool get isRead => !isUnread; // Inverted boolean for clarity bool get hasAttachments => attachments.isNotEmpty; - String get senderName => sender; - DateTime get timestamp => date; + String get senderName => sender; // Alias for UI usage + DateTime get timestamp => date; // Alias for sorting or displaying } +// Enum representing standard email folders enum EmailFolder { inbox, sent, diff --git a/lib/pages/email_client/repositories/email_repository.dart b/lib/pages/email_client/repositories/email_repository.dart index 3c91c216..5c904fff 100644 --- a/lib/pages/email_client/repositories/email_repository.dart +++ b/lib/pages/email_client/repositories/email_repository.dart @@ -1,9 +1,18 @@ +// Imports the Email model used throughout the email operations import 'package:campus_app/pages/email_client/models/email.dart'; +// Abstract repository defining the interface for any email backend (e.g., IMAP) abstract class EmailRepository { + // Establish connection to the email server Future connect(String username, String password); + + // Disconnect from the email server Future disconnect(); + + // Fetch a list of emails from a specified mailbox (e.g., INBOX) Future> fetchEmails({required String mailboxName, int count = 50}); + + // Send a new email with optional cc/bcc fields Future sendEmail({ required String to, required String subject, @@ -11,10 +20,20 @@ abstract class EmailRepository { List? cc, List? bcc, }); + + // Mark a specific email as read using its UID Future markAsRead(int uid); + + // Mark a specific email as unread Future markAsUnread(int uid); + + // Delete a specific email from a mailbox (defaults to INBOX) Future deleteEmail(int uid, {String mailboxName}); + + // Move an email to a different mailbox (e.g., Archive, Trash) Future moveEmail(int uid, String targetMailbox); + + // Search emails based on query params in a specific mailbox Future> searchEmails({ String? query, String? from, @@ -22,11 +41,13 @@ abstract class EmailRepository { bool unreadOnly = false, String mailboxName = 'INBOX', }); + + // Check if a connection to the server is active bool get isConnected; - /// Save or update a draft on the server + // Save or update a draft email on the server Future saveDraft(Email draft); - /// Fetch drafts from the server-side “Drafts” folder + // Fetch drafts from the "Drafts" mailbox Future> fetchDrafts({int count = 50}); } diff --git a/lib/pages/email_client/repositories/imap_email_repository.dart b/lib/pages/email_client/repositories/imap_email_repository.dart index 7207816e..35a5c3c5 100644 --- a/lib/pages/email_client/repositories/imap_email_repository.dart +++ b/lib/pages/email_client/repositories/imap_email_repository.dart @@ -1,24 +1,30 @@ +// Imports required Email model, interface, and IMAP service implementation import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/repositories/email_repository.dart'; import 'package:campus_app/pages/email_client/services/imap_email_service.dart'; +// Concrete implementation of EmailRepository using IMAP protocol class ImapEmailRepository implements EmailRepository { final ImapEmailService _imapService; + // Constructor injection of the IMAP email service ImapEmailRepository(this._imapService); @override Future connect(String username, String password) { + // Connect to the email server using credentials return _imapService.connect(username, password); } @override Future disconnect() { + // Disconnect from the email server return _imapService.disconnect(); } @override Future> fetchEmails({required String mailboxName, int count = 50}) { + // Fetch emails from a specific mailbox return _imapService.fetchEmails(mailboxName: mailboxName, count: count); } @@ -30,6 +36,7 @@ class ImapEmailRepository implements EmailRepository { List? cc, List? bcc, }) { + // Send an email with optional cc/bcc return _imapService.sendEmail( to: to, subject: subject, @@ -41,21 +48,25 @@ class ImapEmailRepository implements EmailRepository { @override Future markAsRead(int uid) { + // Mark an email as read return _imapService.markAsRead(uid); } @override Future markAsUnread(int uid) { + // Mark an email as unread return _imapService.markAsUnread(uid); } @override Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) { + // Delete email from specified mailbox return _imapService.deleteEmail(uid, mailboxName: mailboxName); } @override Future moveEmail(int uid, String targetMailbox) { + // Move email to another mailbox return _imapService.moveEmail(uid, targetMailbox); } @@ -67,6 +78,7 @@ class ImapEmailRepository implements EmailRepository { bool unreadOnly = false, String mailboxName = 'INBOX', }) { + // Search emails based on filters return _imapService.searchEmails( query: query, from: from, @@ -77,11 +89,12 @@ class ImapEmailRepository implements EmailRepository { } @override - bool get isConnected => _imapService.isConnected; + bool get isConnected => _imapService.isConnected; // Proxy for connection state @override - Future saveDraft(Email draft) => _imapService.appendDraft(draft); + Future saveDraft(Email draft) => _imapService.appendDraft(draft); // Save draft email @override - Future> fetchDrafts({int count = 50}) => _imapService.fetchEmails(mailboxName: 'Drafts', count: count); + Future> fetchDrafts({int count = 50}) => + _imapService.fetchEmails(mailboxName: 'Drafts', count: count); // Fetch emails from "Drafts" folder } diff --git a/lib/pages/email_client/services/email_auth_service.dart b/lib/pages/email_client/services/email_auth_service.dart index f2cb43b9..0ac13931 100644 --- a/lib/pages/email_client/services/email_auth_service.dart +++ b/lib/pages/email_client/services/email_auth_service.dart @@ -3,20 +3,25 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:campus_app/core/injection.dart'; import 'package:campus_app/core/exceptions.dart'; +// Service to handle email-based authentication using secure storage class EmailAuthService extends ChangeNotifier { final FlutterSecureStorage _secureStorage = sl(); + // Storage keys static const String _emailUsernameKey = 'email_loginId'; static const String _emailPasswordKey = 'email_password'; static const String _isAuthenticatedKey = 'email_is_authenticated'; + // Internal state bool _isAuthenticated = false; String? _currentUsername; String? _currentPassword; + // Public getters bool get isAuthenticatedSync => _isAuthenticated; String? get currentUsername => _currentUsername; + // Check if user is authenticated (reads from secure storage) Future isAuthenticated() async { try { final authStatus = await _secureStorage.read(key: _isAuthenticatedKey); @@ -39,17 +44,17 @@ class EmailAuthService extends ChangeNotifier { } } + // Authenticate and store credentials securely Future authenticate(String username, String password) async { try { - // Validate credentials format (basic validation) if (username.isEmpty || password.isEmpty) { - throw InvalidLoginIDAndPasswordException(); // Removed message + throw InvalidLoginIDAndPasswordException(); } // Simulate API call to RUB email service await _validateEmailCredentials(username, password); - // Store credentials securely + // Save credentials await _secureStorage.write(key: _emailUsernameKey, value: username); await _secureStorage.write(key: _emailPasswordKey, value: password); await _secureStorage.write(key: _isAuthenticatedKey, value: 'true'); @@ -60,26 +65,24 @@ class EmailAuthService extends ChangeNotifier { notifyListeners(); } catch (e) { - await logout(); + await logout(); // Clear state on failure rethrow; } } + // Simulated email credential validation Future _validateEmailCredentials(String username, String password) async { - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(const Duration(seconds: 1)); // Simulate network delay - if (username.length < 3) { - throw InvalidLoginIDAndPasswordException(); // Removed message - } - - if (password.length < 6) { - throw InvalidLoginIDAndPasswordException(); // Removed message + if (username.length < 3 || password.length < 6) { + throw InvalidLoginIDAndPasswordException(); } } + // Return current credentials if authenticated Future?> getCredentials() async { if (!_isAuthenticated) { - await isAuthenticated(); + await isAuthenticated(); // Ensure auth state is current } if (_isAuthenticated && _currentUsername != null && _currentPassword != null) { @@ -92,6 +95,7 @@ class EmailAuthService extends ChangeNotifier { return null; } + // Log out and clear stored credentials Future logout() async { try { await _secureStorage.delete(key: _emailUsernameKey); @@ -108,10 +112,12 @@ class EmailAuthService extends ChangeNotifier { notifyListeners(); } + // Refresh authentication state Future refresh() async { await isAuthenticated(); } + // Validate currently stored credentials Future validateCurrentCredentials() async { if (!_isAuthenticated || _currentUsername == null || _currentPassword == null) { return false; @@ -121,7 +127,7 @@ class EmailAuthService extends ChangeNotifier { await _validateEmailCredentials(_currentUsername!, _currentPassword!); return true; } catch (e) { - await logout(); + await logout(); // Invalidate session on failure return false; } } diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index b7913074..1714c8c8 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -21,7 +21,7 @@ class EmailService extends ChangeNotifier { List get allEmails => List.unmodifiable(_allEmails); EmailSelectionController get selectionController => _selectionController; - /// Initialize connection and pull all folders (including drafts). + // Initialize connection and pull all folders (including drafts). Future initialize() async { try { final credentials = await _authService.getCredentials(); @@ -44,7 +44,7 @@ class EmailService extends ChangeNotifier { if (!success) throw Exception('Failed to connect to email server'); } - /// Refreshes all mailbox folders, including server drafts. + // Refreshes all mailbox folders, including server drafts. Future refreshEmails() async { if (!_isInitialized) throw Exception('Email service not initialized'); try { @@ -61,14 +61,14 @@ class EmailService extends ChangeNotifier { } } - /// Fetches inbox, sent, drafts (server-side), trash, spam. + // Fetches inbox, sent, drafts (server-side), trash, spam. Future _fetchEmailsFromServer() async { _allEmails.clear(); final folderMappings = { EmailFolder.inbox: ['INBOX'], EmailFolder.sent: ['Sent'], - EmailFolder.drafts: ['Drafts'], // ← We’ll fetch server drafts separately below + EmailFolder.drafts: ['Drafts'], // ← fetch server drafts separately below EmailFolder.trash: ['Trash'], EmailFolder.spam: ['UCE-TMP'], }; @@ -91,7 +91,7 @@ class EmailService extends ChangeNotifier { } } - /// Helper to try all mailbox name aliases for a given folder. + // Helper to try all mailbox name aliases for a given folder. Future _fetchEmailsForFolder(EmailFolder folder, List folderNames) async { for (final folderName in folderNames) { try { @@ -118,7 +118,7 @@ class EmailService extends ChangeNotifier { notifyListeners(); } - /// Sends a new email over SMTP and refreshes the Sent folder. + // Sends a new email over SMTP and refreshes the Sent folder. Future sendEmail({ required String to, required String subject, @@ -215,7 +215,7 @@ class EmailService extends ChangeNotifier { return !(await _authService.validateCurrentCredentials()); } - // === Local Data Helpers === + // Local Data Helpers List filterEmails(String query, EmailFolder folder) { final filtered = _allEmails.where((e) => e.folder == folder).toList(); @@ -259,7 +259,7 @@ class EmailService extends ChangeNotifier { notifyListeners(); } - /// Save or update a draft both locally and on the IMAP server + // Save or update a draft both locally and on the IMAP server Future saveOrUpdateDraft(Email draft) async { // 1) Local cache update if (_isDraftEmpty(draft)) { diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart index 81380fad..2add3600 100644 --- a/lib/pages/email_client/services/imap_email_service.dart +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -19,7 +19,7 @@ class ImapEmailService { bool get isConnected => _imapClient?.isConnected ?? false; - /// Connects to the IMAP server and logs in. + // Connects to the IMAP server and logs in. Future connect(String username, String password) async { _imapClient = ImapClient(isLogEnabled: true); try { @@ -35,7 +35,7 @@ class ImapEmailService { } } - /// Disconnects both IMAP and SMTP clients cleanly. + // Disconnects both IMAP and SMTP clients cleanly. Future disconnect() async { try { await _imapClient?.disconnect(); @@ -50,7 +50,7 @@ class ImapEmailService { } } - /// Fetches [count] messages from [mailboxName], newest-first paging. + // Fetches [count] messages from [mailboxName], newest-first paging. Future> fetchEmails({ String mailboxName = 'INBOX', int count = 50, @@ -81,7 +81,7 @@ class ImapEmailService { return emails.reversed.toList(); } - /// Fetches a single email by its UID. + // Fetches a single email by its UID. Future fetchEmailByUid(int uid, {String mailboxName = 'INBOX'}) async { if (_imapClient == null || !_imapClient!.isConnected) { throw Exception('Not connected to IMAP server'); @@ -92,7 +92,7 @@ class ImapEmailService { return await _convertMimeMessageToEmail(result.messages.first); } - /// Sends an email via SMTP, then appends it into the IMAP “Sent” folder. + // Sends an email via SMTP, then appends it into the IMAP “Sent” folder. Future sendEmail({ required String to, required String subject, @@ -165,7 +165,7 @@ class ImapEmailService { } } - /// Appends (or updates) a draft in the IMAP “Drafts” folder. + // Appends (or updates) a draft in the IMAP “Drafts” folder. Future appendDraft(Email draft) async { if (_imapClient == null || !_imapClient!.isConnected) { throw Exception('Not connected to IMAP server'); @@ -236,7 +236,7 @@ class ImapEmailService { return Future.wait(result.messages.map(_convertMimeMessageToEmail)); } - /// Internal helper to add/remove flags (e.g., Seen). + // Internal helper to add/remove flags (e.g., Seen). Future _updateEmailFlags( int uid, List flags, { @@ -263,7 +263,7 @@ class ImapEmailService { Future markAsUnread(int uid, {String mailboxName = 'INBOX'}) => _updateEmailFlags(uid, [MessageFlags.seen], remove: true, mailboxName: mailboxName); - /// Deletes a message (marks \Deleted + EXPUNGE). + // Deletes a message (marks \Deleted + EXPUNGE). Future deleteEmail(int uid, {String mailboxName = 'INBOX'}) async { try { await _imapClient!.selectMailboxByPath(mailboxName); @@ -276,7 +276,7 @@ class ImapEmailService { } } - /// Moves a message to [targetMailbox]. + // Moves a message to [targetMailbox]. Future moveEmail( int uid, String targetMailbox, { @@ -293,7 +293,7 @@ class ImapEmailService { } } - /// Converts a raw [MimeMessage] into your app’s [Email] model. + // Converts a raw [MimeMessage] into your app’s [Email] model. Future _convertMimeMessageToEmail(MimeMessage msg) async { final plain = msg.decodeTextPlainPart(); final html = msg.decodeTextHtmlPart(); From 111476097fd1fc15ff4b70457a0ab659d0ff9468 Mon Sep 17 00:00:00 2001 From: Domai Date: Mon, 30 Jun 2025 13:51:58 +0200 Subject: [PATCH 20/20] apply dart fixes; use debugPrint; use StyledHtml --- .../email_client/email_drawer/drafts.dart | 5 ---- ..._Screen.dart => compose_email_screen.dart} | 0 .../email_client/email_pages/email_view.dart | 18 ++++++++------ .../email_client/services/email_service.dart | 10 ++++---- .../services/imap_email_service.dart | 24 +++++++++---------- 5 files changed, 28 insertions(+), 29 deletions(-) rename lib/pages/email_client/email_pages/{compose_email_Screen.dart => compose_email_screen.dart} (100%) diff --git a/lib/pages/email_client/email_drawer/drafts.dart b/lib/pages/email_client/email_drawer/drafts.dart index d8a5a781..2a82fd68 100644 --- a/lib/pages/email_client/email_drawer/drafts.dart +++ b/lib/pages/email_client/email_drawer/drafts.dart @@ -143,9 +143,4 @@ class _DraftsPageState extends State { ), ); } - - // Utility: check if a draft is empty (no subject, body, or recipients) - bool _isDraftEmpty(Email draft) { - return draft.subject.trim().isEmpty && draft.body.trim().isEmpty && draft.recipients.isEmpty; - } } diff --git a/lib/pages/email_client/email_pages/compose_email_Screen.dart b/lib/pages/email_client/email_pages/compose_email_screen.dart similarity index 100% rename from lib/pages/email_client/email_pages/compose_email_Screen.dart rename to lib/pages/email_client/email_pages/compose_email_screen.dart diff --git a/lib/pages/email_client/email_pages/email_view.dart b/lib/pages/email_client/email_pages/email_view.dart index e5155b36..44097353 100644 --- a/lib/pages/email_client/email_pages/email_view.dart +++ b/lib/pages/email_client/email_pages/email_view.dart @@ -1,7 +1,7 @@ +import 'package:campus_app/utils/widgets/styled_html.dart'; import 'package:flutter/material.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; import 'package:campus_app/pages/email_client/email_pages/compose_email_screen.dart'; -import 'package:flutter_html/flutter_html.dart'; // Displays a full view of an email, including sender info, subject, body, and actions (reply, delete, restore) class EmailView extends StatelessWidget { @@ -160,12 +160,16 @@ class EmailView extends StatelessWidget { const SizedBox(height: 16), // Email body (HTML if available, fallback to plain text) - email.htmlBody != null && email.htmlBody!.isNotEmpty - ? Html(data: email.htmlBody!) - : Text( - email.body, - style: theme.textTheme.bodyLarge, - ), + if (email.htmlBody != null && email.htmlBody!.isNotEmpty) + StyledHTML( + text: email.htmlBody!, + context: context, + ) + else + Text( + email.body, + style: theme.textTheme.bodyLarge, + ), // Attachments section if (email.attachments.isNotEmpty) ...[ diff --git a/lib/pages/email_client/services/email_service.dart b/lib/pages/email_client/services/email_service.dart index 1714c8c8..6fc02639 100644 --- a/lib/pages/email_client/services/email_service.dart +++ b/lib/pages/email_client/services/email_service.dart @@ -82,12 +82,12 @@ class EmailService extends ChangeNotifier { // 2) Fetch server-side drafts and merge try { - final serverDrafts = await _emailRepository.fetchDrafts(count: 50); + final serverDrafts = await _emailRepository.fetchDrafts(); for (final draft in serverDrafts) { _allEmails.add(draft.copyWith(folder: EmailFolder.drafts)); } } catch (e) { - print('Could not fetch server drafts: $e'); + debugPrint('Could not fetch server drafts: $e'); } } @@ -107,7 +107,7 @@ class EmailService extends ChangeNotifier { } if (folder != EmailFolder.inbox) { - print('Could not fetch ${folder.name} emails from: ${folderNames.join(', ')}'); + debugPrint('Could not fetch ${folder.name} emails from: ${folderNames.join(', ')}'); } } @@ -280,11 +280,11 @@ class EmailService extends ChangeNotifier { try { final success = await _emailRepository.saveDraft(draft); if (!success) { - print('Failed to save draft on server'); + debugPrint('Failed to save draft on server'); // Optionally, show a user-facing error here } } catch (e) { - print('Error while saving draft: $e'); + debugPrint('Error while saving draft: $e'); // Optionally, rollback local change } } diff --git a/lib/pages/email_client/services/imap_email_service.dart b/lib/pages/email_client/services/imap_email_service.dart index 2add3600..c1918e25 100644 --- a/lib/pages/email_client/services/imap_email_service.dart +++ b/lib/pages/email_client/services/imap_email_service.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'dart:math' as math; import 'package:enough_mail/enough_mail.dart'; +import 'package:flutter/foundation.dart'; import 'package:intl/intl.dart'; import 'package:campus_app/pages/email_client/models/email.dart'; -import 'dart:convert'; class ImapEmailService { ImapClient? _imapClient; @@ -27,10 +27,10 @@ class ImapEmailService { _password = password; await _imapClient!.connectToServer(_imapHost, _imapPort, isSecure: true); await _imapClient!.login(_username!, _password!); - print('IMAP: Connected as $_username'); + debugPrint('IMAP: Connected as $_username'); return true; } catch (e) { - print('IMAP: Connection/login failed: $e'); + debugPrint('IMAP: Connection/login failed: $e'); return false; } } @@ -41,7 +41,7 @@ class ImapEmailService { await _imapClient?.disconnect(); await _smtpClient?.disconnect(); } catch (e) { - print('Disconnect error: $e'); + debugPrint('Disconnect error: $e'); } finally { _imapClient = null; _smtpClient = null; @@ -142,7 +142,7 @@ class ImapEmailService { // ─── 3) Send the message ─────────────────────────────────────────────── await _smtpClient!.sendMessage(mimeMessage); - print('SMTP: Message sent'); + debugPrint('SMTP: Message sent'); // ─── 4) Append to IMAP “Sent” folder ────────────────────────────────── if (_imapClient != null && _imapClient!.isConnected) { @@ -152,15 +152,15 @@ class ImapEmailService { mimeMessage, flags: [MessageFlags.seen], // mark as read in Sent ); - print('IMAP: Appended message to Sent'); + debugPrint('IMAP: Appended message to Sent'); } catch (e) { - print('IMAP: Failed to append to Sent: $e'); + debugPrint('IMAP: Failed to append to Sent: $e'); } } return true; } catch (e) { - print('sendEmail error: $e'); + debugPrint('sendEmail error: $e'); return false; } } @@ -186,7 +186,7 @@ class ImapEmailService { ); return true; } catch (e) { - print('appendDraft error: $e'); + debugPrint('appendDraft error: $e'); return false; } } @@ -252,7 +252,7 @@ class ImapEmailService { ); return true; } catch (e) { - print('Error updating email flags: $e'); + debugPrint('Error updating email flags: $e'); return false; } } @@ -271,7 +271,7 @@ class ImapEmailService { await _imapClient!.expunge(); return true; } catch (e) { - print('Error deleting email: $e'); + debugPrint('Error deleting email: $e'); return false; } } @@ -288,7 +288,7 @@ class ImapEmailService { await _imapClient!.uidMove(MessageSequence.fromId(uid)); return true; } catch (e) { - print('Error moving email: $e'); + debugPrint('Error moving email: $e'); return false; } }