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] 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 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 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 {