From 3ec46ac730992e38d8691710755266ccc897c43a Mon Sep 17 00:00:00 2001 From: Jonathan Duval-Venne <45638733+JonathanDuvalV@users.noreply.github.com> Date: Fri, 22 Sep 2023 09:34:03 -0400 Subject: [PATCH 1/2] Feature/reordable links (#849) * Reordable quicklinks * Make quicklinks deletable * Edit mode required for reordering * Make delete icon a badge * Shake animation * Refactor for readability * Keep quicklinks in cache * Restore quicklinks * Restore quick links second option * Divider visibility * green badge * Added repository for cache logic * Remove outdated comments * Added/fixed tests related to quicklinks * Removed unused imports * [BOT] Applying version. * Fix warning * [BOT] Update golden files * Make security not deletable * Fixed imports order and annotations --------- Co-authored-by: JonathanDuvalV --- ios/Podfile.lock | 232 +++++++++--------- lib/core/constants/quick_links.dart | 9 + lib/core/managers/quick_link_repository.dart | 46 ++++ lib/core/models/quick_link.dart | 7 +- lib/core/models/quick_link_data.dart | 23 ++ .../viewmodels/quick_links_viewmodel.dart | 83 ++++++- lib/locator.dart | 2 + lib/ui/views/quick_links_view.dart | 209 ++++++++++++++-- pubspec.lock | 7 + pubspec.yaml | 3 +- test/helpers.dart | 12 + test/managers/quick_link_repository_test.dart | 98 ++++++++ .../managers/quick_links_repository_mock.dart | 49 ++++ .../views/goldenFiles/quicksLinksView_1.png | Bin 8748 -> 9500 bytes test/ui/views/quick_links_view_test.dart | 21 +- test/ui/widgets/link_web_view_test.dart | 1 + test/ui/widgets/web_link_card_test.dart | 4 +- .../quick_links_viewmodel_test.dart | 123 ++++++++++ .../web_link_card_viewmodel_test.dart | 4 +- 19 files changed, 786 insertions(+), 147 deletions(-) create mode 100644 lib/core/managers/quick_link_repository.dart create mode 100644 lib/core/models/quick_link_data.dart create mode 100644 test/managers/quick_link_repository_test.dart create mode 100644 test/mock/managers/quick_links_repository_mock.dart create mode 100644 test/viewmodels/quick_links_viewmodel_test.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b9a272696..80c95058f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4,90 +4,84 @@ PODS: - ReachabilitySwift - device_info_plus (0.0.1): - Flutter - - Firebase/Analytics (9.2.0): + - Firebase/Analytics (10.3.0): - Firebase/Core - - Firebase/Core (9.2.0): + - Firebase/Core (10.3.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 9.2.0) - - Firebase/CoreOnly (9.2.0): - - FirebaseCore (= 9.2.0) - - Firebase/Crashlytics (9.2.0): + - FirebaseAnalytics (~> 10.3.0) + - Firebase/CoreOnly (10.3.0): + - FirebaseCore (= 10.3.0) + - Firebase/Crashlytics (10.3.0): - Firebase/CoreOnly - - FirebaseCrashlytics (~> 9.2.0) - - Firebase/RemoteConfig (9.2.0): + - FirebaseCrashlytics (~> 10.3.0) + - Firebase/RemoteConfig (10.3.0): - Firebase/CoreOnly - - FirebaseRemoteConfig (~> 9.2.0) - - firebase_analytics (9.2.0): - - Firebase/Analytics (= 9.2.0) + - FirebaseRemoteConfig (~> 10.3.0) + - firebase_analytics (10.1.0): + - Firebase/Analytics (= 10.3.0) - firebase_core - Flutter - - firebase_core (1.19.2): - - Firebase/CoreOnly (= 9.2.0) + - firebase_core (2.4.1): + - Firebase/CoreOnly (= 10.3.0) - Flutter - - firebase_crashlytics (2.6.1): - - Firebase/Crashlytics (= 9.2.0) + - firebase_crashlytics (3.0.9): + - Firebase/Crashlytics (= 10.3.0) - firebase_core - Flutter - - firebase_remote_config (2.0.10): - - Firebase/RemoteConfig (= 9.2.0) + - firebase_remote_config (3.0.9): + - Firebase/RemoteConfig (= 10.3.0) - firebase_core - Flutter - - FirebaseABTesting (9.6.0): - - FirebaseCore (~> 9.0) - - FirebaseAnalytics (9.2.0): - - FirebaseAnalytics/AdIdSupport (= 9.2.0) - - FirebaseCore (~> 9.0) - - FirebaseInstallations (~> 9.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - - GoogleUtilities/MethodSwizzler (~> 7.7) - - GoogleUtilities/Network (~> 7.7) - - "GoogleUtilities/NSData+zlib (~> 7.7)" + - FirebaseABTesting (10.5.0): + - FirebaseCore (~> 10.0) + - FirebaseAnalytics (10.3.0): + - FirebaseAnalytics/AdIdSupport (= 10.3.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseAnalytics/AdIdSupport (9.2.0): - - FirebaseCore (~> 9.0) - - FirebaseInstallations (~> 9.0) - - GoogleAppMeasurement (= 9.2.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - - GoogleUtilities/MethodSwizzler (~> 7.7) - - GoogleUtilities/Network (~> 7.7) - - "GoogleUtilities/NSData+zlib (~> 7.7)" + - FirebaseAnalytics/AdIdSupport (10.3.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.3.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseCore (9.2.0): - - FirebaseCoreDiagnostics (~> 9.0) - - FirebaseCoreInternal (~> 9.0) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/Logger (~> 7.7) - - FirebaseCoreDiagnostics (9.5.0): - - GoogleDataTransport (< 10.0.0, >= 9.1.4) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/Logger (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseCoreInternal (9.5.0): - - "GoogleUtilities/NSData+zlib (~> 7.7)" - - FirebaseCrashlytics (9.2.0): - - FirebaseCore (~> 9.0) - - FirebaseInstallations (~> 9.0) - - GoogleDataTransport (< 10.0.0, >= 9.1.4) - - GoogleUtilities/Environment (~> 7.7) + - FirebaseCore (10.3.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreInternal (10.5.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseCrashlytics (10.3.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.8) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (~> 2.1) - - FirebaseInstallations (9.5.0): - - FirebaseCore (~> 9.0) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/UserDefaults (~> 7.7) + - FirebaseInstallations (10.5.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) - PromisesObjC (~> 2.1) - - FirebaseRemoteConfig (9.2.0): - - FirebaseABTesting (~> 9.0) - - FirebaseCore (~> 9.0) - - FirebaseInstallations (~> 9.0) - - GoogleUtilities/Environment (~> 7.7) - - "GoogleUtilities/NSData+zlib (~> 7.7)" + - FirebaseRemoteConfig (10.3.0): + - FirebaseABTesting (~> 10.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" - Flutter (1.0.0) - flutter_config (0.0.1): - Flutter - flutter_custom_tabs (0.0.1): - Flutter - - flutter_secure_storage (3.3.1): + - flutter_secure_storage (6.0.0): - Flutter - fluttertoast (0.0.2): - Flutter @@ -95,30 +89,30 @@ PODS: - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) - - google_maps_flutter (0.0.1): + - google_maps_flutter_ios (0.0.1): - Flutter - GoogleMaps - - GoogleAppMeasurement (9.2.0): - - GoogleAppMeasurement/AdIdSupport (= 9.2.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - - GoogleUtilities/MethodSwizzler (~> 7.7) - - GoogleUtilities/Network (~> 7.7) - - "GoogleUtilities/NSData+zlib (~> 7.7)" + - GoogleAppMeasurement (10.3.0): + - GoogleAppMeasurement/AdIdSupport (= 10.3.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleAppMeasurement/AdIdSupport (9.2.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 9.2.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - - GoogleUtilities/MethodSwizzler (~> 7.7) - - GoogleUtilities/Network (~> 7.7) - - "GoogleUtilities/NSData+zlib (~> 7.7)" + - GoogleAppMeasurement/AdIdSupport (10.3.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.3.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleAppMeasurement/WithoutAdIdSupport (9.2.0): - - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - - GoogleUtilities/MethodSwizzler (~> 7.7) - - GoogleUtilities/Network (~> 7.7) - - "GoogleUtilities/NSData+zlib (~> 7.7)" + - GoogleAppMeasurement/WithoutAdIdSupport (10.3.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Network (~> 7.8) + - "GoogleUtilities/NSData+zlib (~> 7.8)" - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleDataTransport (9.2.0): + - GoogleDataTransport (9.2.1): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) @@ -127,24 +121,24 @@ PODS: - GoogleMaps/Base (5.2.0) - GoogleMaps/Maps (5.2.0): - GoogleMaps/Base - - GoogleUtilities/AppDelegateSwizzler (7.7.0): + - GoogleUtilities/AppDelegateSwizzler (7.11.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - - GoogleUtilities/Environment (7.7.0): + - GoogleUtilities/Environment (7.11.0): - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.7.0): + - GoogleUtilities/Logger (7.11.0): - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (7.7.0): + - GoogleUtilities/MethodSwizzler (7.11.0): - GoogleUtilities/Logger - - GoogleUtilities/Network (7.7.0): + - GoogleUtilities/Network (7.11.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.7.0)" - - GoogleUtilities/Reachability (7.7.0): + - "GoogleUtilities/NSData+zlib (7.11.0)" + - GoogleUtilities/Reachability (7.11.0): - GoogleUtilities/Logger - - GoogleUtilities/UserDefaults (7.7.0): + - GoogleUtilities/UserDefaults (7.11.0): - GoogleUtilities/Logger - home_widget (0.0.1): - Flutter @@ -166,6 +160,7 @@ PODS: - sqflite (0.0.2): - Flutter - FMDB (>= 2.7.5) + - SwiftyXMLParser (5.5.0) - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter @@ -184,13 +179,14 @@ DEPENDENCIES: - flutter_custom_tabs (from `.symlinks/plugins/flutter_custom_tabs/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - - google_maps_flutter (from `.symlinks/plugins/google_maps_flutter/ios`) + - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) - home_widget (from `.symlinks/plugins/home_widget/ios`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) + - SwiftyXMLParser (from `https://github.com/yahoojapan/SwiftyXMLParser.git`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`) @@ -200,7 +196,6 @@ SPEC REPOS: - FirebaseABTesting - FirebaseAnalytics - FirebaseCore - - FirebaseCoreDiagnostics - FirebaseCoreInternal - FirebaseCrashlytics - FirebaseInstallations @@ -238,8 +233,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" - google_maps_flutter: - :path: ".symlinks/plugins/google_maps_flutter/ios" + google_maps_flutter_ios: + :path: ".symlinks/plugins/google_maps_flutter_ios/ios" home_widget: :path: ".symlinks/plugins/home_widget/ios" in_app_review: @@ -252,40 +247,46 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_ios/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" + SwiftyXMLParser: + :git: https://github.com/yahoojapan/SwiftyXMLParser.git url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" +CHECKOUT OPTIONS: + SwiftyXMLParser: + :commit: d7a1d23f04c86c1cd2e8f19247dd15d74e0ea8be + :git: https://github.com/yahoojapan/SwiftyXMLParser.git + SPEC CHECKSUMS: - connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e + connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed - Firebase: 4ba896cb8e5105d4b9e247e1c1b6222b548df55a - firebase_analytics: e6754d7dd82bd2006b002d88c0f44f8f50da26e6 - firebase_core: ada8be870601fe3c2684dae2356f634189bd598f - firebase_crashlytics: fed0cb9004bc3bb7005f04cf2e01765c441e941a - firebase_remote_config: a99557e31b31cb583d3ca70c66134099ad4b054b - FirebaseABTesting: 61826730ce9eee8781ba99a2b3420e9bce148dc9 - FirebaseAnalytics: af5a03a8dff7648c7b8486f6a78b1368e0268dd3 - FirebaseCore: 0e27f2a15d8f7b7ef11e7d93e23b1cbab55d748c - FirebaseCoreDiagnostics: 17cbf4e72b1dbd64bfdc33d4b1f07bce4f16f1d8 - FirebaseCoreInternal: 50a8e39cae8abf72d5145d07ea34c3244f70862b - FirebaseCrashlytics: 9fff819edb2bfc9d3eff612225b207d41945a935 - FirebaseInstallations: 41f811b530c41dd90973d0174381cdb3fcb5e839 - FirebaseRemoteConfig: 16e29297f0dd0c7d2415c4506d614fe0b54875d1 + Firebase: f92fc551ead69c94168d36c2b26188263860acd9 + firebase_analytics: 9f3a4cb560a59976b2c48707abae2d4cb94bcb3a + firebase_core: bf59c32d2e53814f558efa20840c1902fa2fe461 + firebase_crashlytics: a45cced3521640e1e389d8b3662936ea9afd6055 + firebase_remote_config: 5007603d4cec2dc1e5016077a7ec36ed93c5041b + FirebaseABTesting: 8cb5cc4e395c8dce8a2820a6a329020ead56fe2f + FirebaseAnalytics: 036232b6a1e2918e5f67572417be1173576245f3 + FirebaseCore: 988754646ab3bd4bdcb740f1bfe26b9f6c0d5f2a + FirebaseCoreInternal: e463f41bb935cd049505bf7e9a5bdd7dcea90df6 + FirebaseCrashlytics: f20d956f8229010b645e534693c39e0b7843c268 + FirebaseInstallations: 935bc4abb6f7a035cab7a0c31cb777b2be3dd254 + FirebaseRemoteConfig: c24f767c17b0440ee63c7e93380d599173556113 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_config: 2226c1df19c78fe34a05eb7f1363445f18e76fc1 flutter_custom_tabs: 7a10a08686955cb748e5d26e0ae586d30689bf89 - flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec + flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - google_maps_flutter: c59fc576c0d0c7f4dc4bd63832c862d22d5a7c6d - GoogleAppMeasurement: 7a33224321f975d58c166657260526775d9c6b1a - GoogleDataTransport: 1c8145da7117bd68bbbed00cf304edb6a24de00f + google_maps_flutter_ios: 66201f392bf62d500f07670a30488a247b9bb5b9 + GoogleAppMeasurement: c7d6fff39bf2d829587d74088d582e32d75133c3 + GoogleDataTransport: ea169759df570f4e37bdee1623ec32a7e64e67c4 GoogleMaps: 025272d5876d3b32604e5c080dc25eaf68764693 - GoogleUtilities: e0913149f6b0625b553d70dae12b49fc62914fd1 + GoogleUtilities: c2bdc4cf2ce786c4d2e6b3bcfd599a25ca78f06f home_widget: 2829415127ee92e876f816cbbe44c0b6601b8a37 - in_app_review: 4a97249f7a2f539a0f294c2d9196b7fe35e49541 + in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 @@ -293,10 +294,11 @@ SPEC CHECKSUMS: ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + SwiftyXMLParser: 027d9e6fb54a38d95dccec025bcea9693f699c47 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de webview_flutter_wkwebview: 005fbd90c888a42c5690919a1527ecc6649e1162 -PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 +PODFILE CHECKSUM: a6ab79567d5a527b85ace1f55bd19f140d2d619c COCOAPODS: 1.11.3 diff --git a/lib/core/constants/quick_links.dart b/lib/core/constants/quick_links.dart index 819979296..823195e6e 100644 --- a/lib/core/constants/quick_links.dart +++ b/lib/core/constants/quick_links.dart @@ -12,6 +12,7 @@ import 'package:notredame/ui/utils/app_theme.dart'; List quickLinks(AppIntl intl) => [ QuickLink( + id: 1, name: intl.ets_security_title, image: const FaIcon( FontAwesomeIcons.shieldHalved, @@ -20,6 +21,7 @@ List quickLinks(AppIntl intl) => [ ), link: 'security'), QuickLink( + id: 2, name: intl.ets_monets_title, image: Image.asset( 'assets/images/ic_monets_sans_nom_red.png', @@ -27,6 +29,7 @@ List quickLinks(AppIntl intl) => [ ), link: 'https://portail.etsmtl.ca/home'), QuickLink( + id: 3, name: intl.ets_bibliotech_title, image: const FaIcon( FontAwesomeIcons.book, @@ -35,6 +38,7 @@ List quickLinks(AppIntl intl) => [ ), link: 'https://www.etsmtl.ca/Bibliotheque/Accueil'), QuickLink( + id: 4, name: intl.ets_news_title, image: const FaIcon( FontAwesomeIcons.newspaper, @@ -43,6 +47,7 @@ List quickLinks(AppIntl intl) => [ ), link: 'https://www.etsmtl.ca/nouvelles'), QuickLink( + id: 5, name: intl.ets_directory_title, image: const FaIcon( FontAwesomeIcons.addressBook, @@ -51,6 +56,7 @@ List quickLinks(AppIntl intl) => [ ), link: 'https://www.etsmtl.ca/bottin'), QuickLink( + id: 6, name: intl.ets_moodle_title, image: Image.asset( 'assets/images/ic_moodle_red.png', @@ -58,6 +64,7 @@ List quickLinks(AppIntl intl) => [ ), link: 'https://ena.etsmtl.ca/'), QuickLink( + id: 7, name: intl.ets_schedule_generator, image: const FaIcon( FontAwesomeIcons.calendar, @@ -66,11 +73,13 @@ List quickLinks(AppIntl intl) => [ ), link: 'https://horairets.emmanuelcoulombe.dev/'), QuickLink( + id: 8, name: intl.ets_gus, image: SvgPicture.asset('assets/images/ic_gus_red.svg', color: AppTheme.etsLightRed), link: 'https://gus.etsmtl.ca/c2atom/mobile/login'), QuickLink( + id: 9, name: intl.ets_papercut_title, image: const FaIcon( FontAwesomeIcons.print, diff --git a/lib/core/managers/quick_link_repository.dart b/lib/core/managers/quick_link_repository.dart new file mode 100644 index 000000000..9250445b2 --- /dev/null +++ b/lib/core/managers/quick_link_repository.dart @@ -0,0 +1,46 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'dart:convert'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// SERVICES +import 'package:notredame/core/managers/cache_manager.dart'; + +// MODELS +import 'package:notredame/core/models/quick_link.dart'; +import 'package:notredame/core/models/quick_link_data.dart'; + +// CONSTANTS +import 'package:notredame/core/constants/quick_links.dart'; + +// OTHERS +import 'package:notredame/locator.dart'; + +class QuickLinkRepository { + final CacheManager _cacheManager = locator(); + + static const String quickLinksCacheKey = "quickLinksCache"; + + Future> getQuickLinkDataFromCache() async { + final cacheData = await _cacheManager.get(quickLinksCacheKey); + final responseCache = jsonDecode(cacheData) as List; + + return responseCache + .map((e) => QuickLinkData.fromJson(e as Map)) + .toList(); + } + + Future updateQuickLinkDataToCache(List quickLinkList) async { + final quickLinkDataList = quickLinkList + .asMap() + .entries + .map((e) => QuickLinkData(id: e.value.id, index: e.key)) + .toList(); + + await _cacheManager.update( + quickLinksCacheKey, jsonEncode(quickLinkDataList)); + } + + List getDefaultQuickLinks(AppIntl intl) { + return quickLinks(intl); + } +} diff --git a/lib/core/models/quick_link.dart b/lib/core/models/quick_link.dart index c2fe715b6..d2699d7e3 100644 --- a/lib/core/models/quick_link.dart +++ b/lib/core/models/quick_link.dart @@ -2,9 +2,14 @@ import 'package:flutter/material.dart'; class QuickLink { + final int id; final Widget image; final String name; final String link; - QuickLink({@required this.image, @required this.name, @required this.link}); + QuickLink( + {@required this.id, + @required this.image, + @required this.name, + @required this.link}); } diff --git a/lib/core/models/quick_link_data.dart b/lib/core/models/quick_link_data.dart new file mode 100644 index 000000000..274b89b29 --- /dev/null +++ b/lib/core/models/quick_link_data.dart @@ -0,0 +1,23 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; + +class QuickLinkData { + final int id; + final int index; + + QuickLinkData({@required this.id, @required this.index}); + + factory QuickLinkData.fromJson(Map json) { + return QuickLinkData( + id: json['id'] as int, + index: json['index'] as int, + ); + } + + Map toJson() { + return { + 'id': id, + 'index': index, + }; + } +} diff --git a/lib/core/viewmodels/quick_links_viewmodel.dart b/lib/core/viewmodels/quick_links_viewmodel.dart index 7ceee69cd..ca59736b8 100644 --- a/lib/core/viewmodels/quick_links_viewmodel.dart +++ b/lib/core/viewmodels/quick_links_viewmodel.dart @@ -2,15 +2,88 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:stacked/stacked.dart'; -// CONSTANTS -import 'package:notredame/core/constants/quick_links.dart'; +// MANAGERS +import 'package:notredame/core/managers/quick_link_repository.dart'; // MODELS import 'package:notredame/core/models/quick_link.dart'; +import 'package:notredame/core/models/quick_link_data.dart'; + +// OTHERS +import 'package:notredame/locator.dart'; + +class QuickLinksViewModel extends FutureViewModel> { + /// Localization class of the application. + final AppIntl _appIntl; -class QuickLinksViewModel extends BaseViewModel { /// used to get all links for ETS page - final List quickLinkList; + List quickLinkList = []; + + List deletedQuickLinks = []; + + final QuickLinkRepository _quickLinkRepository = + locator(); + + QuickLinksViewModel(AppIntl intl) : _appIntl = intl; + + Future> getQuickLinks() async { + List quickLinkDataList = []; + try { + quickLinkDataList = + await _quickLinkRepository.getQuickLinkDataFromCache(); + } catch (e) { + // if cache is not initialized, return default list + return _quickLinkRepository.getDefaultQuickLinks(_appIntl); + } + + // otherwise, return quickLinks according to the cache + final defaultQuickLinks = + _quickLinkRepository.getDefaultQuickLinks(_appIntl); + quickLinkDataList.sort((a, b) => a.index.compareTo(b.index)); + return quickLinkDataList + .map((data) => defaultQuickLinks + .firstWhere((quickLink) => quickLink.id == data.id)) + .toList(); + } + + Future> getDeletedQuickLinks() async { + // Get ids from current quick links + final currentQuickLinkIds = quickLinkList.map((e) => e.id).toList(); + + // Return those not in current quick links but in default list + return _quickLinkRepository + .getDefaultQuickLinks(_appIntl) + .where((element) => !currentQuickLinkIds.contains(element.id)) + .toList(); + } + + Future deleteQuickLink(int index) async { + final deletedQuickLink = quickLinkList.removeAt(index); + deletedQuickLinks.add(deletedQuickLink); + await _quickLinkRepository.updateQuickLinkDataToCache(quickLinkList); + notifyListeners(); + } + + Future restoreQuickLink(int index) async { + final deletedQuickLink = deletedQuickLinks.removeAt(index); + quickLinkList.add(deletedQuickLink); + await _quickLinkRepository.updateQuickLinkDataToCache(quickLinkList); + notifyListeners(); + } + + Future reorderQuickLinks(int oldIndex, int newIndex) async { + final QuickLink item = quickLinkList.removeAt(oldIndex); + quickLinkList.insert(newIndex, item); + await _quickLinkRepository.updateQuickLinkDataToCache(quickLinkList); + notifyListeners(); + } - QuickLinksViewModel(AppIntl intl) : quickLinkList = quickLinks(intl); + @override + Future> futureToRun() async { + setBusyForObject(quickLinkList, true); + quickLinkList = await getQuickLinks(); + deletedQuickLinks = await getDeletedQuickLinks(); + setBusyForObject(quickLinkList, false); + return quickLinkList; + } } diff --git a/lib/locator.dart b/lib/locator.dart index 012a95d7e..abc371eab 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -22,6 +22,7 @@ import 'package:notredame/core/managers/user_repository.dart'; import 'package:notredame/core/managers/course_repository.dart'; import 'package:notredame/core/managers/cache_manager.dart'; import 'package:notredame/core/managers/settings_manager.dart'; +import 'package:notredame/core/managers/quick_link_repository.dart'; // OTHER import 'package:ets_api_clients/clients.dart'; @@ -49,6 +50,7 @@ void setupLocator() { locator.registerLazySingleton(() => CourseRepository()); locator.registerLazySingleton(() => CacheManager()); locator.registerLazySingleton(() => SettingsManager()); + locator.registerLazySingleton(() => QuickLinkRepository()); // Other locator.registerLazySingleton(() => SignetsAPIClient()); diff --git a/lib/ui/views/quick_links_view.dart b/lib/ui/views/quick_links_view.dart index 97ce90ef5..3c3fb8d8d 100644 --- a/lib/ui/views/quick_links_view.dart +++ b/lib/ui/views/quick_links_view.dart @@ -3,44 +3,215 @@ import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +// MODEL +import 'package:notredame/core/models/quick_link.dart'; + // VIEW-MODEL import 'package:notredame/core/viewmodels/quick_links_viewmodel.dart'; +// UTILS +import 'package:notredame/ui/utils/app_theme.dart'; + // WIDGETS import 'package:notredame/ui/widgets/base_scaffold.dart'; import 'package:notredame/ui/widgets/web_link_card.dart'; +import 'package:reorderable_grid_view/reorderable_grid_view.dart'; class QuickLinksView extends StatefulWidget { @override _QuickLinksViewState createState() => _QuickLinksViewState(); } -class _QuickLinksViewState extends State { +class _QuickLinksViewState extends State + with SingleTickerProviderStateMixin { + // Enable/Disable the edit state + bool _editMode = false; + + // Animation Controller for Shake Animation + AnimationController _controller; + Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(milliseconds: 250), + vsync: this, + ); + + _animation = Tween(begin: -0.05, end: 0.05).animate(_controller); + } + @override Widget build(BuildContext context) => ViewModelBuilder.reactive( viewModelBuilder: () => QuickLinksViewModel(AppIntl.of(context)), builder: (context, model, child) => BaseScaffold( isLoading: model.isBusy, - appBar: AppBar( - title: Text(AppIntl.of(context).title_ets), - automaticallyImplyLeading: false, - ), - body: SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: SingleChildScrollView( - padding: const EdgeInsets.only(top: 8.0), - child: Wrap( - alignment: WrapAlignment.center, - children: List.generate( - model.quickLinkList.length, - (index) => WebLinkCard(model.quickLinkList[index]), - ), - ), + appBar: _buildAppBar(context, model), + body: _buildBody(context, model), + ), + ); + + AppBar _buildAppBar(BuildContext context, QuickLinksViewModel model) { + return AppBar( + title: Text(AppIntl.of(context).title_ets), + automaticallyImplyLeading: false, + actions: const [], + ); + } + + Widget _buildBody(BuildContext context, QuickLinksViewModel model) { + return GestureDetector( + onTap: () { + if (_editMode) { + _controller.reset(); + setState(() { + _editMode = false; + }); + } + }, + child: SafeArea( + child: Column( + children: [ + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0), + child: _buildReorderableGridView( + model, model.quickLinkList, _buildDeleteButton), ), ), - ), + if (_editMode && model.deletedQuickLinks.isNotEmpty) ...[ + const Divider( + thickness: 2, + indent: 10, + endIndent: 10, + ), + Expanded( + child: Padding( + padding: + const EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0), + child: _buildReorderableGridView( + model, model.deletedQuickLinks, _buildAddButton), + ), + ), + ], + ], ), - ); + ), + ); + } + + ReorderableGridView _buildReorderableGridView( + QuickLinksViewModel model, + List quickLinks, + Widget Function(QuickLinksViewModel, int) buildButtonFunction) { + return ReorderableGridView.count( + mainAxisSpacing: 2.0, + crossAxisSpacing: 2.0, + crossAxisCount: 3, + children: List.generate( + quickLinks.length, + (index) { + return KeyedSubtree( + key: ValueKey(quickLinks[index].id), + child: + _buildGridChild(model, index, quickLinks, buildButtonFunction), + ); + }, + ), + onReorder: (oldIndex, newIndex) { + setState(() { + model.reorderQuickLinks(oldIndex, newIndex); + }); + }, + ); + } + + Widget _buildGridChild( + QuickLinksViewModel model, + int index, + List quickLinks, + Widget Function(QuickLinksViewModel, int) buildButtonFunction) { + return GestureDetector( + onLongPress: _editMode + ? null + : () { + _controller.repeat(reverse: true); + setState(() { + _editMode = true; + }); + }, + child: AnimatedBuilder( + animation: _animation, + builder: (BuildContext context, Widget child) { + return Transform.rotate( + angle: _editMode ? _animation.value : 0, + child: child, + ); + }, + child: Stack( + children: [ + WebLinkCard(quickLinks[index]), + if (_editMode && + quickLinks[index].id != + 1) // Don't show delete button for Security QuickLink + Positioned( + top: 0, + left: 0, + child: buildButtonFunction(model, index), + ), + ], + ), + ), + ); + } + + Container _buildDeleteButton(QuickLinksViewModel model, int index) { + return Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: AppTheme.etsDarkGrey, + shape: BoxShape.circle, + ), + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.close, color: Colors.white, size: 16), + onPressed: () { + setState(() { + model.deleteQuickLink(index); + }); + }, + ), + ); + } + + Container _buildAddButton(QuickLinksViewModel model, int index) { + return Container( + width: 32, + height: 32, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.add, color: Colors.white, size: 20), + onPressed: () { + setState(() { + model.restoreQuickLink(index); + }); + }, + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } } diff --git a/pubspec.lock b/pubspec.lock index e24d0871f..bcc47f26a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -912,6 +912,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.4" + reorderable_grid_view: + dependency: "direct main" + description: + name: reorderable_grid_view + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.6" rive: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e72117d56..acc62915d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ description: The 4th generation of ÉTSMobile, the main gateway between the Éco # pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 4.25.3+1 +version: 4.26.0+1 environment: sdk: ">=2.10.0 <3.0.0" @@ -71,6 +71,7 @@ dependencies: auto_size_text: ^3.0.0 easter_egg_trigger: ^1.0.1 calendar_view: ^1.0.1 + reorderable_grid_view: ^2.2.6 dev_dependencies: flutter_test: diff --git a/test/helpers.dart b/test/helpers.dart index 52e32a81d..2b8e67a91 100644 --- a/test/helpers.dart +++ b/test/helpers.dart @@ -28,11 +28,13 @@ import 'package:notredame/core/services/app_widget_service.dart'; import 'package:notredame/core/services/in_app_review_service.dart'; import 'package:notredame/core/services/launch_url_service.dart'; import 'package:notredame/core/services/remote_config_service.dart'; +import 'package:notredame/core/managers/quick_link_repository.dart'; // MOCKS import 'package:ets_api_clients/testing.dart'; import 'mock/managers/cache_manager_mock.dart'; import 'mock/managers/course_repository_mock.dart'; +import 'mock/managers/quick_links_repository_mock.dart'; import 'mock/managers/settings_manager_mock.dart'; import 'mock/managers/user_repository_mock.dart'; import 'mock/services/analytics_service_mock.dart'; @@ -328,6 +330,16 @@ RemoteConfigService setupRemoteConfigServiceMock() { return service; } +/// Load a mock of the [QuickLinkRepository] +QuickLinkRepository setupQuickLinkRepositoryMock() { + unregister(); + final repository = QuickLinkRepositoryMock(); + + locator.registerSingleton(repository); + + return repository; +} + bool getCalendarViewEnabled() { final RemoteConfigService remoteConfigService = locator(); diff --git a/test/managers/quick_link_repository_test.dart b/test/managers/quick_link_repository_test.dart new file mode 100644 index 000000000..f65b26e8e --- /dev/null +++ b/test/managers/quick_link_repository_test.dart @@ -0,0 +1,98 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +// SERVICES / MANAGER +import 'package:notredame/core/managers/cache_manager.dart'; +import 'package:notredame/core/managers/quick_link_repository.dart'; + +// MODELS +import 'package:notredame/core/models/quick_link.dart'; +import 'package:notredame/core/models/quick_link_data.dart'; + +// UTILS +import '../helpers.dart'; + +// MOCKS +import '../mock/managers/cache_manager_mock.dart'; + +void main() { + CacheManager cacheManager; + QuickLinkRepository quickLinkRepository; + + group("QuickLinkRepository - ", () { + setUp(() { + // Setup needed services and managers + cacheManager = setupCacheManagerMock(); + + quickLinkRepository = QuickLinkRepository(); + }); + + tearDown(() { + clearInteractions(cacheManager); + unregister(); + }); + + group("getQuickLinkDataFromCache - ", () { + test("QuickLinkData is loaded from cache.", () async { + // Stub the cache to return some QuickLinkData + final quickLinkData = QuickLinkData(id: 1, index: 0); + CacheManagerMock.stubGet( + cacheManager as CacheManagerMock, + QuickLinkRepository.quickLinksCacheKey, + jsonEncode([quickLinkData])); + + final List results = + await quickLinkRepository.getQuickLinkDataFromCache(); + + expect(results, isInstanceOf>()); + expect(results[0].id, quickLinkData.id); + expect(results[0].index, quickLinkData.index); + + verify(cacheManager.get(QuickLinkRepository.quickLinksCacheKey)) + .called(1); + }); + + test( + "Trying to recover QuickLinkData from cache but an exception is raised.", + () async { + // Stub the cache to throw an exception + CacheManagerMock.stubGetException(cacheManager as CacheManagerMock, + QuickLinkRepository.quickLinksCacheKey); + + expect(quickLinkRepository.getQuickLinkDataFromCache(), + throwsA(isInstanceOf())); + }); + }); + + group("updateQuickLinkDataToCache - ", () { + test("Updating QuickLinkData to cache.", () async { + // Prepare some QuickLinkData to be cached + final quickLink = + QuickLink(id: 1, image: const Text(""), name: 'name', link: 'url'); + final quickLinkData = QuickLinkData(id: quickLink.id, index: 0); + + await quickLinkRepository.updateQuickLinkDataToCache([quickLink]); + + verify(cacheManager.update(QuickLinkRepository.quickLinksCacheKey, + jsonEncode([quickLinkData]))).called(1); + }); + + test( + "Trying to update QuickLinkData to cache but an exception is raised.", + () async { + // Stub the cache to throw an exception + CacheManagerMock.stubUpdateException(cacheManager as CacheManagerMock, + QuickLinkRepository.quickLinksCacheKey); + + final quickLink = + QuickLink(id: 1, image: const Text(""), name: 'name', link: 'url'); + + expect(quickLinkRepository.updateQuickLinkDataToCache([quickLink]), + throwsA(isInstanceOf())); + }); + }); + }); +} diff --git a/test/mock/managers/quick_links_repository_mock.dart b/test/mock/managers/quick_links_repository_mock.dart new file mode 100644 index 000000000..af529884d --- /dev/null +++ b/test/mock/managers/quick_links_repository_mock.dart @@ -0,0 +1,49 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:mockito/mockito.dart'; + +// MANAGER +import 'package:notredame/core/managers/quick_link_repository.dart'; + +// MODELS +import 'package:notredame/core/models/quick_link.dart'; +import 'package:notredame/core/models/quick_link_data.dart'; + +// UTILS +import 'package:ets_api_clients/exceptions.dart'; + +class QuickLinkRepositoryMock extends Mock implements QuickLinkRepository { + /// Stub the function [getQuickLinkDataFromCache] of [mock] when called will return [toReturn]. + static void stubGetQuickLinkDataFromCache(QuickLinkRepositoryMock mock, + {List toReturn = const []}) { + when(mock.getQuickLinkDataFromCache()).thenAnswer((_) async => toReturn); + } + + /// Stub the function [getQuickLinkDataFromCache] of [mock] when called will throw [toThrow]. + static void stubGetQuickLinkDataFromCacheException( + QuickLinkRepositoryMock mock, + {Exception toThrow = const ApiException(prefix: 'ApiException')}) { + when(mock.getQuickLinkDataFromCache()).thenAnswer((_) => + Future.delayed(const Duration(milliseconds: 50)) + .then((value) => throw toThrow)); + } + + /// Stub the function [getDefaultQuickLinks] of [mock] when called will return [toReturn]. + static void stubGetDefaultQuickLinks(QuickLinkRepositoryMock mock, + {List toReturn = const []}) { + when(mock.getDefaultQuickLinks(any)).thenAnswer((_) => toReturn); + } + + /// Stub the function [updateQuickLinkDataToCache] of [mock] when called will complete without errors. + static void stubUpdateQuickLinkDataToCache(QuickLinkRepositoryMock mock) { + when(mock.updateQuickLinkDataToCache(any)).thenAnswer((_) async => {}); + } + + /// Stub the function [updateQuickLinkDataToCache] of [mock] when called will throw [toThrow]. + static void stubUpdateQuickLinkDataToCacheException( + QuickLinkRepositoryMock mock, + {Exception toThrow = const ApiException(prefix: 'ApiException')}) { + when(mock.updateQuickLinkDataToCache(any)).thenAnswer((_) => + Future.delayed(const Duration(milliseconds: 50)) + .then((value) => throw toThrow)); + } +} diff --git a/test/ui/views/goldenFiles/quicksLinksView_1.png b/test/ui/views/goldenFiles/quicksLinksView_1.png index 3172fac2edaf215a1c8a5c20da75b1377e255f59..0d550ce2fb2799102b24df8a26812fe0a47ea840 100644 GIT binary patch literal 9500 zcmeHtcT`i`_AX)pML<9)0tS&H8bDC06bl`U0@9n5h;*cvK#tNyKw9YJNEM_Rnju1z zA|(_70jZJRK{_M>ev5PNefQk^?tQ;8?%$6w_Sj^Xwf358&iT#nTPxxbOqKr3#WPe? zRP^dYKYtwO1jP>dC$YRr` zD{q|aRCI_CE1N9}rSn-%W~MK`ux4p~9ujkD3$eM``*4RH;x}WaOT`lrDafE1I~qdu z^K9@*stfhhzn!r>b|<8a>i2twRF5u0sotGqrFu+DM|JIgxy?^E`q@R&0G`bi{d}wL zIWCgw$4k_I-6AVRF49qv`1V_7%rWVc!vE;L?IjtCiLy1N#zy@2t^a*E_m#A8bjNsh zx-tJ4ZbIq$Fpl| zN4M8yoZN7uGe3VmssCAtL%`CvSBdD+vbm+Div2LRg$m1t&}7om@^WRZDJ)~+SJ|pDsV3FVQ3a(dE6#)$W+o;M zzQoy$lrRSFEQRcJ>F4zb(tN)4%ik+2CG6f0EPJJynVHR`+_YUfU~+2%=k}5kk@Av~ zP8|z+diufBk#jR{y1MbBite3@b8}_ijRFD!>Sx@{%(ySd)^}&CT2uQ3?yBHb)%d)} z>qC;MU><%_iclSM+2z&M>Q7@;P9}wVXT~OD6rFto)~8yrhpIcB)fE*v#Or2H`io5j z70_McqB=3%TZ7-f^LcuDYU)c00+aOg^jM|jW9<{fp)-&<-mE%4!?_vA$d@OsD{=3L ztJTnI=g}H|;UrzBOb=w^`q-flLXNYDqz!I#y? zW-mk6Q!E?N7N$^M-j%vR%OCTn-YoWICi}{%Uq4(s3!_md%OnS^t+u)zP9-{?r#Tsl zQhq_03G(T6GBc$NvDgVS`gdbo-!y$(c(d%}2IoWmHTV5Xs@Ld@Du2R$!)aQt(taqD;)i8CC}^i_FU75t zFGZTA*uY%X;|uXQt2sdUvr7V*x%CbLVT5CffyCEAtfE&ej3ylW2Zd4%MD8}&p zu$>=0gtbY!=sMkx>^=7nxC>wn*Y5GDwr{_W>2#ce7=tlm#xU%KJVRvz`+={(GA*VXxidDN1a#L~8?1aS8JAa3qdaKwIL3arbAffB3uiy6w z=_=B%+^wI1>+Y(5G#`T3%j&EpI$z8k62BTsc%x zLeieI@Q_y?u$B{Q;Ptx_#t4Cx8;0cP=QpqW1^(1Q&vSBc#Mad*5I!kcKHvRi$-xJ&Q^QbHb2Zs?xT(-5PCA^}d!qAvi zLPA1Jc+BVZqpLP~#&d7OF2&XQ?|g0F*+Zm9cCqXaJXC6&Tx(e@#XG0$jIIV6)cpz( zZG@T_7zl#Q9I3C6A7OwNNzx1r4TUzF0TAVM`6Tw#sCrmf0qvqqnBxn?E9R}LD&5vr#Wzo?v!CbsIsZ^3i2#7`fiZe?bTp4Jjj1hyC7X&Q z4Gs<#ke2efSvQcEx3;z@I?P(&^wi%USEE2fL(}o~tBSGnX@Qt#Ye^}owCwEcLK4@- zi{hM|oDaY?|3UZXg#`s-;Ah9%w{I&HcE(+Io%GPkyjQMVF)j3q8D7@kx+=(W_7o@^ zYzmY*^XEM@Lp1jdd}_yDliBGTq;bEOQ9WWm+aR5`ZI=J#$)jlQ+X8`j3+fkCNtBs_ z(o!~bm*Axs!D6P+v)FK)dWSWG05NEHh=&(XI0S-32?XATVwVM*=p zE~Iz*YPz_T8Ug$!`x=xybgmvA!6Wetl$y71zih9|TJ~%nkBW{?HK}s^aAXQe#~;71 z&}iU-;xozZftMo|+z$`-^M;qJYG1=rKbgDKM`OQyxqI*?R|Y$}l3=jZ!w?Aygkj{3 zeAwCnF9SV2(|HaUK>X)1+NA>y{)6e#ef|A~#_WafFN0B)U}r88T1snbWU;GtWSh|% z&sKe5X)sPdKR+v#OAHJQ#+^(q*lAWx(c0nV%=Zsf8qLMz8M@?tw(9$sE;C}EQs za~EQrS(OXX*=VMYyb_<6CjbWgL zSt5Yn8Z8uk_|V<6dh<{oL};dr`_~oT*$Ok9SJU@FiuL2H1LI5be&3I-u?WH%zZ(@k zX9H8YoFEmaOuRVK-F??M|65-l{HO>?bYX(9vzL7u=;^87=Mi!ot?6@%QN6*Qj%#C* zkFKBH=$?4+Wp<-!Wa%9TG5*g2WRT-({OX8vH9wV#ipmUZGK=PeGEqC?sN`^SRfSA_ z&xd7^v;^RWN*4KfdT-Ezwkr|kFBUxfx@RL$vz5y+-)6n_XVs3PEYfvEwC0Y% z^*Js~d$E1F#>MLH_w|qB<2s9w_>#z}^C&s!S7#^F1+%A#xWn*K<+6{vjq%J1ULz}3 zHjYiz+Zts?UE{P&vapiOhC!&4Z{FTJbB4*7 z_0G^4VLo13x;u6hf<kOZr7Ngp9m}NOLcS zin(PbYy`1mCZ=DchAo{~EJ?gT-nMbV4=Nc_sW;vZY2j1KBg$UqhmfDOEo&XF51{t2 z9%mZtXvN?^ck{Y+% z)K{!~~CoE<$9z+z)k@VY^vE&%FzFws0S3QFW+@TuG)4o&H zkje-{ak<3oeaQ5j`#_b63A=!YyqS2fnBb9NN+0S2TBDz7R2tdimoin{K~BaTv@lbr zq*w8sVY>ZL`RJ~7wR8AwZ%F{VKyIBb`6FS$M8C4w5trDL9i@K@R zt6zb#aata0ysOs83;pc`?b+*p=X*5U)OIbK-}&OV+?H>J|AqD(F~PVkhR#SSV#4Nr zPtWeggL0*_*4L&s&uDz}zgL6Czc%qB%^2VM{3CIswNRDp&t;U5T!n5ro=YAN&5ZMsMD5{>4?&#-Xg4w;*xS4VYehD z1%|7f$}8Bj8oUEp{Cs>2C7p)&tgWpzjbVbmzrN52TaLS7t?o%(x^n(ZN&#!l-b@<& z$&=6GU9%ezU9<5uH8p2nr6FH_`F(f)Ai2>CYbt*07N2vqzw_Ak@-Uq2wYVFtW@eun z6p~e1`j8TcY%gf~`SbVbpMmqaNj8&TqfqdLg@v4w5*VOq)qHSIYCKUC0Q}7N>hf~k zD#yV%53`0Fof?20>plR~7htluaYSM6!i4LgQ#W2bX|cY0^G~nTaf-IiR3p-n9mW?egr7LyNAbj zM<=JsBOJR3V7j}zdnxQB{@ng!3yabNet<1K_L^e8a+d(}ex!%4t*xbKYl`KP?xU_k z9|y3m0X+cJLE}0L{`R0cTHGp>eKisBoqtQ_de1!T=;fjde_(s#X5|G8=y%Pp7Ikn_ z7!2$w7c147FiUpZn>CBKfOOCrFNh7k*aVE*JQ=}}mL+!M2G5H-zui^d_}0a zWTibZHPsq_)-SDj*(-8jQ7@_>{Q9UuB!IOaft`tq@!%5Ly1Kf0*nG?s4bX(dAd?xPOAfBW_X zAU0eIdWwd|>F`UsY!f+BT2|KnsAN4dba8Pp!10&7<*kcbPj~EFqNK*ydo_{G+}<~a zZnXXSRkpdgX-MJ*U+@Wd4eHWd*d;%;?Uhjnqk-9tR&MW+4>oYmmjIcM@Oz&D*cA1i z&wqOX`#bz{!H#+z>LGjt|f>gqufNT3SpOg-K_0 z%*-<64t6Is%~b>=yxZ+cF6N$%j2C=pY;G&7miGoslu;^vTHCS+HEZwpgk;@buOwb;74;8ltrLi%q;5Zo>rvh!Izq9LA1~_g!ThHy=jW?GJ-v$d496#(jd7GjW?yu#`F3kMGDI!JDQemD#M7 z{Mu=RlNBD7;StRCxi znu(b8*BIqJ%s{oWnI`~4ovvo{n+^vLV~RZ8w*%jB>8>@2LWiVJMYXO%s+_pgIzvvf ziXfJJ+5&@tRWzFNo{AL~&WL2g@V>^c6dpMlInQ{{=1#?%3lV+tp~xxAf*+f{*osM^ zly&rL+-U2{eVrPw4%6!=_kzk=ZqDrb^c)<|#^|6F*UJ)0%&q{#Pp?bus7G{NA=FhE zz0%ijHWLIhp;h-Qt391FaXO|#y>A5u8Y~Ddeyw?17T)Aec2vw&@?v836&TFb25l8? zduXgJ=(){vW{7X#+;u8(x}Y7+-O5Sv=RbdMr*sZUX;a4abn4b%LS{G>FrnDpACusx zh|xhygU%jJLUfg*4xVv{bff+hBPh=7_tmAbJ!+T4*ReQnI!|*53JA zd&kA=DjytmFmKzhHt1BX2Ku&&wn#{EpSAIut9b2V7oWvgl8Pe`0*X8d+=MZ1-YQeXT|7rt(%en&LZLcft}Y`6$+t?m}7055}yM|#4 z$K|IPTYk*0#7*?Qw*g@YqOwa9*j^l5-Yg*UEj`YVW%60wJOxuF@IKT+%W``Mt}VRJ zeFm=tlcwV}><8w-xP8q#w{bSHm5m{HKS>b-aYrCl1TZ$jw8pG^LEt;oHs3;xu{ugm z6~7UTr3gv`cZK%b^Oco!7)D-s+sC$SezOx3HGiF=$a&TDUIz^b@5r-*(dh!93-^qS zf|a0xp<=v@Bf`O?UOS_O5!_eXy4zv%DQH`PBU96u9XOTcWBsr4^x@$n%Bc(wXr~BK z-fB^0Ei6qC^9$9UOfr4Rd!yLFY5mgFTA3x%cPYOITxt}q*L&3!**9Dz__~x!%+Diz z62Sn4`4vioEz$y_pSCfyC$A1=X5_(6*enE>AYYD;{V|%WYrd0JP>?H|xFAVAT}814 zEI^E%75O7TfxBOmlxXJm<%peADT^raXv@WCy&O>i*5f0a$Nc&*spCZ^VKs9>KoD^f zqt^*_n@z!sCpr6@wd|4>8xTabIv(h{QtMX4;me0y$+gYdn>QV7)FajA($i#z09dn5fnSMIPOQc%8>)!pe|$nfp3#E6Xje-3nNcdcMPLBxr0SKSfi| z8I zY1O%^i7d~(o+l??lI72J7-(5n9kP}uiEUi{k^=lhcVa_e z33m>Vx7d8^5RfA#O@?xIQ}uKvHjeV0RZ%iGrzJ={5yxqW?Zj^q=7}o0FhIt1$>6hV?Fi0Oc+Q%sLjAwfSTgno{vDFDw4~_h6kEoe@q9C z1YC@Nvn@8~;Use`HjL_uB1q4LK3muHo`kmt#{aknDdF5pJr?RcD|!k({Xy*sAE#?# z#=fcW^|}qHf#N!sW$f%LgHpd$?SsghcM6xPOlIod(e6d{zM*~sx}T2cF1IlF=xx#F zqBL-|umlD26GyJm9ok50fG$x0NN(I7qH95Y%~V<0fKH;P)tP`XiR__+8o()!D=knuiKip-X%*oLN(lE!4w zuQ0J#bghuCqQ7!pU83LZ`NCb%E{zYfq?0Z*kueOgKP1+~^xO2~cHJ%Tq#d~{;IQ@< zbG-XEb@<;cLTV~>-rMLH`D}F>f`HxYLf&5SS2id`r4ZBOdVCtjlylb6F_Gulngc`! zT`5+pf^gYi8#&~5rB+f`>-SiQ@yvte;mf*YFSRv6gwEP#HFHr)N>Nx-Q<}@G+xXqk zG#gSBx~WdvSb&)U?v#9alwO+#jX8+j;m0FS$ovaRKqn`yX4+G-M@mCGV3{Rzlvmn27vlf4*MS!#N(>>!rE z%P>45N!O>vg&OK7*9^^LUca_6&!V2#Aq$t*nwN%$fA8-%o}mf)!hbzJ9uOiU^ML^X zjw?K>K$+OwLkJD1bFg;PcyGeaIoQ}VGE)QXYr+esJf-nW=@DACp`iga5bO8)53VR` zl#&kB4iEqw^y0;f(9+wovS7sv+L3i3`ojmY!78UT_!z}P$s=H8s8W;q#pk$$j)F^w zUv~*#6G*WnQ?Nc4mnf5T zGU)CadB101fUTFbu(E;zRH(gntlT!HqO#HmWoK{C4D?Z;9*Y>BF|)8Bj)=)xI5@mt z0!(PWCe$)L@kpso_4D_Cz+~C>WMOIPbwO8mw{|b!??*fb=*Z7wy8obyGmWj2Yso<0 z6}rg-=tJ?DNbu?k5syAD*HHl0IxPc$NSO+ZS(VhSu6wkIdEEcmvzARCxuXj}W`0QB z%?P#z)Ew;v5Ch7w9{RAN{-s5D;E_J;@9(eMKRY+~Zt%yCXa2tK0d82+=O)kA^0v3V zh6#(opa;FatYiqQ5E(jg`UExVbh$y)C-YJu6DPx}A0KIk3m7~e8LKe|1p+z{Hot>e zA6R_wo@faq0I*mIsQVpCpd$CWTdu;D=4YFFosW-1Wo<*Mt59GF_zA&(yU6%23q$|q z7J>g|2f_cp&BNb&>oEC0@4e7amy{Hjzv5I zZOEZPC<0#qrQ>gl2K>6n(Y867+h`CbNtshsjGJtZ0nY%DC>{tk(1d5>DypjRff6&i zvCdQpha8Xsj@~o9eIQML3`m9B&tos^pj0%S{Z#nm;+J)7h-Bj7K?Uk?e-(*W<8RH} zsa*}QlfV&uXSq|-(@h(hg-v~Zv8JAVdJEHF1#6lSx!b^tx_aAfe3#&7Xu8uppP@mS zDqc#jhgOLq_Xu{vhb>oPpgQW9*$wm|!Z@E<6i;!}hgq`r(o2oJ=`U#9+n6nPJ5&Ax zv@rP}Wb6O49QV)rU0O*|Huzm+9>NCcOiW2WEjYrC5)mA^lUM?*FiYuhPH735jQN{9 z`ikjH2bxJhKdH^up+)ku z2zEoy=nmJsLIAmDu_WH&U_B|a3Q-ck)U9(Zyi7Wf^6?X8ov<*?M*fZPwc0o1wl~ac zb*$KY3p)(oUK$jt|`q6$G-ji;78^0AFOk<8;F8jtgkO!vBxoi9Zpp0$}pvp Idrw~e4{ZoE!~g&Q literal 8748 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 Bk|+QG diff --git a/test/ui/views/quick_links_view_test.dart b/test/ui/views/quick_links_view_test.dart index 3ee1dc07a..4e0c7e0c3 100644 --- a/test/ui/views/quick_links_view_test.dart +++ b/test/ui/views/quick_links_view_test.dart @@ -12,13 +12,20 @@ import 'package:notredame/core/constants/quick_links.dart'; import 'package:notredame/core/services/networking_service.dart'; import 'package:notredame/core/services/launch_url_service.dart'; +// MANAGERS +import 'package:notredame/core/managers/quick_link_repository.dart'; + // VIEW import 'package:notredame/ui/views/quick_links_view.dart'; // WIDGETS import 'package:notredame/ui/widgets/web_link_card.dart'; +// UTILS import '../../helpers.dart'; + +// MOCKS +import '../../mock/managers/quick_links_repository_mock.dart'; import '../../mock/services/analytics_service_mock.dart'; import '../../mock/services/internal_info_service_mock.dart'; import '../../mock/services/navigation_service_mock.dart'; @@ -26,6 +33,8 @@ import '../../mock/services/navigation_service_mock.dart'; void main() { AppIntl intl; + QuickLinkRepository quickLinkRepository; + group('QuickLinksView - ', () { setUp(() async { intl = await setupAppIntl(); @@ -34,6 +43,13 @@ void main() { setupInternalInfoServiceMock(); setupNetworkingServiceMock(); setupLaunchUrlServiceMock(); + quickLinkRepository = setupQuickLinkRepositoryMock(); + QuickLinkRepositoryMock.stubGetDefaultQuickLinks( + quickLinkRepository as QuickLinkRepositoryMock, + toReturn: quickLinks(intl)); + + QuickLinkRepositoryMock.stubGetQuickLinkDataFromCacheException( + quickLinkRepository as QuickLinkRepositoryMock); }); tearDown(() { @@ -42,6 +58,7 @@ void main() { unregister(); unregister(); unregister(); + unregister(); }); group('UI - ', () { @@ -51,8 +68,8 @@ void main() { useScaffold: false)); await tester.pumpAndSettle(); - expect( - find.byType(WebLinkCard), findsNWidgets(quickLinks(intl).length)); + expect(find.byType(WebLinkCard, skipOffstage: false), + findsNWidgets(quickLinks(intl).length)); }); group("golden - ", () { diff --git a/test/ui/widgets/link_web_view_test.dart b/test/ui/widgets/link_web_view_test.dart index b7b1e48a1..93ce97810 100644 --- a/test/ui/widgets/link_web_view_test.dart +++ b/test/ui/widgets/link_web_view_test.dart @@ -13,6 +13,7 @@ import 'package:notredame/ui/widgets/link_web_view.dart'; import '../../helpers.dart'; final _quickLink = QuickLink( + id: 1, image: const Icon(Icons.ac_unit), name: 'test', link: 'https://clubapplets.ca/'); diff --git a/test/ui/widgets/web_link_card_test.dart b/test/ui/widgets/web_link_card_test.dart index beb09a3b7..aaba00a20 100644 --- a/test/ui/widgets/web_link_card_test.dart +++ b/test/ui/widgets/web_link_card_test.dart @@ -16,8 +16,8 @@ import 'package:notredame/ui/widgets/web_link_card.dart'; import '../../helpers.dart'; -final _quickLink = - QuickLink(image: const Icon(Icons.ac_unit), name: 'test', link: 'testlink'); +final _quickLink = QuickLink( + id: 1, image: const Icon(Icons.ac_unit), name: 'test', link: 'testlink'); void main() { AnalyticsService analyticsService; diff --git a/test/viewmodels/quick_links_viewmodel_test.dart b/test/viewmodels/quick_links_viewmodel_test.dart new file mode 100644 index 000000000..0f075cd17 --- /dev/null +++ b/test/viewmodels/quick_links_viewmodel_test.dart @@ -0,0 +1,123 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/core/constants/quick_links.dart'; + +// MODELS +import 'package:notredame/core/models/quick_link.dart'; +import 'package:notredame/core/models/quick_link_data.dart'; + +// VIEWMODEL +import 'package:notredame/core/viewmodels/quick_links_viewmodel.dart'; + +// MANAGER +import 'package:notredame/core/managers/quick_link_repository.dart'; + +// OTHERS +import '../helpers.dart'; +import '../mock/managers/quick_links_repository_mock.dart'; + +void main() { + AppIntl intl; + QuickLinksViewModel viewModel; + QuickLinkRepository quickLinkRepository; + + // Sample data for tests + QuickLinkData quickLinkDataSample; + QuickLink quickLinkSample; + + group("QuickLinksViewModel - ", () { + setUp(() async { + // Setting up mocks + quickLinkRepository = setupQuickLinkRepositoryMock(); + intl = await setupAppIntl(); + + viewModel = QuickLinksViewModel(intl); + quickLinkDataSample = QuickLinkData(id: 1, index: 0); + quickLinkSample = quickLinks(intl).first; + }); + + tearDown(() { + unregister(); + }); + + group('getQuickLinks -', () { + test('Should get quick links from cache', () async { + QuickLinkRepositoryMock.stubGetQuickLinkDataFromCache( + quickLinkRepository as QuickLinkRepositoryMock, + toReturn: [quickLinkDataSample]); + + QuickLinkRepositoryMock.stubGetDefaultQuickLinks( + quickLinkRepository as QuickLinkRepositoryMock, + toReturn: [quickLinkSample]); + + final result = await viewModel.getQuickLinks(); + + expect(result, [quickLinkSample]); + }); + + test('Should return default quick links if cache is not initialized', + () async { + QuickLinkRepositoryMock.stubGetQuickLinkDataFromCacheException( + quickLinkRepository as QuickLinkRepositoryMock); + + QuickLinkRepositoryMock.stubGetDefaultQuickLinks( + quickLinkRepository as QuickLinkRepositoryMock, + toReturn: [quickLinkSample]); + + final result = await viewModel.getQuickLinks(); + + expect(result, [quickLinkSample]); + }); + }); + + group('deleteQuickLink -', () { + test('Should delete a quick link and update cache', () async { + viewModel.quickLinkList = [quickLinkSample]; + + await viewModel.deleteQuickLink(0); + + expect(viewModel.quickLinkList, isEmpty); + expect(viewModel.deletedQuickLinks, [quickLinkSample]); + }); + }); + + group('restoreQuickLink -', () { + test('Should restore a deleted quick link and update cache', () async { + viewModel.deletedQuickLinks = [quickLinkSample]; + + await viewModel.restoreQuickLink(0); + + expect(viewModel.deletedQuickLinks, isEmpty); + expect(viewModel.quickLinkList, [quickLinkSample]); + }); + }); + + group('reorderQuickLinks -', () { + test('Should reorder quick links and update cache', () async { + final anotherQuickLink = quickLinks(intl).last; + viewModel.quickLinkList = [quickLinkSample, anotherQuickLink]; + + await viewModel.reorderQuickLinks(0, 1); + + expect(viewModel.quickLinkList, [anotherQuickLink, quickLinkSample]); + }); + }); + + group('futureToRun -', () { + test('Should fetch and set quick links', () async { + QuickLinkRepositoryMock.stubGetQuickLinkDataFromCache( + quickLinkRepository as QuickLinkRepositoryMock, + toReturn: [quickLinkDataSample]); + + QuickLinkRepositoryMock.stubGetDefaultQuickLinks( + quickLinkRepository as QuickLinkRepositoryMock, + toReturn: [quickLinkSample]); + + final result = await viewModel.futureToRun(); + + expect(result, [quickLinkSample]); + }); + }); + }); +} diff --git a/test/viewmodels/web_link_card_viewmodel_test.dart b/test/viewmodels/web_link_card_viewmodel_test.dart index 990cb269a..e600d9474 100644 --- a/test/viewmodels/web_link_card_viewmodel_test.dart +++ b/test/viewmodels/web_link_card_viewmodel_test.dart @@ -35,9 +35,9 @@ void main() { WebLinkCardViewModel viewModel; final quickLink = QuickLink( - image: const Icon(Icons.ac_unit), name: 'test', link: 'testlink'); + id: 1, image: const Icon(Icons.ac_unit), name: 'test', link: 'testlink'); final securityQuickLink = QuickLink( - image: const Icon(Icons.ac_unit), name: 'test', link: 'security'); + id: 1, image: const Icon(Icons.ac_unit), name: 'test', link: 'security'); group('WebLinkCardViewModel - ', () { setUp(() async { From db09c2a0f9642ca4235d9af0c44e5e5975171200 Mon Sep 17 00:00:00 2001 From: Camille Brulotte <37625944+camillebrulotte@users.noreply.github.com> Date: Fri, 22 Sep 2023 10:37:53 -0400 Subject: [PATCH 2/2] Add FAQ page (#850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Merge master * Add new page * Finir FAQ login * Add FAQ to more page * Add faq viewmodel tests * flutter test --update-goldens * Add more page test * Clean up * [BOT] Applying version. * [BOT] Applying format. * Fix test * Remove unused * Remove unused variables * Change colors in darkteam * [BOT] Applying format. * generate goldens * [BOT] Applying format. * [BOT] Update golden files * Import * -_- * 😑 * [BOT] Applying version. --------- Co-authored-by: camillebrulotte Co-authored-by: clubapplets-server --- l10n/intl_en.arb | 5 +- l10n/intl_fr.arb | 7 +- lib/core/constants/discovery_ids.dart | 1 + lib/core/constants/faq.dart | 129 ++++++++++ lib/core/constants/router_paths.dart | 1 + lib/core/models/faq_actions.dart | 27 ++ lib/core/models/faq_questions.dart | 12 + lib/core/viewmodels/faq_viewmodel.dart | 48 ++++ lib/core/viewmodels/login_viewmodel.dart | 4 - lib/ui/router.dart | 6 + lib/ui/utils/discovery_components.dart | 23 ++ lib/ui/views/faq_view.dart | 286 ++++++++++++++++++++++ lib/ui/views/login_view.dart | 27 +- lib/ui/views/more_view.dart | 13 + pubspec.lock | 7 + pubspec.yaml | 3 +- test/ui/views/faq_view_test.dart | 85 +++++++ test/ui/views/goldenFiles/FaqView_1.png | Bin 0 -> 10594 bytes test/ui/views/goldenFiles/moreView_1.png | Bin 3429 -> 3450 bytes test/ui/views/login_view_test.dart | 51 +--- test/ui/views/more_view_test.dart | 19 +- test/viewmodels/faq_viewmodel_test.dart | 53 ++++ test/viewmodels/login_viewmodel_test.dart | 8 - 23 files changed, 732 insertions(+), 83 deletions(-) create mode 100644 lib/core/constants/faq.dart create mode 100644 lib/core/models/faq_actions.dart create mode 100644 lib/core/models/faq_questions.dart create mode 100644 lib/core/viewmodels/faq_viewmodel.dart create mode 100644 lib/ui/views/faq_view.dart create mode 100644 test/ui/views/faq_view_test.dart create mode 100644 test/ui/views/goldenFiles/FaqView_1.png create mode 100644 test/viewmodels/faq_viewmodel_test.dart diff --git a/l10n/intl_en.arb b/l10n/intl_en.arb index b1216dac3..fafd6d270 100644 --- a/l10n/intl_en.arb +++ b/l10n/intl_en.arb @@ -263,6 +263,7 @@ "discovery_navbar_more_title": "More", "discovery_navbar_more_details": "The More page gives you access to additional options of the application.", "discovery_page_more_contributors": "Here you will find the list of all the students who contributed and developed this application.", + "discovery_page_faq": "Here you will find the answers to the most frequently asked questions.", "discovery_page_more_report_bug": "The app is not perfect and we are always open to suggestions! If you see a problem or want us to be part of an idea, you can use this feature.", "discovery_page_more_settings": "Here you can change the general settings of the application.", @@ -309,7 +310,9 @@ "in_app_review_title": "Rate us!", "forgot_password": "Forgot your password?", - "need_help_contact_us": "Need help? Contact us!", + "need_help": "Need help?", + "actions": "Actions", + "questions_and_answers": "Questions and answers", "universal_code_example": "Ex: AB12345", "my_tickets": "My tickets", "no_ticket": "No ticket", diff --git a/l10n/intl_fr.arb b/l10n/intl_fr.arb index c4b8c0a4d..387817b8c 100644 --- a/l10n/intl_fr.arb +++ b/l10n/intl_fr.arb @@ -264,6 +264,7 @@ "discovery_navbar_more_details": "La page Plus vous permet d'avoir accès aux options supplémentaires de l'application.", "discovery_page_more_report_bug": "L'application n'est pas parfaite et nous sommes toujours ouverts à des suggestions! Si vous voyez un problème ou si vous souhaitez nous faire part d'une idée, vous pouvez utiliser cette fonctionnalité", "discovery_page_more_contributors": "Ici vous trouverez la liste de tous les étudiants qui ont travaillé sur le projet d'un point de vue du code.", + "discovery_page_faq": "Si vous avez des questions, vous pouvez consulter notre FAQ.", "discovery_page_more_settings": "Et finalement ici vous pouvez changer les paramètres généraux de l'application!", "discovery_page_thankyou_message": "Merci encore d'avoir installé ÉTSMobile! Nous espérons que cette application vous sera utile! Plein de nouvelles fonctionnalités vont arriver dans les mois à venir donc penser à mettre à jour l'application!!", @@ -307,9 +308,11 @@ }, "progress_bar_suffix": "jours", - "in_app_review_title": "Évaluez-nous!", + "in_app_review_title": "Évaluez-nous!", + "need_help": "Besoin d'aide?", + "questions_and_answers": "Questions et réponses", + "actions": "Actions", "forgot_password": "Mot de passe oublié?", - "need_help_contact_us": "Besoin d'aide? Contactez-nous!", "universal_code_example": "Ex: AB12345", "my_tickets": "Mes billets", "no_ticket": "Aucun billet", diff --git a/lib/core/constants/discovery_ids.dart b/lib/core/constants/discovery_ids.dart index c14e683dc..2212de49e 100644 --- a/lib/core/constants/discovery_ids.dart +++ b/lib/core/constants/discovery_ids.dart @@ -23,6 +23,7 @@ class DiscoveryIds { static const String detailsMoreBugReport = "page_more_bug_report"; static const String detailsMoreContributors = "page_more_contributors"; + static const String detailsMoreFaq = "page_more_faq"; static const String detailsMoreSettings = "page_more_settings"; static const String detailsMoreThankYou = "page_more_thank_you"; } diff --git a/lib/core/constants/faq.dart b/lib/core/constants/faq.dart new file mode 100644 index 000000000..05592e81a --- /dev/null +++ b/lib/core/constants/faq.dart @@ -0,0 +1,129 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; + +// MODELS +import 'package:notredame/core/models/faq_actions.dart'; +import 'package:notredame/core/models/faq_questions.dart'; + +// CONSTANTS +import 'package:notredame/core/constants/app_info.dart'; + +class Faq { + List questions = [ + QuestionItem( + title: { + "fr": "Quel mot de passe dois-je utiliser pour me connecter ?", + "en": "What password should I use to log in?" + }, + description: { + "fr": + "Le mot de passe à utiliser correspond à celui utilisé pour la connexion à MonÉTS et les autres systèmes informatiques de l’ÉTS.", + "en": + "The password is the one you use for logging into MonÉTS and other ÉTS computer systems." + }, + ), + QuestionItem( + title: { + "fr": "Je n’ai pas accès au cours et au programme.", + "en": "I don't have access to the courses and program." + }, + description: { + "fr": + "Les nouveaux étudiants pourraient ne pas voir l’horaire et les cours inscrits avant le début de la première session de cours. Cependant, ces informations apparaissent dès le début de la première session de cours.", + "en": + "New students may not see the schedule and courses before the start of the first course session. However, this information becomes available at the beginning of the first course session." + }, + ), + QuestionItem( + title: { + "fr": + "Je suis diplômé de l’ÉTS et je souhaite faire réactiver mon compte.", + "en": "I am an ÉTS graduate, and I want to reactivate my account." + }, + description: { + "fr": "Vous pouvez demander de réactiver votre compte", + "en": "You can request to reactivate your account." + }, + ), + QuestionItem( + title: { + "fr": "Je ne vois plus mes notes de contrôle", + "en": "I can't see my grades anymore." + }, + description: { + "fr": + "Il est possible qu’il s’agit de la période d'évaluation des cours. Vous devez compléter les évaluations sur SignETS. Les notes seront disponibles après avoir répondu aux évaluations.", + "en": + "It is possible that this is the course evaluation period. You need to complete the evaluations on SignETS. Grades will be available after responding to the evaluations." + }, + ), + ]; + + List actions = [ + ActionItem( + title: { + "fr": "Où trouver mon code universel ?", + "en": "Where can I find my universal code?" + }, + description: { + "fr": + "Le code universel se trouve dans la décision d’admission sur le portail de monÉTS.", + "en": + "The universal code can be found in the admission decision on the MonÉTS portal." + }, + type: ActionType.webview, + link: "https://portail.etsmtl.ca/home/Admission", + iconName: Icons.person, + iconColor: const Color(0xFFD5A8F8), + circleColor: const Color(0xFF6939B7), + ), + ActionItem( + title: { + "fr": + "Je suis diplômé de l’ÉTS et je souhaite faire réactiver mon compte.", + "en": "I am an ÉTS graduate, and I want to reactivate my account." + }, + description: { + "fr": "Vous pouvez demander de réactiver votre compte.", + "en": "You can request to reactivate your account." + }, + type: ActionType.webview, + link: "https://formulaires.etsmtl.ca/ReactivationCompte", + iconName: Icons.school, + iconColor: const Color(0xFF78E2BC), + circleColor: const Color(0xFF39B78A), + ), + ActionItem( + title: { + "fr": + "Questions concernant vos conditions d'admission, des inscriptions et des conditions relatives à la poursuite de vos études", + "en": + "Questions about your admission conditions, registrations, and conditions for continuing your studies" + }, + description: { + "fr": "Veuillez contacter le Bureau de la registraire.", + "en": "Please contact the Office of the Registrar." + }, + type: ActionType.email, + link: "accueilbdr@etsmtl.ca", + iconName: Icons.email, + iconColor: const Color(0xFFFCA4A4), + circleColor: const Color(0xFFDA4444), + ), + ActionItem( + title: { + "fr": "Questions concernant l’application ÉTSMobile", + "en": "Questions about the ÉTSMobile app" + }, + description: { + "fr": "Veuillez contacter App|ETS.", + "en": "Please contact App|ETS." + }, + type: ActionType.email, + link: AppInfo.email, + iconName: Icons.install_mobile, + iconColor: const Color(0xFF71D8F7), + circleColor: const Color(0xFF397DB7), + ), + ]; +} diff --git a/lib/core/constants/router_paths.dart b/lib/core/constants/router_paths.dart index 17f874fb5..f62fcf345 100644 --- a/lib/core/constants/router_paths.dart +++ b/lib/core/constants/router_paths.dart @@ -2,6 +2,7 @@ class RouterPaths { static const String startup = "/startup"; static const String login = "/login"; + static const String faq = "/faq"; static const String dashboard = "/dashboard"; static const String schedule = "/schedule"; static const String student = "/student"; diff --git a/lib/core/models/faq_actions.dart b/lib/core/models/faq_actions.dart new file mode 100644 index 000000000..c229ede28 --- /dev/null +++ b/lib/core/models/faq_actions.dart @@ -0,0 +1,27 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; + +class ActionItem { + final Map title; + final Map description; + final ActionType type; + final String link; + final IconData iconName; + final Color iconColor; + final Color circleColor; + + ActionItem({ + @required this.title, + @required this.description, + @required this.type, + @required this.link, + @required this.iconName, + @required this.iconColor, + @required this.circleColor, + }); +} + +enum ActionType { + webview, + email, +} diff --git a/lib/core/models/faq_questions.dart b/lib/core/models/faq_questions.dart new file mode 100644 index 000000000..a96bc41a3 --- /dev/null +++ b/lib/core/models/faq_questions.dart @@ -0,0 +1,12 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; + +class QuestionItem { + final Map title; + final Map description; + + QuestionItem({ + @required this.title, + @required this.description, + }); +} diff --git a/lib/core/viewmodels/faq_viewmodel.dart b/lib/core/viewmodels/faq_viewmodel.dart new file mode 100644 index 000000000..2fba133d9 --- /dev/null +++ b/lib/core/viewmodels/faq_viewmodel.dart @@ -0,0 +1,48 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; +import 'package:notredame/locator.dart'; +import 'package:stacked/stacked.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// MANAGERS +import 'package:notredame/core/managers/settings_manager.dart'; + +// SERVICES +import 'package:notredame/core/services/launch_url_service.dart'; +import 'package:notredame/core/services/analytics_service.dart'; + +// CONSTANTS +import 'package:notredame/core/constants/app_info.dart'; + +class FaqViewModel extends BaseViewModel { + final SettingsManager _settingsManager = locator(); + + final LaunchUrlService _launchUrlService = locator(); + + Locale get locale => _settingsManager.locale; + + String mailtoStr(String email, String subject) { + return 'mailto:$email?subject=$subject'; + } + + Future launchWebsite(String link, Brightness brightness) async { + await _launchUrlService.launchInBrowser(link, brightness); + } + + Future openMail(String addressEmail, BuildContext context) async { + var email = ""; + if (addressEmail == AppInfo.email) { + email = mailtoStr(addressEmail, AppIntl.of(context).email_subject); + } else { + email = mailtoStr(addressEmail, ""); + } + + final urlLaunchable = await _launchUrlService.canLaunch(email); + + if (urlLaunchable) { + await _launchUrlService.launch(email); + } else { + locator().logError("login_view", "Cannot send email."); + } + } +} diff --git a/lib/core/viewmodels/login_viewmodel.dart b/lib/core/viewmodels/login_viewmodel.dart index 09a8cb47b..0061bda40 100644 --- a/lib/core/viewmodels/login_viewmodel.dart +++ b/lib/core/viewmodels/login_viewmodel.dart @@ -90,8 +90,4 @@ class LoginViewModel extends BaseViewModel { return _appIntl.login_error_invalid_credentials; } - - String mailtoStr(String email, String subject) { - return 'mailto:$email?subject=$subject'; - } } diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 53c973372..b7739e1c0 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -13,6 +13,7 @@ import 'package:notredame/ui/views/feedback_view.dart'; // VIEWS import 'package:notredame/ui/views/login_view.dart'; +import 'package:notredame/ui/views/faq_view.dart'; import 'package:notredame/ui/views/not_found_view.dart'; import 'package:notredame/ui/views/more_view.dart'; import 'package:notredame/ui/views/outage_view.dart'; @@ -48,6 +49,11 @@ Route generateRoute(RouteSettings routeSettings) { return MaterialPageRoute( settings: RouteSettings(name: routeSettings.name), builder: (_) => LoginView()); + case RouterPaths.faq: + return MaterialPageRoute( + settings: RouteSettings(name: routeSettings.name), + builder: (_) => + FaqView(backgroundColor: routeSettings.arguments as Color)); case RouterPaths.dashboard: final code = (routeSettings.arguments as UpdateCode) ?? UpdateCode.none; return PageRouteBuilder( diff --git a/lib/ui/utils/discovery_components.dart b/lib/ui/utils/discovery_components.dart index d5655dd46..b7236cf6a 100644 --- a/lib/ui/utils/discovery_components.dart +++ b/lib/ui/utils/discovery_components.dart @@ -367,6 +367,29 @@ List discoveryComponents(BuildContext context) { ), ), ), + Discovery( + path: null, + featureId: DiscoveryIds.detailsMoreFaq, + title: AppIntl.of(context).need_help, + details: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.2), + child: Column( + children: [ + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + _buildSkipDiscoveryButton(context), + Text(AppIntl.of(context).discovery_page_faq, + textAlign: TextAlign.justify), + ], + ), + ), + ], + ), + ), + ), Discovery( path: null, featureId: DiscoveryIds.detailsMoreSettings, diff --git a/lib/ui/views/faq_view.dart b/lib/ui/views/faq_view.dart new file mode 100644 index 000000000..e088180b9 --- /dev/null +++ b/lib/ui/views/faq_view.dart @@ -0,0 +1,286 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// VIEWMODEL +import 'package:notredame/core/viewmodels/faq_viewmodel.dart'; + +// MODELS +import 'package:notredame/core/models/faq_actions.dart'; + +// CONSTANTS +import 'package:notredame/core/constants/faq.dart'; + +class FaqView extends StatefulWidget { + final Color backgroundColor; + + const FaqView({this.backgroundColor}); + + @override + State createState() => _FaqViewState(); +} + +class _FaqViewState extends State { + final Faq faq = Faq(); + + @override + Widget build(BuildContext context) => ViewModelBuilder.reactive( + viewModelBuilder: () => FaqViewModel(), + builder: (context, model, child) { + return Scaffold( + backgroundColor: widget.backgroundColor, + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + getTitle(), + getSubtitle(AppIntl.of(context).questions_and_answers), + Padding( + padding: const EdgeInsets.only(left: 15.0, right: 15.0), + child: CarouselSlider( + options: CarouselOptions( + height: 250.0, + ), + items: faq.questions.asMap().entries.map((entry) { + final int index = entry.key; + final question = faq.questions[index]; + + return Builder( + builder: (BuildContext context) { + return Container( + width: MediaQuery.of(context).size.width, + margin: const EdgeInsets.symmetric(horizontal: 5.0), + decoration: BoxDecoration( + color: Theme.of(context).brightness == + Brightness.light + ? const Color.fromARGB(255, 240, 238, 238) + : const Color.fromARGB(255, 40, 40, 40), + borderRadius: + const BorderRadius.all(Radius.circular(8.0)), + ), + child: getQuestionCard( + question.title[model.locale.languageCode], + question.description[model.locale.languageCode], + ), + ); + }, + ); + }).toList(), + ), + ), + getSubtitle(AppIntl.of(context).actions), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.only(top: 1.0), + itemCount: faq.actions.length, + itemBuilder: (context, index) { + final action = faq.actions[index]; + + return getActionCard( + action.title[model.locale.languageCode], + action.description[model.locale.languageCode], + action.type, + action.link, + action.iconName, + action.iconColor, + action.circleColor, + context, + model); + }, + ), + ) + ], + ), + ); + }, + ); + + Padding getTitle() { + return Padding( + padding: const EdgeInsets.only(top: 60.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 5.0), + child: GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Icon( + Icons.arrow_back, + color: widget.backgroundColor == Colors.white + ? Colors.black + : Colors.white, + ), + ), + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + AppIntl.of(context).need_help, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headline5.copyWith( + color: widget.backgroundColor == Colors.white + ? Colors.black + : Colors.white, + ), + ), + ), + ], + ), + ); + } + + Padding getSubtitle(String subtitle) { + return Padding( + padding: const EdgeInsets.only(left: 18.0, top: 18.0, bottom: 10.0), + child: Text( + subtitle, + style: Theme.of(context).textTheme.headline5.copyWith( + color: widget.backgroundColor == Colors.white + ? Colors.black + : Colors.white, + ), + ), + ); + } + + Padding getQuestionCard(String title, String description) { + return Padding( + padding: const EdgeInsets.only(top: 20.0, left: 20.0, right: 20.0), + child: Align( + alignment: Alignment.topLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + textScaleFactor: 1.0, + style: Theme.of(context).textTheme.bodyText2.copyWith( + fontSize: 20, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.justify, + ), + const SizedBox(height: 20.0), + Text( + description, + textScaleFactor: 1.0, + style: Theme.of(context).textTheme.bodyText2.copyWith( + fontSize: 16, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.justify, + ), + ], + ), + ), + ); + } + + Padding getActionCard( + String title, + String description, + ActionType type, + String link, + IconData iconName, + Color iconColor, + Color circleColor, + BuildContext context, + FaqViewModel model) { + return Padding( + padding: const EdgeInsets.fromLTRB(15.0, 0.0, 15.0, 15.0), + child: ElevatedButton( + onPressed: () { + if (type.name == ActionType.webview.name) { + openWebview(model, link); + } else if (type.name == ActionType.email.name) { + openMail(model, context, link); + } + }, + style: ButtonStyle( + elevation: MaterialStateProperty.all(8.0), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + )), + child: getActionCardInfo( + context, + title, + description, + iconName, + iconColor, + circleColor, + ), + ), + ); + } + + Row getActionCardInfo(BuildContext context, String title, String description, + IconData iconName, Color iconColor, Color circleColor) { + return Row( + children: [ + Column( + children: [ + CircleAvatar( + backgroundColor: circleColor, + radius: 25, + child: Icon(iconName, color: iconColor), + ), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(15, 15, 0, 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyText2.copyWith( + fontSize: 18, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10.0), + Text( + description, + style: Theme.of(context).textTheme.bodyText2.copyWith( + fontSize: 16, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.justify, + ) + ], + ), + ), + ) + ], + ); + } + + Future openWebview(FaqViewModel model, String link) async { + model.launchWebsite(link, Theme.of(context).brightness); + } + + Future openMail( + FaqViewModel model, BuildContext context, String addressEmail) async { + model.openMail(addressEmail, context); + } +} diff --git a/lib/ui/views/login_view.dart b/lib/ui/views/login_view.dart index db999169a..8135e44b6 100644 --- a/lib/ui/views/login_view.dart +++ b/lib/ui/views/login_view.dart @@ -6,7 +6,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:stacked/stacked.dart'; // SERVICE -import 'package:notredame/core/services/analytics_service.dart'; +import 'package:notredame/core/services/navigation_service.dart'; import 'package:notredame/core/services/launch_url_service.dart'; // UTILS @@ -20,8 +20,8 @@ import 'package:notredame/core/viewmodels/login_viewmodel.dart'; import 'package:notredame/ui/widgets/password_text_field.dart'; // CONSTANTS -import 'package:notredame/core/constants/app_info.dart'; import 'package:notredame/core/constants/urls.dart'; +import 'package:notredame/core/constants/router_paths.dart'; // OTHER import 'package:notredame/ui/utils/app_theme.dart'; @@ -37,6 +37,8 @@ class _LoginViewState extends State { final FocusScopeNode _focusNode = FocusScopeNode(); + final NavigationService _navigationService = locator(); + final LaunchUrlService _launchUrlService = locator(); /// Unique key of the login form form @@ -207,13 +209,18 @@ class _LoginViewState extends State { padding: const EdgeInsets.only(top: 24), child: InkWell( child: Text( - AppIntl.of(context).need_help_contact_us, + AppIntl.of(context).need_help, style: const TextStyle( decoration: TextDecoration.underline, color: Colors.white), ), onTap: () async { - sendEmail(model); + _navigationService.pushNamed( + RouterPaths.faq, + arguments: Utils.getColorByBrightness( + context, + AppTheme.etsLightRed, + AppTheme.primaryDark)); }, ), ), @@ -260,16 +267,4 @@ class _LoginViewState extends State { Color get submitTextColor => Utils.getColorByBrightness(context, AppTheme.etsLightRed, Colors.white); - - Future sendEmail(LoginViewModel model) async { - final clubEmail = - model.mailtoStr(AppInfo.email, AppIntl.of(context).email_subject); - final urlLaunchable = await _launchUrlService.canLaunch(clubEmail); - - if (urlLaunchable) { - await _launchUrlService.launch(clubEmail); - } else { - locator().logError("login_view", "Cannot send email."); - } - } } diff --git a/lib/ui/views/more_view.dart b/lib/ui/views/more_view.dart index 93c49a001..8ed7f33d3 100644 --- a/lib/ui/views/more_view.dart +++ b/lib/ui/views/more_view.dart @@ -160,6 +160,19 @@ class _MoreViewState extends State { opaque: false, )); }), + ListTile( + title: Text(AppIntl.of(context).need_help), + leading: _buildDiscoveryFeatureDescriptionWidget( + context, + getProperIconAccordingToTheme(Icons.question_answer), + DiscoveryIds.detailsMoreFaq, + model), + onTap: () { + _analyticsService.logEvent(tag, "FAQ clicked"); + model.navigationService.pushNamed(RouterPaths.faq, + arguments: Utils.getColorByBrightness( + context, Colors.white, AppTheme.primaryDark)); + }), ListTile( title: Text(AppIntl.of(context).settings_title), leading: _buildDiscoveryFeatureDescriptionWidget( diff --git a/pubspec.lock b/pubspec.lock index bcc47f26a..54a5724b1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -85,6 +85,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + carousel_slider: + dependency: "direct main" + description: + name: carousel_slider + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.1" characters: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index acc62915d..3f58d02db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ description: The 4th generation of ÉTSMobile, the main gateway between the Éco # pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 4.26.0+1 +version: 4.27.0+1 environment: sdk: ">=2.10.0 <3.0.0" @@ -71,6 +71,7 @@ dependencies: auto_size_text: ^3.0.0 easter_egg_trigger: ^1.0.1 calendar_view: ^1.0.1 + carousel_slider: ^4.2.1 reorderable_grid_view: ^2.2.6 dev_dependencies: diff --git a/test/ui/views/faq_view_test.dart b/test/ui/views/faq_view_test.dart new file mode 100644 index 000000000..057f61b6d --- /dev/null +++ b/test/ui/views/faq_view_test.dart @@ -0,0 +1,85 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// MANAGERS +import 'package:notredame/core/managers/settings_manager.dart'; +import '../../mock/managers/settings_manager_mock.dart'; + +// VIEW +import 'package:notredame/ui/views/faq_view.dart'; + +// HELPERS +import '../../helpers.dart'; + +// CONSTANTS +import 'package:notredame/core/constants/faq.dart'; + +void main() { + group('FaqView - ', () { + AppIntl appIntl; + + SettingsManager settingsManager; + + setUp(() async { + setupLaunchUrlServiceMock(); + settingsManager = setupSettingsManagerMock(); + appIntl = await setupAppIntl(); + }); + + tearDown(() {}); + + group('UI - ', () { + testWidgets('has x ElevatedButton', (WidgetTester tester) async { + SettingsManagerMock.stubLocale(settingsManager as SettingsManagerMock); + + await tester.pumpWidget(localizedWidget(child: const FaqView())); + await tester.pumpAndSettle(); + + final elevatedButton = find.byType(ElevatedButton, skipOffstage: false); + + final Faq faq = Faq(); + final numberOfButtons = faq.actions.length; + expect(elevatedButton, findsNWidgets(numberOfButtons)); + }); + + testWidgets('has 2 subtitles', (WidgetTester tester) async { + SettingsManagerMock.stubLocale(settingsManager as SettingsManagerMock); + + await tester.pumpWidget(localizedWidget(child: const FaqView())); + await tester.pumpAndSettle(); + + final subtitle1 = find.text(appIntl.actions); + expect(subtitle1, findsNWidgets(1)); + + final subtitle2 = find.text(appIntl.questions_and_answers); + expect(subtitle2, findsNWidgets(1)); + }); + + testWidgets('has 1 title', (WidgetTester tester) async { + SettingsManagerMock.stubLocale(settingsManager as SettingsManagerMock); + + await tester.pumpWidget(localizedWidget(child: const FaqView())); + await tester.pumpAndSettle(); + + final title = find.text(appIntl.need_help); + + expect(title, findsOneWidget); + }); + }); + + group("golden - ", () { + testWidgets("default view", (WidgetTester tester) async { + SettingsManagerMock.stubLocale(settingsManager as SettingsManagerMock); + tester.binding.window.physicalSizeTestValue = const Size(1800, 2410); + + await tester.pumpWidget(localizedWidget(child: const FaqView())); + await tester.pumpAndSettle(); + + await expectLater(find.byType(FaqView), + matchesGoldenFile(goldenFilePath("FaqView_1"))); + }); + }); + }); +} diff --git a/test/ui/views/goldenFiles/FaqView_1.png b/test/ui/views/goldenFiles/FaqView_1.png new file mode 100644 index 0000000000000000000000000000000000000000..e75a99a40637f71cc00b945d4c991443482ce8bb GIT binary patch literal 10594 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 F_-{S}oudE% literal 0 HcmV?d00001 diff --git a/test/ui/views/goldenFiles/moreView_1.png b/test/ui/views/goldenFiles/moreView_1.png index 09f80b04d672c9a87296e3ff71ab54b3336e599e..5f323d1f3f78ac682549280bbb2498cb1f95dc34 100644 GIT binary patch delta 2425 zcmai#eK^x=1II0SIVgENO;MAVysSCmsN`)bDpVr!x~-Co~EJSzfXvrVTU9u+7fz)OoJ!xt@QX`?~J?&-?z~-~0Rh+#|+|#@BWL zmOfrPf>KK-J|4Xk9dni~5lluJ?%x+k`&9#H`FN)mydqBB+q|aYH|DJ;t6$pn9Phpf z$DRMV2%cAMek?53FQ&E7*EqL$=ZW*-fAmle2RKDfkt^?2v}QX|_HM=77vHRE7;NHg zYrJ3EG}Dw(Rww8ra|B$g?K5ux>z{CC1S3HyA%iuyjnkp?+}lk>wl7r2=)=ZWP%^IfHHP)`Uq_GOG&^y4nc3%0S6AaLDT0iS+ zTSvR71}`UTtTNEuU=H`ZhenW^%3z_HG2{7?)j-!gru)W6p)i(bF^=!=@5hilH>SWn zy-?YgLe=tnaCNDlhUi89nL=HGGLb#$5$G{RV?1?Vc5p|c8-g)CRf0P^V_MjYifH7C z3RR0e{HG4Pqp+XiU+I?m-CfE%&S6arClA{Z+bV1*1-8{HK> zn5Y@k0P~tpwN=uB)77?J04Xz$7v;f}Oa}VeZfpM9ncP}3iJ}xthnS|RXr?lK zmEuI*Ph9JV%mf{ACwoSiLDL1EA5a^=CWC6`SM7%gdR7!DHk0()CS+JhC z!TG`I;%!Vu=Jb+1B&lI>bx4_RTY?cDmOE7IVJmtWV`^*dA>V6DDR@DTJcLl)7#dN` zdaCCU;Xt|b;zfZ{X8$p`lryHqA#B`#4sg)gUQqibWARL6XP8GcA7HS+0VH z%M&j$3^1Za5z9;Ht{6o#1I2Ca)<~KL&>dh0r#A~nj52Eo2w;gSwhh5ar*#ZrTY_6U zKhF^d!kHPumryu}{$v~RLJObfY;e#|np;b28Wm1|xvO7LsJ_s(No!?+wdvX0k;D#e zJsXC^Pu!^zpPgBPj0a>F=!GPGQxCC&sT@Q*6Mt~7{t(XkWu}wZQa`Y%fy6gC9uq?q z8Qj)MyfRQ*Utdq#lZ|XXYxZ|vy88N!mibYZp!H85P_o>{1ge-Mv;}ioc@?{#AMi-D zP$FCui`RE#L4|{9BDPefH^`1VR@NS1iIJy6oq_@6#nz*RXSQzL`mm}_FC`_V5drhH zO|Xnejf;!Jkklvz@F)jxg^tOpB;(pIjadjUo*U^7g5{zC=IdT((N)GNF;m;|!|k7#S@g=c75qjEN; zBfO(hz;l{5;5AK6y{~!f;V|AVHC^!)G6>UH12N+|OjEy$-tghOX51On?O(tz+K$G} z*PrEbxm?h_0UcmwmV4u5zdm!&E6V-Ol|ZS}%i*abY0_82 zk}8o|_66d9v^>`_%%57DKeZxq5u;x{b~q%Y2?6`D7uvJFe*8OR9#YM~Kkl`=D4BH@ z|Kpf?_psCQOHZEiP4zSh>(Rz};l;}zRMdx7(dnEsiqX-Z{Xn%X&MRP$S4KY(yo0@pp0CcT6Tg*VaD3Iwb|#Pc?C3sIfI@s zy>(sU^lWcNV`JagFoVHxRVwyRNEc*H_f;xvJJ_fdbT|ZEAOBBgYEJa_^@Z~+JXass z^*1sNsAiGfSpdSjL$G7~%>Pv~C#SEJxw2f`1>avp`oJ%GHp-jngq+7 z+|Y;!QTq7{Ly-GHAH!1DAyD+eRJXD-?#rvEI=>sfGk;1?-IBHn%EM}4HV)@IkXv(t4+I}x7*&S5>P`}NS3*_g zrjj!z8>s8tfYj>Wy*k{Lv+qYnMsZ@e{Vfrlm!Zz-8%HOc@@3JzW#Buzxb9{Or@_`Omp_2f7CeL$G1Ambt$9 z`LCzLci#=jg*0mO0dCZ(vEUi9Z>EwZD*gS*#MnFgyM*m+@;-zMws%+ToO8SJ}GUyh8^y@^g7>SWNN5YB9Z@5o2lyMse|XV z;r6eY&unsAM4w*djLb`hB@(KCd6{V0#$+h;_hdtK)@Fjm+Und&wh;CMiL34io`b>R a<;$){1cndQgh|w(;p2V4i?;K~FMk2(nds&K delta 2380 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 .having((source) => source.onPressed, 'onPressed', isNull)); }); - - testWidgets('should open emails', (WidgetTester tester) async { - const url = 'mailto:applets@ens.etsmtl.ca?subject=ÉTSMobile Problem'; - LaunchUrlServiceMock.stubCanLaunchUrl(launchUrlService, url); - LaunchUrlServiceMock.stubLaunchUrl(launchUrlService, url); - - await tester.pumpWidget(localizedWidget(child: LoginView())); - await tester.pumpAndSettle(); - - await tester - .tap(find.widgetWithText(InkWell, intl.need_help_contact_us)); - - // Rebuild the widget after the state has changed. - await tester.pump(); - - verify(launchUrlService.canLaunch(url)).called(1); - verify(launchUrlService.launch(url)).called(1); - verifyNoMoreInteractions(launchUrlService); - }); - - testWidgets('cannot launch email on this platform', - (WidgetTester tester) async { - const url = 'mailto:applets@ens.etsmtl.ca?subject=ÉTSMobile Problem'; - LaunchUrlServiceMock.stubCanLaunchUrl(launchUrlService, url, - toReturn: false); - LaunchUrlServiceMock.stubLaunchUrl(launchUrlService, url, - toReturn: false); - - await tester.pumpWidget(localizedWidget(child: LoginView())); - await tester.pumpAndSettle(); - - await tester - .tap(find.widgetWithText(InkWell, intl.need_help_contact_us)); - - // Rebuild the widget after the state has changed. - await tester.pumpAndSettle(); - - verify(launchUrlService.canLaunch(url)).called(1); - verifyNever(launchUrlService.launch(url)); - verifyNoMoreInteractions(launchUrlService); - - verify(analyticsService.logError(any, any)).called(1); - verifyNoMoreInteractions(analyticsService); - }); }); }); } diff --git a/test/ui/views/more_view_test.dart b/test/ui/views/more_view_test.dart index a4221b97c..d7cc7387f 100644 --- a/test/ui/views/more_view_test.dart +++ b/test/ui/views/more_view_test.dart @@ -48,7 +48,7 @@ void main() { }); group('UI - ', () { - testWidgets('has 1 listView and 6 listTiles', + testWidgets('has 1 listView and 8 listTiles', (WidgetTester tester) async { await tester.pumpWidget( localizedWidget(child: FeatureDiscovery(child: MoreView()))); @@ -58,7 +58,7 @@ void main() { expect(listview, findsOneWidget); final listTile = find.byType(ListTile); - expect(listTile, findsNWidgets(7)); + expect(listTile, findsNWidgets(8)); }); group('navigation - ', () { @@ -147,6 +147,21 @@ void main() { expect(find.byType(AboutDialog), findsOneWidget); }); + testWidgets('need help', (WidgetTester tester) async { + await tester.pumpWidget( + localizedWidget(child: FeatureDiscovery(child: MoreView()))); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Tap the button. + await tester.tap(find.widgetWithText(ListTile, intl.need_help)); + + // Rebuild the widget after the state has changed. + await tester.pump(); + + verify(navigation.pushNamed(RouterPaths.faq, arguments: Colors.white)) + .called(1); + }); + testWidgets('settings', (WidgetTester tester) async { await tester.pumpWidget( localizedWidget(child: FeatureDiscovery(child: MoreView()))); diff --git a/test/viewmodels/faq_viewmodel_test.dart b/test/viewmodels/faq_viewmodel_test.dart new file mode 100644 index 000000000..52519f29a --- /dev/null +++ b/test/viewmodels/faq_viewmodel_test.dart @@ -0,0 +1,53 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +// SERVICES / MANAGERS +import 'package:notredame/core/services/launch_url_service.dart'; +import 'package:notredame/core/managers/settings_manager.dart'; +import '../mock/services/launch_url_service_mock.dart'; + +// VIEW MODEL +import 'package:notredame/core/viewmodels/faq_viewmodel.dart'; + +// OTHER +import '../helpers.dart'; + +void main() { + LaunchUrlServiceMock launchUrlService; + + FaqViewModel viewModel; + + group('FaqViewModel - ', () { + setUp(() async { + launchUrlService = setupLaunchUrlServiceMock() as LaunchUrlServiceMock; + setupSettingsManagerMock(); + + viewModel = FaqViewModel(); + }); + + tearDown(() { + unregister(); + unregister(); + }); + + group('Emails - ', () { + test('Has the right mailto', () { + final str = viewModel.mailtoStr("email", "subject"); + + expect(str, "mailto:email?subject=subject"); + }); + }); + + group('Webview - ', () { + test('Calls launchInBrowser', () { + viewModel.launchWebsite("https://clubapplets.ca/", Brightness.light); + + verify(launchUrlService.launchInBrowser( + "https://clubapplets.ca/", Brightness.light)) + .called(1); + }); + }); + }); +} diff --git a/test/viewmodels/login_viewmodel_test.dart b/test/viewmodels/login_viewmodel_test.dart index aabdf381f..04a9b619d 100644 --- a/test/viewmodels/login_viewmodel_test.dart +++ b/test/viewmodels/login_viewmodel_test.dart @@ -136,13 +136,5 @@ void main() { expect(viewModel.password, ""); }); }); - - group('Emails - ', () { - test('Has the right mailto', () { - final str = viewModel.mailtoStr("email", "subject"); - - expect(str, "mailto:email?subject=subject"); - }); - }); }); }