diff --git a/l10n/intl_en.arb b/l10n/intl_en.arb index c27be44c3..06636a7b5 100644 --- a/l10n/intl_en.arb +++ b/l10n/intl_en.arb @@ -158,6 +158,7 @@ "profile_program_completion_not_available": "N/A", "profile_other_programs": "Other Programs", + "useful_link_title": "Useful links", "ets_security_title": "Security", "ets_monets_title": "MonÉTS", "ets_bibliotech_title": "Bibliotech", @@ -169,6 +170,8 @@ "ets_gus": "GUS", "ets_papercut_title":"PaperCut", + "news_title" : "News", + "more_about_applets_title": "About ApplETS", "more_report_bug": "Report a bug or request a feature", "more_report_bug_steps_title": "How does it work?\n\n", diff --git a/l10n/intl_fr.arb b/l10n/intl_fr.arb index ca8f5251f..7892f52f8 100644 --- a/l10n/intl_fr.arb +++ b/l10n/intl_fr.arb @@ -158,6 +158,7 @@ "profile_program_completion_not_available": "ND", "profile_other_programs": "Autres Programmes", + "useful_link_title": "Liens utiles", "ets_security_title": "Sécurité", "ets_monets_title": "MonÉTS", "ets_bibliotech_title": "Bibliotech", @@ -169,6 +170,8 @@ "ets_gus": "GUS", "ets_papercut_title":"PaperCut", + "news_title" : "Annonces", + "more_about_applets_title": "À propos d'ApplETS", "more_report_bug": "Rapporter un bogue ou une amélioration", "more_report_bug_steps_title": "Comment faire?\n\n", diff --git a/lib/core/constants/router_paths.dart b/lib/core/constants/router_paths.dart index f62fcf345..5d14421c6 100644 --- a/lib/core/constants/router_paths.dart +++ b/lib/core/constants/router_paths.dart @@ -10,6 +10,8 @@ class RouterPaths { static const String ets = "/ets"; static const String webView = "/ets/web-view"; static const String security = "/ets/security"; + static const String usefulLinks = "/ets/useful-links"; + static const String news = "/ets/news"; static const String more = "/more"; static const String settings = "/more/settings"; static const String contributors = "/more/contributors"; diff --git a/lib/core/managers/news_repository.dart b/lib/core/managers/news_repository.dart new file mode 100644 index 000000000..72ae55d79 --- /dev/null +++ b/lib/core/managers/news_repository.dart @@ -0,0 +1,132 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; + +// SERVICES +import 'package:notredame/core/services/networking_service.dart'; +import 'package:notredame/core/managers/cache_manager.dart'; + +// MODELS +import 'package:notredame/core/models/news.dart'; + +// UTILS +import 'package:notredame/core/utils/cache_exception.dart'; + +// OTHER +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: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec tempus arcu sed quam tincidunt, non venenatis orci mollis.", + description: "Test 1 description", + date: DateTime.now(), + image: "https://picsum.photos/400/200", + tags: ["tag1", "tag2"], + ), + News( + id: 2, + title: "Test 2", + description: "Test 2 description", + date: DateTime.now(), + image: "https://picsum.photos/400/200", + tags: ["tag1", "tag2"], + ), + News( + id: 3, + title: "Test 3", + description: "Test 3 description", + date: DateTime.now(), + image: "https://picsum.photos/400/200", + tags: ["tag1", "tag2"], + ), + ]; + + List get news => _news; + + /// 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(); + + // Update the list of news to avoid duplicate news + for (final News news in fetchedNews) { + if (!_news.contains(news)) { + _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; + } +} diff --git a/lib/core/models/news.dart b/lib/core/models/news.dart new file mode 100644 index 000000000..b535f72af --- /dev/null +++ b/lib/core/models/news.dart @@ -0,0 +1,40 @@ +class News { + final int id; + final String title; + final String description; + final String image; + final List tags; + final DateTime date; + + News({ + required this.id, + required this.title, + required this.description, + required this.image, + required this.tags, + required this.date, + }); + + /// 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, + image: map['image'] as String, + tags: map['tags'] as List, + date: DateTime.parse(map['date'] as String), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'image': image, + 'tags': tags.toList(), + 'date': date.toString(), + }; + } +} diff --git a/lib/core/utils/utils.dart b/lib/core/utils/utils.dart index 9a4654a9e..89f501b5b 100644 --- a/lib/core/utils/utils.dart +++ b/lib/core/utils/utils.dart @@ -34,6 +34,10 @@ mixin Utils { : darkColor; } + static bool isDarkTheme(BuildContext context) { + return Theme.of(context).brightness == Brightness.dark; + } + /// Get first day of the week depending on startingDay which corresponds to weekday static DateTime getFirstDayOfCurrentWeek( DateTime currentDate, StartingDayOfWeek startingDay) { diff --git a/lib/core/viewmodels/news_viewmodel.dart b/lib/core/viewmodels/news_viewmodel.dart new file mode 100644 index 000000000..0db88a741 --- /dev/null +++ b/lib/core/viewmodels/news_viewmodel.dart @@ -0,0 +1,67 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:stacked/stacked.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import 'package:notredame/core/managers/news_repository.dart'; +import 'package:notredame/core/models/news.dart'; + +import 'package:notredame/locator.dart'; + +class NewsViewModel extends FutureViewModel> { + /// 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 + for (final n in _newsRepository.news) { + _news.add(n); + } + + return _news; + } + + NewsViewModel({required AppIntl intl}) : _appIntl = intl; + + 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); + } + + Future refresh() async { + try { + setBusyForObject(isLoadingEvents, true); + _newsRepository.getNews(); + notifyListeners(); + } on Exception catch (error) { + onError(error); + } + } +} diff --git a/lib/locator.dart b/lib/locator.dart index ea8ee8336..20dc2fb74 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -18,6 +18,7 @@ import 'package:notredame/core/services/internal_info_service.dart'; import 'package:notredame/core/services/launch_url_service.dart'; import 'package:notredame/core/services/navigation_service.dart'; import 'package:notredame/core/services/networking_service.dart'; +import 'package:notredame/core/managers/news_repository.dart'; import 'package:notredame/core/services/preferences_service.dart'; import 'package:notredame/core/services/remote_config_service.dart'; import 'package:notredame/core/services/rive_animation_service.dart'; @@ -47,6 +48,7 @@ void setupLocator() { locator.registerLazySingleton(() => CacheManager()); locator.registerLazySingleton(() => SettingsManager()); locator.registerLazySingleton(() => QuickLinkRepository()); + locator.registerLazySingleton(() => NewsRepository()); // Other locator.registerLazySingleton(() => SignetsAPIClient()); diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 1ea109b1b..5db6577e7 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -13,6 +13,8 @@ import 'package:notredame/ui/views/choose_language_view.dart'; import 'package:notredame/ui/views/contributors_view.dart'; import 'package:notredame/ui/views/dashboard_view.dart'; import 'package:notredame/ui/views/faq_view.dart'; +import 'package:notredame/ui/views/ets_view.dart'; +import 'package:notredame/ui/views/news_view.dart'; import 'package:notredame/ui/views/feedback_view.dart'; import 'package:notredame/ui/views/grade_details_view.dart'; import 'package:notredame/ui/views/login_view.dart'; @@ -80,9 +82,17 @@ Route generateRoute(RouteSettings routeSettings) { pageBuilder: (_, __, ___) => GradesDetailsView(course: routeSettings.arguments! as Course)); case RouterPaths.ets: + return PageRouteBuilder( + settings: RouteSettings(name: routeSettings.name), + pageBuilder: (_, __, ___) => ETSView()); + case RouterPaths.usefulLinks: return PageRouteBuilder( settings: RouteSettings(name: routeSettings.name), pageBuilder: (_, __, ___) => QuickLinksView()); + case RouterPaths.news: + return PageRouteBuilder( + settings: RouteSettings(name: routeSettings.name), + pageBuilder: (_, __, ___) => NewsView()); case RouterPaths.webView: return PageRouteBuilder( pageBuilder: (_, __, ___) => diff --git a/lib/ui/views/ets_view.dart b/lib/ui/views/ets_view.dart new file mode 100644 index 000000000..5b8e0524c --- /dev/null +++ b/lib/ui/views/ets_view.dart @@ -0,0 +1,65 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// WIDGET +import 'package:notredame/ui/views/news_view.dart'; +import 'package:notredame/ui/views/quick_links_view.dart'; +import 'package:notredame/ui/widgets/base_scaffold.dart'; + +class ETSView extends StatefulWidget { + @override + _ETSViewState createState() => _ETSViewState(); +} + +class _ETSViewState extends State { + List tabsView = [NewsView(), QuickLinksView()]; + + @override + Widget build(BuildContext context) { + final List tabs = [ + AppIntl.of(context)!.news_title, + AppIntl.of(context)!.useful_link_title + ]; + + return BaseScaffold( + isInteractionLimitedWhileLoading: false, + body: DefaultTabController( + length: tabs.length, + child: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverAppBar( + elevation: 4.0, + automaticallyImplyLeading: false, + pinned: true, + floating: true, + title: Text(AppIntl.of(context)!.title_ets), + forceElevated: innerBoxIsScrolled, + bottom: TabBar( + indicatorColor: + (Theme.of(context).brightness == Brightness.dark) + ? Colors.white + : Colors.black26, + labelColor: (Theme.of(context).brightness == Brightness.dark) + ? Colors.white + : Colors.black, + tabs: List.generate( + tabs.length, + (index) => Tab( + text: tabs[index], + ), + ), + ), + ), + ]; + }, + body: TabBarView( + children: tabsView, + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/news_view.dart b/lib/ui/views/news_view.dart new file mode 100644 index 000000000..8cfa8078c --- /dev/null +++ b/lib/ui/views/news_view.dart @@ -0,0 +1,42 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// WIDGET +import 'package:notredame/ui/widgets/news_card.dart'; + +// VIEW-MODEL +import 'package:notredame/core/viewmodels/news_viewmodel.dart'; + +class NewsView extends StatefulWidget { + @override + State createState() => _NewsViewState(); +} + +class _NewsViewState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) => + ViewModelBuilder.reactive( + viewModelBuilder: () => NewsViewModel(intl: AppIntl.of(context)!), + builder: (context, model, child) { + return RefreshIndicator( + child: Theme( + data: + Theme.of(context).copyWith(canvasColor: Colors.transparent), + child: model.isLoadingEvents + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.fromLTRB(0, 4, 0, 8), + children: + model.news.map((news) => NewsCard(news)).toList()), + ), + onRefresh: () => model.refresh(), + ); + }); +} diff --git a/lib/ui/views/quick_links_view.dart b/lib/ui/views/quick_links_view.dart index f75541930..716ed8190 100644 --- a/lib/ui/views/quick_links_view.dart +++ b/lib/ui/views/quick_links_view.dart @@ -10,7 +10,7 @@ import 'package:stacked/stacked.dart'; import 'package:notredame/core/models/quick_link.dart'; import 'package:notredame/core/viewmodels/quick_links_viewmodel.dart'; import 'package:notredame/ui/utils/app_theme.dart'; -import 'package:notredame/ui/widgets/base_scaffold.dart'; +import 'package:notredame/ui/widgets/scaffold_safe_area.dart'; import 'package:notredame/ui/widgets/web_link_card.dart'; class QuickLinksView extends StatefulWidget { @@ -43,9 +43,8 @@ class _QuickLinksViewState extends State Widget build(BuildContext context) => ViewModelBuilder.reactive( viewModelBuilder: () => QuickLinksViewModel(AppIntl.of(context)!), - builder: (context, model, child) => BaseScaffold( + builder: (context, model, child) => ScaffoldSafeArea( isLoading: model.isBusy, - appBar: _buildAppBar(context, model), body: _buildBody(context, model), ), ); diff --git a/lib/ui/widgets/base_scaffold.dart b/lib/ui/widgets/base_scaffold.dart index a004ebad0..05b846b12 100644 --- a/lib/ui/widgets/base_scaffold.dart +++ b/lib/ui/widgets/base_scaffold.dart @@ -13,7 +13,7 @@ import 'package:notredame/core/services/networking_service.dart'; import 'package:notredame/core/utils/utils.dart'; import 'package:notredame/locator.dart'; import 'package:notredame/ui/utils/app_theme.dart'; -import 'package:notredame/ui/utils/loading.dart'; +import 'package:notredame/ui/widgets/scaffold_safe_area.dart'; import 'package:notredame/ui/widgets/bottom_bar.dart'; /// Basic Scaffold to avoid boilerplate code in the application. @@ -84,19 +84,11 @@ class _BaseScaffoldState extends State { Widget build(BuildContext context) => Scaffold( body: Scaffold( appBar: widget.appBar, - body: SafeArea( - top: false, - child: Stack( - children: [ - widget.body ?? const SizedBox(), - if (widget._isLoading) - buildLoading( - isInteractionLimitedWhileLoading: - widget._isInteractionLimitedWhileLoading) - else - const SizedBox() - ], - ), + body: ScaffoldSafeArea( + body: widget.body ?? const SizedBox(), + isInteractionLimitedWhileLoading: + widget._isInteractionLimitedWhileLoading, + isLoading: widget._isLoading, ), bottomNavigationBar: widget._showBottomBar ? BottomBar() : null, floatingActionButton: widget.fab, diff --git a/lib/ui/widgets/bottom_bar.dart b/lib/ui/widgets/bottom_bar.dart index c4427fa94..032f80dfc 100644 --- a/lib/ui/widgets/bottom_bar.dart +++ b/lib/ui/widgets/bottom_bar.dart @@ -57,6 +57,8 @@ class _BottomBarState extends State { break; case RouterPaths.ets: case RouterPaths.security: + case RouterPaths.news: + case RouterPaths.usefulLinks: _currentView = BottomBar.etsView; break; case RouterPaths.more: diff --git a/lib/ui/widgets/news_card.dart b/lib/ui/widgets/news_card.dart new file mode 100644 index 000000000..33c21db69 --- /dev/null +++ b/lib/ui/widgets/news_card.dart @@ -0,0 +1,74 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:timeago/timeago.dart' as timeago; + +// MODEL +import 'package:notredame/core/models/news.dart'; + +class NewsCard extends StatelessWidget { + final News news; + + const NewsCard(this.news); + + @override + Widget build(BuildContext context) { + timeago.setLocaleMessages('fr', timeago.FrShortMessages()); + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + key: UniqueKey(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildImage(news), + const SizedBox(height: 8), + _buildTitleAndTime(news, context), + ], + ), + ), + ], + ), + ); + } + + Widget _buildImage(News news) { + return ClipRRect( + borderRadius: BorderRadius.circular(16.0), + child: Image.network( + news.image, + fit: BoxFit.cover, + ), + ); + } + + Widget _buildTitleAndTime(News news, BuildContext context) { + final TextStyle textStyle = + Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 16, + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text( + news.title, + style: textStyle, + ), + ), + const SizedBox(width: 10), + Text( + timeago.format(news.date, locale: AppIntl.of(context)!.localeName), + style: textStyle, + ), + ], + ); + } +} diff --git a/lib/ui/widgets/scaffold_safe_area.dart b/lib/ui/widgets/scaffold_safe_area.dart new file mode 100644 index 000000000..11253a2aa --- /dev/null +++ b/lib/ui/widgets/scaffold_safe_area.dart @@ -0,0 +1,50 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter/material.dart'; + +// UTILS +import 'package:notredame/ui/utils/loading.dart'; + +/// ScaffoldSafeArea to avoid boilerplate code in the application. +/// Contains a loader controlled by [_isLoading] +class ScaffoldSafeArea extends StatefulWidget { + final Widget? body; + + final bool _isLoading; + + /// If true, interactions with the UI is limited while loading. + final bool _isInteractionLimitedWhileLoading; + + const ScaffoldSafeArea( + {this.body, + bool isLoading = false, + bool isInteractionLimitedWhileLoading = true}) + : _isLoading = isLoading, + _isInteractionLimitedWhileLoading = isInteractionLimitedWhileLoading; + + @override + _ScaffoldSafeAreaState createState() => _ScaffoldSafeAreaState(); +} + +class _ScaffoldSafeAreaState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: Stack( + children: [ + widget.body ?? const SizedBox(), + if (widget._isLoading) + buildLoading( + isInteractionLimitedWhileLoading: + widget._isInteractionLimitedWhileLoading) + else + const SizedBox() + ], + ), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock index f124c63bd..95ce1b5e8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1225,6 +1225,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.12" + timeago: + dependency: "direct main" + description: + name: timeago + url: "https://pub.dartlang.org" + source: hosted + version: "3.3.0" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 567c82704..4241a7a4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: auto_size_text: ^3.0.0 easter_egg_trigger: ^1.0.1 calendar_view: ^1.0.1 + timeago: ^3.3.0 carousel_slider: ^4.2.1 reorderable_grid_view: ^2.2.6 import_sorter: ^4.6.0 diff --git a/test/helpers.dart b/test/helpers.dart index 1dfbd1be7..72579a63c 100644 --- a/test/helpers.dart +++ b/test/helpers.dart @@ -16,6 +16,7 @@ import 'package:notredame/core/managers/cache_manager.dart'; import 'package:notredame/core/managers/course_repository.dart'; import 'package:notredame/core/managers/quick_link_repository.dart'; import 'package:notredame/core/managers/settings_manager.dart'; +import 'package:notredame/core/managers/news_repository.dart'; import 'package:notredame/core/managers/user_repository.dart'; import 'package:notredame/core/services/analytics_service.dart'; import 'package:notredame/core/services/app_widget_service.dart'; @@ -32,6 +33,7 @@ import 'package:notredame/core/services/siren_flutter_service.dart'; import 'package:notredame/locator.dart'; import 'mock/managers/cache_manager_mock.dart'; import 'mock/managers/course_repository_mock.dart'; +import 'mock/managers/news_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'; @@ -88,6 +90,16 @@ SignetsAPIClientMock setupSignetsApiMock() { return service; } +/// Load a mock of the [NewsRepository] +NewsRepositoryMock setupNewsRepositoryMock() { + unregister(); + final service = NewsRepositoryMock(); + + locator.registerSingleton(service); + + return service; +} + /// Load a mock of the [InAppReviewService] InAppReviewService setupInAppReviewServiceMock() { unregister(); diff --git a/test/managers/news_repository_test.dart b/test/managers/news_repository_test.dart new file mode 100644 index 000000000..cb014abd1 --- /dev/null +++ b/test/managers/news_repository_test.dart @@ -0,0 +1,59 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter_test/flutter_test.dart'; + +// MANAGER +import 'package:notredame/core/managers/news_repository.dart'; + +// MODEL +import 'package:notredame/core/models/news.dart'; + +// UTILS +import '../helpers.dart'; + +void main() { + group('NewsRepository tests', () { + 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 new file mode 100644 index 000000000..9c1ab1908 --- /dev/null +++ b/test/mock/managers/news_repository_mock.dart @@ -0,0 +1,37 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:mockito/mockito.dart'; + +// MANAGER +import 'package:notredame/core/managers/news_repository.dart'; + +// MODELS +import 'package:notredame/core/models/news.dart'; + +// UTILS +import 'package:ets_api_clients/exceptions.dart'; + +class NewsRepositoryMock extends Mock implements NewsRepository { + /// 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}) { + when(mock.getNews( + fromCacheOnly: fromCacheOnly ?? anyNamed("fromCacheOnly"))) + .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}) { + when(mock.getNews( + fromCacheOnly: fromCacheOnly ?? anyNamed("fromCacheOnly"))) + .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 new file mode 100644 index 000000000..9e2021bd4 --- /dev/null +++ b/test/models/news_test.dart @@ -0,0 +1,90 @@ +// FLUTTER / DART / THIRD-PARTIES +// ignore_for_file: avoid_dynamic_calls + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// MODELS +import 'package:notredame/core/models/news.dart'; +import 'package:notredame/core/models/tags.dart'; + +void main() { + group('News class tests', () { + test('News.fromJson() should parse JSON correctly', () { + final json = { + 'id': 1, + 'title': 'Test Title', + 'description': 'Test Description', + 'image': 'https://example.com/image.jpg', + 'tags': [ + {'text': 'Tag 1', 'color': Colors.blue.value}, + {'text': 'Tag 2', 'color': Colors.red.value}, + ], + 'date': '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.image, equals('https://example.com/image.jpg')); + expect(news.tags.length, equals(2)); + expect(news.tags[0].text, equals('Tag 1')); + expect(news.tags[0].color, equals(Colors.blue[500])); + expect(news.tags[1].text, equals('Tag 2')); + expect(news.tags[1].color, equals(Colors.red[500])); + expect(news.date, 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', + image: 'https://example.com/image.jpg', + tags: [ + Tag(text: 'Tag 1', color: Colors.blue[500]), + Tag(text: 'Tag 2', color: Colors.red[500]), + ], + date: DateTime.parse('2022-01-01T12:00:00Z'), + ); + + final json = news.toJson(); + + expect(json['id'], equals(1)); + expect(json['title'], equals('Test Title')); + expect(json['description'], equals('Test Description')); + expect(json['image'], equals('https://example.com/image.jpg')); + expect(json['tags'], hasLength(2)); + expect(json['tags'][0]['text'], equals('Tag 1')); + expect(json['tags'][0]['color'], equals(Colors.blue[500].value)); + expect(json['tags'][1]['text'], equals('Tag 2')); + expect(json['tags'][1]['color'], equals(Colors.red[500].value)); + expect(json['date'], equals('2022-01-01 12:00:00.000Z')); + }); + }); + + group('Tag class tests', () { + test('Tag.fromJson() should parse JSON correctly', () { + final json = { + 'text': 'Test Tag', + 'color': Colors.blue[500].value, + }; + + final tag = Tag.fromJson(json); + + expect(tag.text, equals('Test Tag')); + expect(tag.color, equals(Colors.blue[500])); + }); + + test('toJson() should convert Tag to JSON correctly', () { + final tag = Tag(text: 'Test Tag', color: Colors.blue[500]); + + final json = tag.toJson(); + + expect(json['text'], equals('Test Tag')); + expect(json['color'], equals(Colors.blue[500].value)); + }); + }); +} diff --git a/test/ui/views/ets_view_test.dart b/test/ui/views/ets_view_test.dart new file mode 100644 index 000000000..87842d3e8 --- /dev/null +++ b/test/ui/views/ets_view_test.dart @@ -0,0 +1,115 @@ +// Dart imports: +import 'dart:io'; + +// Flutter imports: +import 'package:feature_discovery/feature_discovery.dart'; +import 'package:flutter/material.dart'; + +// Package imports: +import 'package:flutter_test/flutter_test.dart'; + +// Project imports: +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/models/tags.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'; +import 'package:notredame/ui/views/ets_view.dart'; +import 'package:notredame/ui/widgets/base_scaffold.dart'; +import '../../helpers.dart'; +import '../../mock/managers/news_repository_mock.dart'; + +void main() { + NewsRepositoryMock newsRepository; + + 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", + date: DateTime.now(), + image: null, + tags: [ + Tag(text: "tag1", color: Colors.blue), + Tag(text: "tag2", color: Colors.green), + ], + ), + News( + id: 2, + title: "Test 2", + description: "Test 2 description", + date: DateTime.now(), + image: null, + tags: [ + Tag(text: "tag1", color: Colors.blue), + Tag(text: "tag2", color: Colors.green), + ], + ), + News( + id: 3, + title: "Test 3", + description: "Test 3 description", + date: DateTime.now(), + image: null, + tags: [ + Tag(text: "tag1", color: Colors.blue), + Tag(text: "tag2", color: Colors.green), + ], + ), + ]; + + group('ETSView -', () { + setUp(() async { + await setupAppIntl(); + setupLogger(); + + newsRepository = setupNewsRepositoryMock(); + setupNavigationServiceMock(); + setupNetworkingServiceMock(); + setupAnalyticsServiceMock(); + setupSettingsManagerMock(); + + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + }); + + tearDown(() { + unregister(); + unregister(); + unregister(); + unregister(); + unregister(); + unregister(); + }); + + testWidgets('has Tab bar and sliverAppBar and BaseScaffold', + (WidgetTester tester) async { + await tester.pumpWidget( + localizedWidget(child: FeatureDiscovery(child: ETSView()))); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.byType(TabBar), findsOneWidget); + + expect(find.byType(SliverAppBar), findsOneWidget); + + expect(find.byType(BaseScaffold), findsOneWidget); + }); + + group("golden - ", () { + testWidgets("default view", (WidgetTester tester) async { + tester.binding.window.physicalSizeTestValue = const Size(800, 1410); + + await tester.pumpWidget( + localizedWidget(child: FeatureDiscovery(child: ETSView()))); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + await expectLater(find.byType(ETSView), + matchesGoldenFile(goldenFilePath("etsView_1"))); + }); + }, skip: !Platform.isLinux); + }); +} diff --git a/test/ui/views/news_view_test.dart b/test/ui/views/news_view_test.dart new file mode 100644 index 000000000..c8a5d3b70 --- /dev/null +++ b/test/ui/views/news_view_test.dart @@ -0,0 +1,143 @@ +// Dart imports: +import 'dart:io'; + +// Flutter imports: +import 'package:flutter/material.dart'; + +// Package imports: +import 'package:flutter_test/flutter_test.dart'; + +// Project imports: +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/models/tags.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'; +import 'package:notredame/ui/widgets/news_card.dart'; +import '../../helpers.dart'; +import '../../mock/managers/news_repository_mock.dart'; + +void main() { + NewsRepositoryMock newsRepository; + + 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", + date: DateTime.now(), + image: null, + tags: [ + Tag(text: "tag1", color: Colors.blue), + Tag(text: "tag2", color: Colors.green), + ], + ), + News( + id: 2, + title: "Test 2", + description: "Test 2 description", + date: DateTime.now(), + image: null, + tags: [ + Tag(text: "tag1", color: Colors.blue), + Tag(text: "tag2", color: Colors.green), + ], + ), + News( + id: 3, + title: "Test 3", + description: "Test 3 description", + date: DateTime.now(), + image: null, + tags: [ + Tag(text: "tag1", color: Colors.blue), + Tag(text: "tag2", color: Colors.green), + ], + ), + ]; + + final List emptyNews = List.empty(); + + group('NewsView -', () { + setUp(() async { + await setupAppIntl(); + setupLogger(); + + newsRepository = setupNewsRepositoryMock(); + setupNavigationServiceMock(); + setupNetworkingServiceMock(); + setupSettingsManagerMock(); + + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + }); + + tearDown(() { + unregister(); + unregister(); + unregister(); + unregister(); + unregister(); + }); + + testWidgets('Empty news', (WidgetTester tester) async { + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: emptyNews); + NewsRepositoryMock.stubNews(newsRepository, toReturn: emptyNews); + + 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.byType(NewsCard), findsNothing); + }); + + testWidgets('List of news', (WidgetTester tester) async { + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + + 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.byType(NewsCard), findsNWidgets(3)); + }); + + group("golden - ", () { + testWidgets("news view empty", (WidgetTester tester) async { + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: emptyNews); + NewsRepositoryMock.stubNews(newsRepository, toReturn: emptyNews); + + tester.binding.window.physicalSizeTestValue = const Size(800, 1410); + + await tester.pumpWidget(localizedWidget(child: NewsView())); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + await expectLater(find.byType(NewsView), + matchesGoldenFile(goldenFilePath("newsView_1"))); + }); + + testWidgets("news view", (WidgetTester tester) async { + NewsRepositoryMock.stubGetNews(newsRepository, toReturn: news); + NewsRepositoryMock.stubNews(newsRepository, toReturn: news); + + tester.binding.window.physicalSizeTestValue = const Size(800, 1410); + + await tester.pumpWidget(localizedWidget(child: NewsView())); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + 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 new file mode 100644 index 000000000..718ffdfe4 --- /dev/null +++ b/test/ui/widgets/news_card_test.dart @@ -0,0 +1,38 @@ +// Package imports: +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:notredame/core/models/news.dart'; +import 'package:notredame/core/models/tags.dart'; +import 'package:notredame/ui/widgets/news_card.dart'; + +// Project imports: +import '../../helpers.dart'; + +void main() { + final news = News( + id: 3, + title: "Test 3", + description: "Test 3 description", + date: DateTime.now(), + image: null, + tags: [ + Tag(text: "tag1", color: Colors.blue), + Tag(text: "tag2", color: Colors.green), + ], + ); + + group('News card Tests', () { + setUpAll(() async { + await setupAppIntl(); + }); + + testWidgets('Displays a news card without an image', + (WidgetTester tester) async { + await tester.pumpWidget(localizedWidget(child: NewsCard(news))); + await tester.pumpAndSettle(); + + expect(find.text(news.title), findsOneWidget); + expect(find.byType(Image), findsNothing); + }); + }); +} diff --git a/test/viewmodels/news_viewmodel_test.dart b/test/viewmodels/news_viewmodel_test.dart new file mode 100644 index 000000000..328d80628 --- /dev/null +++ b/test/viewmodels/news_viewmodel_test.dart @@ -0,0 +1,77 @@ +// FLUTTER / DART / THIRD-PARTIES +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +// MANAGERS +import 'package:notredame/core/managers/settings_manager.dart'; +import 'package:notredame/core/managers/news_repository.dart'; + +// MODEL +import 'package:notredame/core/models/news.dart'; + +// VIEWMODEL +import 'package:notredame/core/viewmodels/news_viewmodel.dart'; + +// UTILS +import 'package:notredame/locator.dart'; +import 'package:logger/logger.dart'; +import '../helpers.dart'; + +class MockNewsRepository extends Mock implements NewsRepository { + @override + List get news => [ + News( + id: 1, + title: 'Mock News 1', + description: 'Mock Description 1', + image: 'https://example.com/mock-image1.jpg', + tags: [], + date: DateTime.now(), + ), + News( + id: 2, + title: 'Mock News 2', + description: 'Mock Description 2', + image: 'https://example.com/mock-image2.jpg', + tags: [], + date: DateTime.now(), + ), + ]; + + static void stubGetNews(MockNewsRepository mock, List newsList) { + when(mock.getNews(fromCacheOnly: anyNamed('fromCacheOnly'))) + .thenAnswer((_) async => newsList); + } +} + +void main() { + NewsViewModel viewModel; + + group('NewsViewModel tests', () { + setUp(() { + setupLogger(); + setupSettingsManagerMock(); + final mockNewsRepository = MockNewsRepository(); + MockNewsRepository.stubGetNews(mockNewsRepository, []); + locator.registerSingleton(mockNewsRepository); + viewModel = NewsViewModel(intl: null); // Pass null for AppIntl + }); + + tearDown(() { + locator.unregister(); + locator.unregister(); + locator.unregister(); + }); + + test('Fetching news updates the news list', () async { + expect(viewModel.isBusy, isFalse); + + await viewModel.futureToRun(); + + expect(viewModel.news, hasLength(2)); + expect(viewModel.news[0].title, equals('Mock News 1')); + expect(viewModel.news[1].title, equals('Mock News 2')); + expect(viewModel.isBusy, isFalse); + }); + }); +}