diff --git a/.github/workflows/release-workflow.yaml b/.github/workflows/release-workflow.yaml index 08d0bc9da..8cdfa79a6 100644 --- a/.github/workflows/release-workflow.yaml +++ b/.github/workflows/release-workflow.yaml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2 with: - flutter-version: '3.3.x' + flutter-version: '3.x' channel: 'stable' cache: true - name: Setup Fastlane diff --git a/l10n/intl_en.arb b/l10n/intl_en.arb index bda1e7db6..0f93dbc66 100644 --- a/l10n/intl_en.arb +++ b/l10n/intl_en.arb @@ -170,7 +170,11 @@ "ets_gus": "GUS", "ets_papercut_title":"PaperCut", - "news_title" : "Announcement", + "news_title" : "News", + "news_error_not_found_title" : "Oh oh!", + "news_error_not_found" : "Something went wrong while trying to retrieve the news. Please try again later.", + "news_no_more_card_title": "You're all set!", + "news_no_more_card": "You have reached the end of the news list. Come back another time for more news!", "news_details_title" : "News", "news_event_date": "Event date", diff --git a/l10n/intl_fr.arb b/l10n/intl_fr.arb index 7b39ef5be..ce8279143 100644 --- a/l10n/intl_fr.arb +++ b/l10n/intl_fr.arb @@ -173,6 +173,10 @@ "news_title" : "Annonces", "news_details_title" : "Annonce", "news_event_date": "Date de l'événement", + "news_error_not_found_title" : "Oh oh!", + "news_error_not_found" : "Une erreur est survenue lors de la récupération des annonces. Veuillez réessayer plus tard.", + "news_no_more_card_title": "Tu es à jour!", + "news_no_more_card": "Vous avez atteint la fin des annonces. Revenez plus tard pour voir les prochaines annonces!", "report_news": "Pourquoi reportez-vous cette annonce ?", "report_as": "Signaler comme", diff --git a/lib/core/constants/quick_links.dart b/lib/core/constants/quick_links.dart index b14e4e108..c7d8612cb 100644 --- a/lib/core/constants/quick_links.dart +++ b/lib/core/constants/quick_links.dart @@ -82,8 +82,7 @@ List quickLinks(AppIntl intl) => [ id: 8, name: intl.ets_gus, image: SvgPicture.asset('assets/images/ic_gus_red.svg', - colorFilter: const ColorFilter.mode( - AppTheme.etsLightRed, BlendMode.srcIn)), + color: AppTheme.etsLightRed), link: 'https://gus.etsmtl.ca/c2atom/mobile/login'), QuickLink( id: 9, diff --git a/lib/core/managers/news_repository.dart b/lib/core/managers/news_repository.dart index 85e4c920e..52576da1a 100644 --- a/lib/core/managers/news_repository.dart +++ b/lib/core/managers/news_repository.dart @@ -1,155 +1,25 @@ -// Dart imports: -import 'dart:convert'; - // Flutter imports: -import 'package:flutter/material.dart'; +import 'package:ets_api_clients/clients.dart'; // Package imports: -import 'package:logger/logger.dart'; +import 'package:ets_api_clients/models.dart'; // Project imports: import 'package:notredame/core/managers/cache_manager.dart'; -import 'package:notredame/core/models/news.dart'; -import 'package:notredame/core/services/networking_service.dart'; -import 'package:notredame/core/utils/cache_exception.dart'; import 'package:notredame/locator.dart'; /// Repository to access all the news class NewsRepository { static const String tag = "NewsRepository"; - @visibleForTesting - static const String newsCacheKey = "newsCache"; - - final Logger _logger = locator(); - - /// Cache manager to access and update the cache. - final CacheManager _cacheManager = locator(); - - /// Used to verify if the user has connectivity - final NetworkingService _networkingService = locator(); - - /// List of the news with 3 test news. - List? _news = [ - News( - id: 1, - title: "Merci à McGill Robotics pour l’invitation au RoboHacks 2023!", - description: - "Le club scientifique qui conceptualise un robot de recherche et secourisme recrute pour ses nouveaux projets! Une rencontre d’information est prévue le mercredi 13 octobre 2021 à 17h au local D-5023.Viens nous rencontrer pour en savoir plus sur notre prochaine mission de secourisme et faire partie de l’équipe!Le club scientifique qui conceptualise un robot de recherche et secourisme recrute pour ses nouveaux projets! Une rencontre d’information est prévue le mercredi 13 octobre 2021 à 17h au local D-5023.Viens nous rencontrer pour en savoir plus sur notre prochaine mission de secourisme et faire partie de l’équipe!Le club scientifique qui conceptualise un robot de recherche et secourisme recrute pour ses nouveaux projets! Une rencontre d’information est prévue le mercredi 13 octobre 2021 à 17h au local D-5023.Viens nous rencontrer pour en savoir plus sur notre prochaine mission de secourisme et faire partie de l’équipe!Le club scientifique qui conceptualise un robot de recherche et secourisme recrute pour ses nouveaux projets! Une rencontre d’information est prévue le mercredi 13 octobre 2021 à 17h au local D-5023.Viens nous rencontrer pour en savoir plus sur notre prochaine mission de secourisme et faire partie de l’équipe!", - author: "Capra", - avatar: "https://picsum.photos/200/200", - activity: "Club scientifique", - publishedDate: DateTime.now(), - eventStartDate: DateTime.now().add(const Duration(days: 2)), - eventEndDate: DateTime.now().add(const Duration(days: 5)), - image: "https://picsum.photos/400/200", - tags: [ - "Robotique", - "Programmation", - "Intelligence artificielle", - "Compétition", - ], - ), - News( - id: 2, - title: "Compétition de développement mobile", - description: - "AMC est une compétition de développement mobile organisée par ApplETS, un club étudiant de l'ÉTS. La compétition à lieu du 27 au 28 janvier 2024. Que vous soyez un étudiant universitaire ou collégial, novice ou expérimenté en développement, cette compétition est l'occasion idéale de repousser vos limites, d'apprendre des autres et de montrer votre talent dans le monde de la technologie mobile.", - author: "App|ETS", - avatar: "https://picsum.photos/200/200", - activity: "Club scientifique", - publishedDate: DateTime.now(), - eventStartDate: DateTime.now().add(const Duration(days: 10)), - eventEndDate: DateTime.now().add(const Duration(days: 11)), - image: "https://picsum.photos/400/200", - tags: [ - "Compétition", - "Développement mobile", - ], - ), - News( - id: 3, - title: "Test 3", - description: "Test 3 description", - author: "Jean-Guy Tremblay", - avatar: "https://picsum.photos/200/200", - activity: "Service à la vie étudiante", - publishedDate: DateTime.now(), - eventStartDate: DateTime.now().add(const Duration(days: 3)), - image: "https://picsum.photos/400/200", - tags: ["5 @ 7", "Vie étudiante", "Activité"], - ), - ]; - - List? get news => _news; + final HelloAPIClient _helloApiClient = locator(); /// Get and update the list of news. /// After fetching the news from the [?] the [CacheManager] /// is updated with the latest version of the news. - Future?> getNews({bool fromCacheOnly = false}) async { - // Force fromCacheOnly mode when user has no connectivity - if (!(await _networkingService.hasConnectivity())) { - // ignore: parameter_assignments - fromCacheOnly = true; - } - - // Load the news from the cache if the list doesn't exist - if (_news == null) { - await getNewsFromCache(); - } - - if (fromCacheOnly) { - return _news; - } - - final List fetchedNews = fetchNewsFromAPI(); - - _news ??= []; - - // Update the list of news to avoid duplicate news - for (final News news in fetchedNews) { - if (_news?.contains(news) ?? false) { - _news?.add(news); - } - } - - try { - // Update cache - _cacheManager.update(newsCacheKey, jsonEncode(_news)); - } on CacheException catch (_) { - // Do nothing, the caching will retry later and the error has been logged by the [CacheManager] - _logger.e( - "$tag - getNews: exception raised will trying to update the cache."); - } - - return _news; - } - - Future getNewsFromCache() async { - _news = []; - try { - final List responseCache = - jsonDecode(await _cacheManager.get(newsCacheKey)) as List; - - // Build list of news loaded from the cache. - _news = responseCache - .map((e) => News.fromJson(e as Map)) - .toList(); - - _logger.d( - "$tag - getNewsFromCache: ${_news?.length} news loaded from cache"); - } on CacheException catch (_) { - _logger.e( - "$tag - getNewsFromCache: exception raised will trying to load news from cache."); - } - } - - // TODO : Fetch news from the API - List fetchNewsFromAPI() { - final List fetchedNews = []; - - _logger.d("$tag - getNews: fetched ${fetchedNews.length} news."); - - return fetchedNews; + Future getNews({int pageNumber = 1, int pageSize = 3}) async { + final PaginatedNews pagination = await _helloApiClient.getEvents( + pageNumber: pageNumber, pageSize: pageSize); + return pagination; } } diff --git a/lib/core/models/news.dart b/lib/core/models/news.dart deleted file mode 100644 index 1bca74a55..000000000 --- a/lib/core/models/news.dart +++ /dev/null @@ -1,59 +0,0 @@ -class News { - final int id; - final String title; - final String description; - final String author; - final String avatar; - final String activity; - final String image; - final List tags; - final DateTime publishedDate; - final DateTime eventStartDate; - final DateTime? eventEndDate; - - News( - {required this.id, - required this.title, - required this.description, - required this.author, - required this.avatar, - required this.activity, - required this.image, - required this.tags, - required this.publishedDate, - required this.eventStartDate, - this.eventEndDate}); - - /// Used to create [News] instance from a JSON file - factory News.fromJson(Map map) { - return News( - id: map['id'] as int, - title: map['title'] as String, - description: map['description'] as String, - author: map['author'] as String, - avatar: map['avatar'] as String, - activity: map['activity'] as String, - image: map['image'] as String, - tags: map['tags'] as List, - publishedDate: DateTime.parse(map['publishedDate'] as String), - eventStartDate: DateTime.parse(map['eventStartDate'] as String), - eventEndDate: DateTime.parse(map['eventEndDate'] as String), - ); - } - - Map toJson() { - return { - 'id': id, - 'title': title, - 'description': description, - 'author': author, - 'avatar': avatar, - 'activity': activity, - 'image': image, - 'tags': tags.toList(), - 'publishedDate': publishedDate.toString(), - 'eventStartDate': eventStartDate.toString(), - 'eventEndDate': eventEndDate.toString(), - }; - } -} diff --git a/lib/core/services/launch_url_service.dart b/lib/core/services/launch_url_service.dart index 1cc9a717b..a7b7457b6 100644 --- a/lib/core/services/launch_url_service.dart +++ b/lib/core/services/launch_url_service.dart @@ -10,12 +10,6 @@ import 'package:notredame/core/managers/settings_manager.dart'; import 'package:notredame/locator.dart'; import 'package:notredame/ui/utils/app_theme.dart'; -// Managers - -// UTILS - -// OTHER - class LaunchUrlService { final SettingsManager settingsManager = locator(); diff --git a/lib/core/viewmodels/news_details_viewmodel.dart b/lib/core/viewmodels/news_details_viewmodel.dart index c32dd38dc..5acfc1394 100644 --- a/lib/core/viewmodels/news_details_viewmodel.dart +++ b/lib/core/viewmodels/news_details_viewmodel.dart @@ -2,10 +2,10 @@ import 'dart:ui'; // Package imports: +import 'package:ets_api_clients/models.dart'; import 'package:stacked/stacked.dart'; // Project imports: -import 'package:notredame/core/models/news.dart'; import 'package:notredame/ui/utils/app_theme.dart'; class NewsDetailsViewModel extends FutureViewModel { diff --git a/lib/core/viewmodels/news_viewmodel.dart b/lib/core/viewmodels/news_viewmodel.dart index 30544e7be..a822e666c 100644 --- a/lib/core/viewmodels/news_viewmodel.dart +++ b/lib/core/viewmodels/news_viewmodel.dart @@ -1,65 +1,41 @@ // Package imports: -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:fluttertoast/fluttertoast.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:stacked/stacked.dart'; +import 'package:ets_api_clients/models.dart'; // Project imports: import 'package:notredame/core/managers/news_repository.dart'; -import 'package:notredame/core/models/news.dart'; import 'package:notredame/locator.dart'; -class NewsViewModel extends FutureViewModel> { +class NewsViewModel extends BaseViewModel implements Initialisable { /// Load the events final NewsRepository _newsRepository = locator(); - /// Localization class of the application. - final AppIntl _appIntl; - - /// News list - List? _news = []; - - /// Return the list of all the news. - List get news { - _news = []; - - // Build the list of news - _news?.addAll(_newsRepository.news ?? []); - - return _news ?? []; - } - - NewsViewModel({required AppIntl intl}) : _appIntl = intl; + final PagingController pagingController = + PagingController(firstPageKey: 1); bool isLoadingEvents = false; @override - Future> futureToRun() async { - try { - setBusyForObject(isLoadingEvents, true); - _news = await _newsRepository.getNews(fromCacheOnly: true); - notifyListeners(); - } catch (e) { - onError(e); - } finally { - setBusyForObject(isLoadingEvents, false); - } - - return news; - } - - @override - // ignore: type_annotate_public_apis - void onError(error) { - Fluttertoast.showToast(msg: _appIntl.error); + void initialise() { + // This will be called when init state cycle runs + pagingController.addPageRequestListener((pageKey) { + fetchPage(pageKey); + }); } - Future refresh() async { + Future fetchPage(int pageNumber) async { try { - setBusyForObject(isLoadingEvents, true); - _newsRepository.getNews(); - notifyListeners(); - } on Exception catch (error) { - onError(error); + final pagination = await _newsRepository.getNews(pageNumber: pageNumber); + final isLastPage = pagination?.totalPages == pageNumber; + if (isLastPage) { + pagingController.appendLastPage(pagination?.news ?? []); + } else { + final nextPageKey = pageNumber + 1; + pagingController.appendPage(pagination?.news ?? [], nextPageKey); + } + } catch (error) { + pagingController.error = error; } } } diff --git a/lib/locator.dart b/lib/locator.dart index 3f2448107..0776db27d 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -53,5 +53,6 @@ void setupLocator() { // Other locator.registerLazySingleton(() => SignetsAPIClient()); locator.registerLazySingleton(() => MonETSAPIClient()); + locator.registerLazySingleton(() => HelloAPIClient()); locator.registerLazySingleton(() => Logger()); } diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 1372a47ec..550f90d53 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -29,7 +29,6 @@ import 'package:notredame/ui/views/settings_view.dart'; import 'package:notredame/ui/views/startup_view.dart'; import 'package:notredame/ui/views/student_view.dart'; import 'package:notredame/ui/widgets/link_web_view.dart'; -import 'package:notredame/core/models/news.dart'; Route generateRoute(RouteSettings routeSettings) { switch (routeSettings.name) { diff --git a/lib/ui/views/news_details_view.dart b/lib/ui/views/news_details_view.dart index 94022c437..ba8f679e2 100644 --- a/lib/ui/views/news_details_view.dart +++ b/lib/ui/views/news_details_view.dart @@ -1,4 +1,5 @@ // Flutter imports: +import 'package:ets_api_clients/models.dart'; import 'package:flutter/material.dart'; // Package imports: @@ -8,7 +9,6 @@ import 'package:stacked/stacked.dart'; // Project imports: import 'package:notredame/core/utils/utils.dart'; -import 'package:notredame/core/models/news.dart'; import 'package:notredame/core/services/analytics_service.dart'; import 'package:notredame/core/viewmodels/news_details_viewmodel.dart'; import 'package:notredame/locator.dart'; @@ -95,13 +95,15 @@ class _NewsDetailsViewState extends State { _buildTitle(widget.news.title), _buildDate( context, - widget.news.publishedDate, + widget.news.publicationDate, widget.news.eventStartDate, widget.news.eventEndDate), - _buildImage(widget.news.image), - _buildAuthor(widget.news.avatar, widget.news.author, - widget.news.activity), - _buildContent(widget.news.description), + _buildImage(widget.news.imageUrl!), + _buildAuthor( + "https://cdn-icons-png.flaticon.com/512/147/147142.png", + widget.news.organizer.organisation!, + widget.news.organizer.activityArea!), + _buildContent(widget.news.content), ], ), ), @@ -132,7 +134,7 @@ class _NewsDetailsViewState extends State { padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0), child: Text( title, - style: Theme.of(context).textTheme.bodyText1!.copyWith( + style: Theme.of(context).textTheme.bodySmall!.copyWith( color: Utils.getColorByBrightness(context, Colors.black, Colors.white), fontSize: 25, @@ -154,8 +156,8 @@ class _NewsDetailsViewState extends State { Widget _buildAuthor(String avatar, String author, String activity) { return ColoredBox( - color: Utils.getColorByBrightness( - context, AppTheme.etsLightRed, Theme.of(context).bottomAppBarColor), + color: Utils.getColorByBrightness(context, AppTheme.etsLightRed, + Theme.of(context).bottomAppBarTheme.color ?? AppTheme.etsLightRed), child: ListTile( leading: ClipOval( child: avatar == "" @@ -292,11 +294,11 @@ class _NewsDetailsViewState extends State { (index) => Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: model.getTagColor(widget.news.tags[index]), + color: model.getTagColor(widget.news.tags[index].name), borderRadius: BorderRadius.circular(8), ), child: Text( - widget.news.tags[index], + widget.news.tags[index].name, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, diff --git a/lib/ui/views/news_view.dart b/lib/ui/views/news_view.dart index a04297aea..173c11724 100644 --- a/lib/ui/views/news_view.dart +++ b/lib/ui/views/news_view.dart @@ -1,9 +1,11 @@ // Flutter imports: +import 'package:ets_api_clients/models.dart'; import 'package:flutter/material.dart'; // Package imports: import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:stacked/stacked.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; // Project imports: import 'package:notredame/core/viewmodels/news_viewmodel.dart'; @@ -16,7 +18,7 @@ class NewsView extends StatefulWidget { } class _NewsViewState extends State { - int nbSkeletons = 3; + static const int _nbSkeletons = 3; @override void initState() { @@ -26,27 +28,150 @@ class _NewsViewState extends State { @override Widget build(BuildContext context) => ViewModelBuilder.reactive( - viewModelBuilder: () => NewsViewModel(intl: AppIntl.of(context)!), + viewModelBuilder: () => NewsViewModel(), + onModelReady: (model) { + model.pagingController.addStatusListener((status) { + if (status == PagingStatus.subsequentPageError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppIntl.of(context)!.news_error_not_found, + ), + action: SnackBarAction( + label: AppIntl.of(context)!.retry, + onPressed: () => + model.pagingController.retryLastFailedRequest(), + ), + ), + ); + } + }); + }, builder: (context, model, child) { return RefreshIndicator( - onRefresh: model.refresh, + onRefresh: () => Future.sync( + () => model.pagingController.refresh(), + ), child: Theme( data: Theme.of(context) .copyWith(canvasColor: Colors.transparent), - child: model.isLoadingEvents - ? _buildSkeletonLoader() - : ListView( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), - children: - model.news.map((news) => NewsCard(news)).toList(), - ), + child: PagedListView( + key: const Key("pagedListView"), + pagingController: model.pagingController, + padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => NewsCard(item), + firstPageProgressIndicatorBuilder: (context) => + _buildSkeletonLoader(), + newPageProgressIndicatorBuilder: (context) => + NewsCardSkeleton(), + noMoreItemsIndicatorBuilder: (context) => + _buildNoMoreNewsCard(), + firstPageErrorIndicatorBuilder: (context) => + _buildError(model.pagingController), + ), + ), )); }); Widget _buildSkeletonLoader() { - return ListView.builder( - itemCount: nbSkeletons, - itemBuilder: (context, index) => NewsCardSkeleton(), + final Widget skeleton = NewsCardSkeleton(); + return Column(children: [ + for (var i = 0; i < _nbSkeletons; i++) skeleton, + ]); + } + + Widget _buildNoMoreNewsCard() { + return Column( + children: [ + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Divider(), + ), + const SizedBox(height: 16), + Card( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(0, 8, 8, 8), + child: Row( + children: [ + const Icon(Icons.check, color: Colors.blue, size: 40), + const SizedBox(width: 16), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppIntl.of(context)!.news_no_more_card_title, + style: const TextStyle(fontSize: 24)), + const SizedBox(height: 16), + Text( + AppIntl.of(context)!.news_no_more_card, + textAlign: TextAlign.justify, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildError(PagingController pagingController) { + return Scaffold( + body: SafeArea( + minimum: const EdgeInsets.all(20), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + bottom: 80, + ), + child: Text( + AppIntl.of(context)!.news_error_not_found_title, + style: const TextStyle( + fontSize: 40, fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.only( + bottom: 70, + ), + child: Text( + AppIntl.of(context)!.news_error_not_found, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + ), + ), + ), + Flexible( + child: ElevatedButton( + onPressed: () { + pagingController.retryLastFailedRequest(); + }, + child: Text(AppIntl.of(context)!.retry), + ), + ), + ], + ), + ), + ), ); } } diff --git a/lib/ui/views/quick_links_view.dart b/lib/ui/views/quick_links_view.dart index 227b34120..ebdee57ef 100644 --- a/lib/ui/views/quick_links_view.dart +++ b/lib/ui/views/quick_links_view.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; // Package imports: import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:notredame/ui/widgets/base_scaffold.dart'; import 'package:reorderable_grid_view/reorderable_grid_view.dart'; import 'package:stacked/stacked.dart'; @@ -43,20 +42,11 @@ class _QuickLinksViewState extends State Widget build(BuildContext context) => ViewModelBuilder.reactive( viewModelBuilder: () => QuickLinksViewModel(AppIntl.of(context)!), - builder: (context, model, child) => BaseScaffold( - isLoading: model.isBusy, + builder: (context, model, child) => Scaffold( 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: () { diff --git a/lib/ui/views/schedule_view.dart b/lib/ui/views/schedule_view.dart index 5cfa60084..f4990d8c5 100644 --- a/lib/ui/views/schedule_view.dart +++ b/lib/ui/views/schedule_view.dart @@ -86,14 +86,12 @@ class _ScheduleViewState extends State actions: model.busy(model.settings) ? [] : _buildActionButtons(model), ), - body: model.busy(model.settings) - ? const SizedBox() - : RefreshIndicator( - child: !model.calendarViewSetting - ? _buildCalendarView(model, context) - : _buildListView(model, context), - onRefresh: () => model.refresh(), - )), + body: RefreshIndicator( + child: !model.calendarViewSetting + ? _buildCalendarView(model, context) + : _buildListView(model, context), + onRefresh: () => model.refresh(), + )), ); Widget _buildListView(ScheduleViewModel model, BuildContext context) { @@ -473,7 +471,7 @@ class _ScheduleViewState extends State } List _buildActionButtons(ScheduleViewModel model) => [ - if ((model.settings[PreferencesFlag.scheduleShowTodayBtn] as bool) == + if ((model.settings[PreferencesFlag.scheduleShowTodayBtn] as bool?) == true) IconButton( icon: const Icon(Icons.today), diff --git a/lib/ui/widgets/news_card.dart b/lib/ui/widgets/news_card.dart index d9c3c2339..27abc227b 100644 --- a/lib/ui/widgets/news_card.dart +++ b/lib/ui/widgets/news_card.dart @@ -5,10 +5,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:shimmer/shimmer.dart'; import 'package:timeago/timeago.dart' as timeago; +import 'package:ets_api_clients/models.dart'; // Project imports: import 'package:notredame/core/constants/router_paths.dart'; -import 'package:notredame/core/models/news.dart'; import 'package:notredame/core/services/navigation_service.dart'; import 'package:notredame/locator.dart'; import 'package:notredame/ui/utils/app_theme.dart'; @@ -23,7 +23,6 @@ class NewsCard extends StatefulWidget { } class _NewsCardState extends State { - bool _isImageLoaded = false; final NavigationService _navigationService = locator(); @override @@ -44,7 +43,7 @@ class _NewsCardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildImage(widget.news.image), + _buildImage(widget.news.imageUrl), const SizedBox(height: 8), _buildTitleAndTime(widget.news, context), ], @@ -56,16 +55,26 @@ class _NewsCardState extends State { ); } - Widget _buildImage(String image) { - if (image == "") { - return const SizedBox(); + Widget _buildImage(String? imageUrl) { + if (imageUrl != null && imageUrl != "") { + return ClipRRect( + borderRadius: BorderRadius.circular(16.0), + child: Image.network( + imageUrl == "" + ? "https://www.shutterstock.com/image-vector/no-photo-thumbnail-graphic-element-600nw-2311073121.jpg" + : imageUrl, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + return child; + } else { + return _shimmerEffect(); + } + }, + ), + ); } - return ClipRRect( - borderRadius: BorderRadius.circular(16.0), - child: _isImageLoaded - ? Image.network(image, fit: BoxFit.cover) - : _shimmerEffect(), - ); + + return const SizedBox(); } Widget _shimmerEffect() { @@ -102,38 +111,11 @@ class _NewsCardState extends State { ), const SizedBox(width: 10), Text( - timeago.format(news.publishedDate, + timeago.format(news.publicationDate, locale: AppIntl.of(context)!.localeName), style: textStyle, ), ], ); } - - @override - void initState() { - super.initState(); - _preloadImage(); - } - - void _preloadImage() { - Image.network(widget.news.image) - .image - // ignore: use_named_constants - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener( - (ImageInfo image, bool synchronousCall) { - if (mounted) { - setState(() { - _isImageLoaded = true; - }); - } - }, - onError: (exception, stackTrace) { - // Handle image load error - }, - ), - ); - } } diff --git a/pubspec.lock b/pubspec.lock index c1b19cb32..57b160a85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -285,11 +285,11 @@ packages: dependency: "direct main" description: path: "." - ref: "1.0.0" - resolved-ref: "3171eef062b1876a286a9224aff37db5316247f7" + ref: "1.1.2" + resolved-ref: "1f7906d5d2c69c8c0d3dadc223a45317038e1902" url: "https://github.com/ApplETS/ETS-API-Clients.git" source: git - version: "1.0.0" + version: "1.1.2" fading_edge_scrollview: dependency: transitive description: @@ -727,7 +727,7 @@ packages: source: hosted version: "0.15.4" http: - dependency: "direct main" + dependency: transitive description: name: http sha256: a2bbf9d017fcced29139daa8ed2bba4ece450ab222871df93ca9eec6f80c34ba @@ -782,6 +782,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.5" + infinite_scroll_pagination: + dependency: "direct main" + description: + name: infinite_scroll_pagination + sha256: "9517328f4e373f08f57dbb11c5aac5b05554142024d6b60c903f3b73476d52db" + url: "https://pub.dev" + source: hosted + version: "3.2.0" intl: dependency: transitive description: @@ -1227,6 +1235,14 @@ packages: description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" source_gen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4d752c327..ab9026d2a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,13 +42,12 @@ dependencies: # Customs ets_api_clients: - # path: ../ETS-API-Clients/ + # path: ../ETS-API-Clients/ git: url: https://github.com/ApplETS/ETS-API-Clients.git - ref: 1.0.0 + ref: 1.1.2 # Other - http: ^1.2.0 flutter_cache_manager: ^3.0.1 flutter_secure_storage: ^9.0.0 shared_preferences: ^2.2.2 @@ -77,6 +76,7 @@ dependencies: import_sorter: ^4.6.0 flutter_keychain: ^2.4.0 shimmer: ^3.0.0 + infinite_scroll_pagination: ^3.2.0 dev_dependencies: flutter_test: diff --git a/test/managers/news_repository_test.dart b/test/managers/news_repository_test.dart deleted file mode 100644 index c749a2c1a..000000000 --- a/test/managers/news_repository_test.dart +++ /dev/null @@ -1,55 +0,0 @@ -// Package imports: -import 'package:flutter_test/flutter_test.dart'; - -// Project imports: -import 'package:notredame/core/managers/news_repository.dart'; -import 'package:notredame/core/models/news.dart'; -import '../helpers.dart'; - -void main() { - group('NewsRepository tests', () { - late NewsRepository repository; - - setUp(() { - setupLogger(); - setupAnalyticsServiceMock(); - setupCacheManagerMock(); - setupNetworkingServiceMock(); - repository = NewsRepository(); - }); - - test('Fetching news updates the news list', () async { - // TODO : remove when the news will be empty by default without test news - //expect(repository.news, isEmpty); - - final List? fetchedNews = - await repository.getNews(fromCacheOnly: true); - - expect(repository.news, isNotEmpty); - expect(repository.news, equals(fetchedNews)); - }); - - test('Fetching news from cache returns the correct data', () async { - // TODO : remove when the news will be empty by default without test news - //expect(repository.news, isEmpty); - - await repository.getNews(fromCacheOnly: true); - - final List? newsFromCache = - await repository.getNews(fromCacheOnly: true); - - expect(newsFromCache, isNotEmpty); - expect(newsFromCache, equals(repository.news)); - }); - - test('Fetching news from API updates the news list', () async { - // TODO : remove when the news will be empty by default without test news - //expect(repository.news, isEmpty); - - final List? fetchedNews = await repository.getNews(); - - expect(repository.news, isNotEmpty); - expect(repository.news, equals(fetchedNews)); - }); - }); -} diff --git a/test/mock/managers/news_repository_mock.dart b/test/mock/managers/news_repository_mock.dart index 9cb4e7a60..1e65059d3 100644 --- a/test/mock/managers/news_repository_mock.dart +++ b/test/mock/managers/news_repository_mock.dart @@ -1,34 +1,29 @@ // Package imports: import 'package:ets_api_clients/exceptions.dart'; +import 'package:ets_api_clients/models.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; // Project imports: import 'package:notredame/core/managers/news_repository.dart'; -import 'package:notredame/core/models/news.dart'; import 'news_repository_mock.mocks.dart'; @GenerateNiceMocks([MockSpec()]) class NewsRepositoryMock extends MockNewsRepository { - /// Stub the getter [news] of [mock] when called will return [toReturn]. - static void stubNews(NewsRepositoryMock mock, - {List toReturn = const []}) { - when(mock.news).thenReturn(toReturn); - } - /// Stub the function [getNews] of [mock] when called will return [toReturn]. static void stubGetNews(NewsRepositoryMock mock, - {List toReturn = const [], bool fromCacheOnly = false}) { - when(mock.getNews(fromCacheOnly: fromCacheOnly)) + {PaginatedNews? toReturn, int pageNumber = 1, int pageSize = 3}) { + when(mock.getNews(pageNumber: pageNumber, pageSize: pageSize)) .thenAnswer((_) async => toReturn); } /// Stub the function [getNews] of [mock] when called will throw [toThrow]. static void stubGetNewsException(NewsRepositoryMock mock, {Exception toThrow = const ApiException(prefix: 'ApiException'), - bool fromCacheOnly = false}) { - when(mock.getNews(fromCacheOnly: fromCacheOnly)).thenAnswer((_) => - Future.delayed(const Duration(milliseconds: 50)) + int pageNumber = 1, + int pageSize = 3}) { + when(mock.getNews(pageNumber: pageNumber, pageSize: pageSize)).thenAnswer( + (_) => Future.delayed(const Duration(milliseconds: 50)) .then((value) => throw toThrow)); } } diff --git a/test/models/news_test.dart b/test/models/news_test.dart deleted file mode 100644 index 6ba719321..000000000 --- a/test/models/news_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -// ignore_for_file: avoid_dynamic_calls - -// Package imports: -import 'package:flutter_test/flutter_test.dart'; - -// Project imports: -import 'package:notredame/core/models/news.dart'; - -void main() { - group('News class tests', () { - test('News.fromJson() should parse JSON correctly', () { - final json = { - 'id': 1, - 'title': 'Test Title', - 'description': 'Test Description', - 'author': 'Author', - 'avatar': 'https://example.com/image.jpg', - 'activity': 'Club scientifique', - 'image': 'https://example.com/image.jpg', - 'tags': ['Tag 1', 'Tag 2'], - 'publishedDate': '2022-01-01T12:00:00Z', - 'eventStartDate': '2022-01-01T12:00:00Z', - 'eventEndDate': '2022-01-01T12:00:00Z', - }; - - final news = News.fromJson(json); - expect(news.id, equals(1)); - expect(news.title, equals('Test Title')); - expect(news.description, equals('Test Description')); - expect(news.author, equals('Author')); - expect(news.avatar, equals('https://example.com/image.jpg')); - expect(news.activity, equals('Club scientifique')); - expect(news.image, equals('https://example.com/image.jpg')); - expect(news.tags.length, equals(2)); - expect(news.tags[0], equals('Tag 1')); - expect(news.tags[1], equals('Tag 2')); - expect( - news.publishedDate, equals(DateTime.parse('2022-01-01T12:00:00Z'))); - expect( - news.eventStartDate, equals(DateTime.parse('2022-01-01T12:00:00Z'))); - expect(news.eventEndDate, equals(DateTime.parse('2022-01-01T12:00:00Z'))); - }); - - test('toJson() should convert News to JSON correctly', () { - final news = News( - id: 1, - title: 'Test Title', - description: 'Test Description', - author: 'Author', - avatar: 'https://example.com/image.jpg', - activity: 'Club scientifique', - image: 'https://example.com/image.jpg', - tags: ['Tag 1', 'Tag 2'], - publishedDate: DateTime.parse('2022-01-01T12:00:00.000Z'), - eventStartDate: DateTime.parse('2022-01-01T12:00:00.000Z'), - eventEndDate: DateTime.parse('2022-01-01T12:00:00.000Z'), - ); - - final json = news.toJson(); - - expect(json['id'], equals(1)); - expect(json['title'], equals('Test Title')); - expect(json['description'], equals('Test Description')); - expect(json['author'], equals('Author')); - expect(json['avatar'], equals('https://example.com/image.jpg')); - expect(json['activity'], equals('Club scientifique')); - expect(json['image'], equals('https://example.com/image.jpg')); - expect(json['tags'], hasLength(2)); - expect(json['tags'][0], equals('Tag 1')); - expect(json['tags'][1], equals('Tag 2')); - expect(json['publishedDate'], equals('2022-01-01 12:00:00.000Z')); - expect(json['eventStartDate'], equals('2022-01-01 12:00:00.000Z')); - expect(json['eventEndDate'], equals('2022-01-01 12:00:00.000Z')); - }); - }); -} diff --git a/test/ui/views/ets_view_test.dart b/test/ui/views/ets_view_test.dart index 47fd2c250..fd4f9edc4 100644 --- a/test/ui/views/ets_view_test.dart +++ b/test/ui/views/ets_view_test.dart @@ -1,7 +1,5 @@ -// Dart imports: -import 'dart:io'; - // Flutter imports: +import 'package:ets_api_clients/models.dart'; import 'package:flutter/material.dart'; // Package imports: @@ -12,7 +10,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:notredame/core/managers/course_repository.dart'; import 'package:notredame/core/managers/news_repository.dart'; import 'package:notredame/core/managers/settings_manager.dart'; -import 'package:notredame/core/models/news.dart'; import 'package:notredame/core/services/analytics_service.dart'; import 'package:notredame/core/services/navigation_service.dart'; import 'package:notredame/core/services/networking_service.dart'; @@ -26,46 +23,104 @@ void main() { final List news = [ News( - id: 1, - title: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis.", - description: "Test 1 description", - author: "Author 1", - avatar: "https://example.com/avatar1.jpg", - activity: "Activity 1", - image: "", - tags: ["tag1", "tag2"], - publishedDate: DateTime.parse('2022-01-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-01-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-01-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 1', + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis. 1', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + tags: [ + NewsTags( + id: 'e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 1", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))), + NewsTags( + id: 'faaaaaaa-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 2", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))) + ], + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ), News( - id: 2, - title: "Test 2", - description: "Test 2 description", - author: "Author 2", - avatar: "https://example.com/avatar2.jpg", - activity: "Activity 2", - image: "", - tags: ["tag3", "tag4"], - publishedDate: DateTime.parse('2022-02-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 2', + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis. 2', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + tags: [ + NewsTags( + id: 'e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 1", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))), + NewsTags( + id: 'faaaaaaa-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 2", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))) + ], + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ), News( - id: 3, - title: "Test 3", - description: "Test 3 description", - author: "Author 3", - avatar: "https://example.com/avatar3.jpg", - activity: "Activity 3", - image: "", - tags: ["tag5", "tag6"], - publishedDate: DateTime.parse('2022-02-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 3', + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis. 3', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + tags: [ + NewsTags( + id: 'e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 1", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))), + NewsTags( + id: 'faaaaaaa-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 2", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))) + ], + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ), ]; + final PaginatedNews paginatedNews = PaginatedNews( + news: news, pageNumber: 1, pageSize: 3, totalRecords: 3, totalPages: 1); group('ETSView -', () { setUp(() async { @@ -78,8 +133,7 @@ void main() { setupAnalyticsServiceMock(); setupSettingsManagerMock(); - NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); - NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: paginatedNews); }); tearDown(() { @@ -115,6 +169,6 @@ void main() { await expectLater(find.byType(ETSView), matchesGoldenFile(goldenFilePath("etsView_1"))); }); - }, skip: !Platform.isLinux); + }); }); } diff --git a/test/ui/views/goldenFiles/etsView_1.png b/test/ui/views/goldenFiles/etsView_1.png index 8ecc96c1f..33c882135 100644 Binary files a/test/ui/views/goldenFiles/etsView_1.png and b/test/ui/views/goldenFiles/etsView_1.png differ diff --git a/test/ui/views/goldenFiles/newsView_1.png b/test/ui/views/goldenFiles/newsView_1.png index 9ff3b2f28..91a717a07 100644 Binary files a/test/ui/views/goldenFiles/newsView_1.png and b/test/ui/views/goldenFiles/newsView_1.png differ diff --git a/test/ui/views/goldenFiles/newsView_2.png b/test/ui/views/goldenFiles/newsView_2.png index d70543850..b971ebec5 100644 Binary files a/test/ui/views/goldenFiles/newsView_2.png and b/test/ui/views/goldenFiles/newsView_2.png differ diff --git a/test/ui/views/news_details_view_test.dart b/test/ui/views/news_details_view_test.dart index 4f9f4ff9d..c3b3fc10f 100644 --- a/test/ui/views/news_details_view_test.dart +++ b/test/ui/views/news_details_view_test.dart @@ -2,13 +2,13 @@ import 'dart:io'; // Flutter imports: +import 'package:ets_api_clients/models.dart'; import 'package:flutter/material.dart'; // Package imports: import 'package:flutter_test/flutter_test.dart'; // Project imports: -import 'package:notredame/core/models/news.dart'; import 'package:notredame/core/services/analytics_service.dart'; import 'package:notredame/core/services/navigation_service.dart'; import 'package:notredame/core/services/networking_service.dart'; @@ -25,17 +25,36 @@ void main() { setupNetworkingServiceMock(); sampleNews = News( - id: 1, - title: 'Sample News Title', - description: 'Sample News Description', - author: 'Sample Author', - avatar: '', - activity: 'Sample Activity', - image: '', - tags: ['sampleTag1', 'sampleTag2'], - publishedDate: DateTime.parse('2022-01-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 1', + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis. 1', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + tags: [ + NewsTags( + id: 'e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 1", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))), + NewsTags( + id: 'faaaaaaa-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 2", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))) + ], + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ); }); @@ -53,9 +72,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text(sampleNews.title), findsOneWidget); - expect(find.text(sampleNews.description), findsOneWidget); - expect(find.text(sampleNews.author), findsOneWidget); - expect(find.textContaining(sampleNews.activity), findsOneWidget); + expect(find.text(sampleNews.content), findsOneWidget); + expect(find.text(sampleNews.organizer.organisation!), findsOneWidget); expect(find.byType(IconButton), findsWidgets); }); diff --git a/test/ui/views/news_view_test.dart b/test/ui/views/news_view_test.dart index 65d48643e..92228c8fa 100644 --- a/test/ui/views/news_view_test.dart +++ b/test/ui/views/news_view_test.dart @@ -1,7 +1,5 @@ -// Dart imports: -import 'dart:io'; - // Flutter imports: +import 'package:ets_api_clients/models.dart'; import 'package:flutter/material.dart'; // Package imports: @@ -11,7 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:notredame/core/managers/course_repository.dart'; import 'package:notredame/core/managers/news_repository.dart'; import 'package:notredame/core/managers/settings_manager.dart'; -import 'package:notredame/core/models/news.dart'; import 'package:notredame/core/services/navigation_service.dart'; import 'package:notredame/core/services/networking_service.dart'; import 'package:notredame/ui/views/news_view.dart'; @@ -24,48 +21,48 @@ void main() { final List news = [ News( - id: 1, - title: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis.", - description: "Test 1 description", - author: "Author 1", - avatar: "https://example.com/avatar1.jpg", - activity: "Activity 1", - image: "", - tags: ["tag1", "tag2"], - publishedDate: DateTime.parse('2022-01-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), - ), - News( - id: 2, - title: "Test 2", - description: "Test 2 description", - author: "Author 2", - avatar: "https://example.com/avatar2.jpg", - activity: "Activity 2", - image: "", - tags: ["tag3", "tag4"], - publishedDate: DateTime.parse('2022-02-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), - ), - News( - id: 3, - title: "Test 3", - description: "Test 3 description", - author: "Author 3", - avatar: "https://example.com/avatar3.jpg", - activity: "Activity 3", - image: "", - tags: ["tag5", "tag6"], - publishedDate: DateTime.parse('2022-02-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 1', + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis. 1', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + tags: [ + NewsTags( + id: 'e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 1", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))), + NewsTags( + id: 'faaaaaaa-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 2", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))) + ], + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ), ]; + final PaginatedNews paginatedNews = PaginatedNews( + news: news, pageNumber: 1, pageSize: 3, totalRecords: 3, totalPages: 1); final List emptyNews = List.empty(); + final PaginatedNews paginatedEmptyNews = PaginatedNews( + news: emptyNews, + pageNumber: 1, + pageSize: 3, + totalRecords: 0, + totalPages: 1); group('NewsView -', () { setUp(() async { @@ -77,8 +74,7 @@ void main() { setupNetworkingServiceMock(); setupSettingsManagerMock(); - NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); - NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: paginatedNews); }); tearDown(() { @@ -90,37 +86,36 @@ void main() { }); testWidgets('Empty news', (WidgetTester tester) async { - NewsRepositoryMock.stubGetNews(newsRepository, toReturn: emptyNews); - NewsRepositoryMock.stubNews(newsRepository, toReturn: emptyNews); + NewsRepositoryMock.stubGetNews(newsRepository, + toReturn: paginatedEmptyNews); await tester.pumpWidget(localizedWidget(child: NewsView())); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.byType(RefreshIndicator), findsOneWidget); - expect(find.byType(ListView), findsOneWidget); + expect(find.byKey(const Key("pagedListView")), findsOneWidget); expect(find.byType(NewsCard), findsNothing); }); testWidgets('List of news', (WidgetTester tester) async { - NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); - NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: paginatedNews); await tester.pumpWidget(localizedWidget(child: NewsView())); await tester.pumpAndSettle(const Duration(seconds: 5)); expect(find.byType(RefreshIndicator), findsOneWidget); - expect(find.byType(ListView), findsOneWidget); + expect(find.byKey(const Key("pagedListView")), findsOneWidget); expect(find.byType(NewsCard), findsNWidgets(3)); }); group("golden - ", () { testWidgets("news view empty", (WidgetTester tester) async { - NewsRepositoryMock.stubGetNews(newsRepository, toReturn: emptyNews); - NewsRepositoryMock.stubNews(newsRepository, toReturn: emptyNews); + NewsRepositoryMock.stubGetNews(newsRepository, + toReturn: paginatedEmptyNews); tester.binding.window.physicalSizeTestValue = const Size(800, 1410); @@ -132,8 +127,7 @@ void main() { }); testWidgets("news view", (WidgetTester tester) async { - NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); - NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: paginatedNews); tester.binding.window.physicalSizeTestValue = const Size(800, 1410); @@ -143,6 +137,6 @@ void main() { await expectLater(find.byType(NewsView), matchesGoldenFile(goldenFilePath("newsView_2"))); }); - }, skip: !Platform.isLinux); + }); }); } diff --git a/test/ui/widgets/news_card_test.dart b/test/ui/widgets/news_card_test.dart index 36d12403a..6e232f6a0 100644 --- a/test/ui/widgets/news_card_test.dart +++ b/test/ui/widgets/news_card_test.dart @@ -1,27 +1,46 @@ // Flutter imports: +import 'package:ets_api_clients/models.dart'; import 'package:flutter/material.dart'; // Package imports: import 'package:flutter_test/flutter_test.dart'; // Project imports: -import 'package:notredame/core/models/news.dart'; import 'package:notredame/ui/widgets/news_card.dart'; import '../../helpers.dart'; void main() { final news = News( - id: 1, - title: "Test 3", - description: "Test 3 description", - author: "Author 3", - avatar: "https://example.com/avatar3.jpg", - activity: "Activity 3", - image: "", - tags: ["tag5", "tag6"], - publishedDate: DateTime.parse('2022-02-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 1', + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis. 1', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + tags: [ + NewsTags( + id: 'e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 1", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))), + NewsTags( + id: 'faaaaaaa-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 2", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))) + ], + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ); group('News card Tests', () { diff --git a/test/viewmodels/news_details_viewmodel_test.dart b/test/viewmodels/news_details_viewmodel_test.dart index 8286085c8..2a8ca0074 100644 --- a/test/viewmodels/news_details_viewmodel_test.dart +++ b/test/viewmodels/news_details_viewmodel_test.dart @@ -1,25 +1,44 @@ // Package imports: +import 'package:ets_api_clients/models.dart'; import 'package:flutter_test/flutter_test.dart'; // Project imports: -import 'package:notredame/core/models/news.dart'; import 'package:notredame/core/viewmodels/news_details_viewmodel.dart'; import 'package:notredame/ui/utils/app_theme.dart'; void main() { late NewsDetailsViewModel viewModel; final News mockNews = News( - id: 1, - title: "Test News", - description: "Test Description", - author: "Test Author", - avatar: "https://example.com/avatar.jpg", - activity: "Test Activity", - image: "https://example.com/image.jpg", - tags: ["tag1", "tag2"], - publishedDate: DateTime.parse('2022-01-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 1', + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis. 1', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + tags: [ + NewsTags( + id: 'e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 1", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))), + NewsTags( + id: 'faaaaaaa-e3e3-e3e3-e3e3-e3e3e3e3e3e3', + name: "tag 2", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180))) + ], + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ); group('NewsDetailsViewModel Tests', () { diff --git a/test/viewmodels/news_viewmodel_test.dart b/test/viewmodels/news_viewmodel_test.dart index 11f766bc2..a7dbfdd29 100644 --- a/test/viewmodels/news_viewmodel_test.dart +++ b/test/viewmodels/news_viewmodel_test.dart @@ -1,12 +1,11 @@ -// Package imports: -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:ets_api_clients/models.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logger/logger.dart'; +import 'package:mockito/mockito.dart'; // Project imports: import 'package:notredame/core/managers/news_repository.dart'; import 'package:notredame/core/managers/settings_manager.dart'; -import 'package:notredame/core/models/news.dart'; import 'package:notredame/core/viewmodels/news_viewmodel.dart'; import 'package:notredame/locator.dart'; import '../helpers.dart'; @@ -15,45 +14,62 @@ import '../mock/managers/news_repository_mock.dart'; void main() { late NewsViewModel viewModel; late NewsRepositoryMock newsRepository; - late AppIntl appIntl; final List news = [ News( - id: 1, - title: "Mock News 1", - description: "Test 1 description", - author: "Author 1", - avatar: "https://example.com/avatar1.jpg", - activity: "Activity 1", - image: "", - tags: ["tag1", "tag2"], - publishedDate: DateTime.parse('2022-01-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), + id: "4627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 1', + content: 'Mock Description 1', + imageUrl: 'https://example.com/mock-image1.jpg', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + tags: [], + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), ), News( - id: 2, - title: "Mock News 2", - description: "Test 2 description", - author: "Author 2", - avatar: "https://example.com/avatar2.jpg", - activity: "Activity 2", - image: "", - tags: ["tag3", "tag4"], - publishedDate: DateTime.parse('2022-02-01T12:00:00Z'), - eventStartDate: DateTime.parse('2022-02-02T12:00:00Z'), - eventEndDate: DateTime.parse('2022-02-02T12:00:00Z'), - ) + id: "5627a622-f7c7-4ff9-9a01-50c69333ff42", + title: 'Mock News 2', + content: 'Mock Description 2', + imageUrl: 'https://example.com/mock-image2.jpg', + state: 1, + publicationDate: DateTime.now().subtract(const Duration(days: 5)), + eventStartDate: DateTime.now().add(const Duration(days: 2)), + eventEndDate: DateTime.now().add(const Duration(days: 2, hours: 2)), + organizer: NewsUser( + id: "e3e3e3e3-e3e3-e3e3-e3e3-e3e3e3e3e3e3", + type: "organizer", + organisation: "Mock Organizer", + email: "", + createdAt: DateTime.now().subtract(const Duration(days: 180)), + updatedAt: DateTime.now().subtract(const Duration(days: 180)), + ), + tags: [], + createdAt: DateTime.now().subtract(const Duration(days: 5)), + updatedAt: DateTime.now().subtract(const Duration(days: 5)), + ), ]; + final PaginatedNews paginatedNews = PaginatedNews( + news: news, totalRecords: 2, totalPages: 2, pageNumber: 1, pageSize: 3); + group('NewsViewModel tests', () { setUp(() async { newsRepository = setupNewsRepositoryMock(); setupLogger(); setupSettingsManagerMock(); - NewsRepositoryMock.stubNews(newsRepository, toReturn: news); - appIntl = await setupAppIntl(); - viewModel = NewsViewModel(intl: appIntl); + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: paginatedNews); + viewModel = NewsViewModel(); }); tearDown(() { @@ -62,15 +78,22 @@ void main() { locator.unregister(); }); - test('Fetching news updates the news list', () async { + test('NewsViewModel fetch first page', () async { expect(viewModel.isBusy, isFalse); - await viewModel.futureToRun(); + await viewModel.fetchPage(1); + + verify(newsRepository.getNews(pageNumber: 1)).called(1); + expect(viewModel.pagingController.nextPageKey, 2); + }); - expect(viewModel.news, hasLength(2)); - expect(viewModel.news[0].title, equals('Mock News 1')); - expect(viewModel.news[1].title, equals('Mock News 2')); + test('NewsViewModel fetch last page', () async { expect(viewModel.isBusy, isFalse); + + await viewModel.fetchPage(2); + + verify(newsRepository.getNews(pageNumber: 2)).called(1); + expect(viewModel.pagingController.nextPageKey, 3); }); }); }