diff --git a/lib/features/dashboard/widgets-dashboard/about_us_card.dart b/lib/features/dashboard/widgets-dashboard/about_us_card.dart new file mode 100644 index 000000000..065a63240 --- /dev/null +++ b/lib/features/dashboard/widgets-dashboard/about_us_card.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:notredame/features/app/analytics/analytics_service.dart'; +import 'package:notredame/features/dashboard/dashboard_viewmodel.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:notredame/constants/urls.dart'; +import 'package:notredame/utils/app_theme.dart'; +import 'package:notredame/features/app/widgets/dismissible_card.dart'; +import 'package:notredame/constants/preferences_flags.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class AboutUsCard extends StatelessWidget { + final DashboardViewModel model; + final PreferencesFlag flag; + final AnalyticsService analyticsService; + static const String tag = "DashboardView"; + + const AboutUsCard({ + super.key, + required this.model, + required this.flag, + required this.analyticsService, + }); + + @override + Widget build(BuildContext context) { + return DismissibleCard( + key: UniqueKey(), + onDismissed: (DismissDirection direction) { + model.hideCard(flag); + }, + cardColor: AppTheme.appletsPurple, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.fromLTRB(17, 15, 0, 0), + child: Text(AppIntl.of(context)!.card_applets_title, + style: Theme.of(context).primaryTextTheme.titleLarge), + )), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(17, 10, 15, 10), + child: Text(AppIntl.of(context)!.card_applets_text, + style: Theme.of(context).primaryTextTheme.bodyMedium), + ), + Container( + padding: const EdgeInsets.fromLTRB(10, 0, 0, 0), + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Wrap(spacing: 15.0, children: [ + IconButton( + onPressed: () { + analyticsService.logEvent(tag, "Facebook clicked"); + Utils.launchURL(Urls.clubFacebook, AppIntl.of(context)!); + }, + icon: const FaIcon( + FontAwesomeIcons.facebook, + color: Colors.white, + ), + ), + IconButton( + onPressed: () { + analyticsService.logEvent(tag, "Instagram clicked"); + Utils.launchURL(Urls.clubInstagram, AppIntl.of(context)!); + }, + icon: const FaIcon( + FontAwesomeIcons.instagram, + color: Colors.white, + ), + ), + IconButton( + onPressed: () { + analyticsService.logEvent(tag, "Github clicked"); + Utils.launchURL(Urls.clubGithub, AppIntl.of(context)!); + }, + icon: const FaIcon( + FontAwesomeIcons.github, + color: Colors.white, + ), + ), + IconButton( + onPressed: () { + analyticsService.logEvent(tag, "Email clicked"); + Utils.launchURL(Urls.clubEmail, AppIntl.of(context)!); + }, + icon: const FaIcon( + FontAwesomeIcons.envelope, + color: Colors.white, + ), + ), + IconButton( + onPressed: () { + analyticsService.logEvent(tag, "Discord clicked"); + Utils.launchURL(Urls.clubDiscord, AppIntl.of(context)!); + }, + icon: const FaIcon( + FontAwesomeIcons.discord, + color: Colors.white, + ), + ), + ]), + ), + ), + ], + ), + ]), + ); + } +} diff --git a/lib/features/dashboard/widgets-dashboard/grades_card.dart b/lib/features/dashboard/widgets-dashboard/grades_card.dart new file mode 100644 index 000000000..41243b311 --- /dev/null +++ b/lib/features/dashboard/widgets-dashboard/grades_card.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:notredame/features/app/navigation/navigation_service.dart'; +import 'package:notredame/features/app/navigation/router_paths.dart'; +import 'package:notredame/features/app/widgets/dismissible_card.dart'; +import 'package:notredame/features/dashboard/dashboard_viewmodel.dart'; +import 'package:notredame/features/student/grades/widgets/grade_button.dart'; +import 'package:notredame/utils/app_theme.dart'; +import 'package:notredame/constants/preferences_flags.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class GradesCard extends StatelessWidget { + final DashboardViewModel model; + final PreferencesFlag flag; + final VoidCallback dismissCard; + + const GradesCard({ + required this.model, + required this.flag, + required this.dismissCard, + super.key, + }); + + @override + Widget build(BuildContext context) { + final NavigationService navigationService = NavigationService(); + + return DismissibleCard( + key: UniqueKey(), + onDismissed: (DismissDirection direction) { + dismissCard(); + }, + isBusy: model.busy(model.courses), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.fromLTRB(17, 15, 0, 0), + child: GestureDetector( + onTap: () => navigationService + .pushNamedAndRemoveUntil(RouterPaths.student), + child: Text(AppIntl.of(context)!.grades_title, + style: Theme.of(context).textTheme.titleLarge), + ), + ), + ), + if (model.courses.isEmpty) + SizedBox( + height: 100, + child: Center( + child: Text(AppIntl.of(context)! + .grades_msg_no_grades + .split("\n") + .first)), + ) + else + Container( + padding: const EdgeInsets.fromLTRB(17, 10, 15, 10), + child: Wrap( + children: model.courses + .map((course) => GradeButton(course, + color: + Theme.of(context).brightness == Brightness.light + ? AppTheme.lightThemeBackground + : AppTheme.darkThemeBackground)) + .toList(), + ), + ) + ]), + ); + } +} diff --git a/lib/features/dashboard/widgets-dashboard/message_broadcast_card.dart b/lib/features/dashboard/widgets-dashboard/message_broadcast_card.dart new file mode 100644 index 000000000..1d99bf28c --- /dev/null +++ b/lib/features/dashboard/widgets-dashboard/message_broadcast_card.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:notredame/features/app/widgets/dismissible_card.dart'; + +class MessageBroadcastCard extends StatelessWidget { + final String title; + final String content; + final VoidCallback onDismissed; + final String broadcastColor; + + const MessageBroadcastCard({ + required this.title, + required this.content, + required this.onDismissed, + required this.broadcastColor, + super.key, + }); + + @override + Widget build(BuildContext context) { + final broadcastMsgColor = Color(int.parse(broadcastColor)); + return DismissibleCard( + key: UniqueKey(), + onDismissed: (DismissDirection direction) { + onDismissed(); + }, + cardColor: broadcastMsgColor, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.fromLTRB(17, 15, 0, 0), + child: Text(title, + style: Theme.of(context).primaryTextTheme.titleLarge), + )), + Container( + padding: const EdgeInsets.fromLTRB(17, 0, 17, 8), + child: Text(content, + style: Theme.of(context).primaryTextTheme.bodyMedium), + ), + ]), + ); + } +} diff --git a/lib/features/dashboard/widgets-dashboard/progress_bar_card.dart b/lib/features/dashboard/widgets-dashboard/progress_bar_card.dart new file mode 100644 index 000000000..83c96c664 --- /dev/null +++ b/lib/features/dashboard/widgets-dashboard/progress_bar_card.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:notredame/constants/preferences_flags.dart'; +import 'package:notredame/features/app/widgets/dismissible_card.dart'; +import 'package:notredame/utils/app_theme.dart'; +import 'package:notredame/features/dashboard/dashboard_viewmodel.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ProgressBarCard extends StatefulWidget { + final DashboardViewModel model; + final PreferencesFlag flag; + final Text? progressBarText; + final VoidCallback dismissCard; + final VoidCallback changeProgressBarText; + final VoidCallback setText; + + const ProgressBarCard({ + required this.model, + required this.flag, + required this.progressBarText, + required this.dismissCard, + required this.changeProgressBarText, + required this.setText, + super.key, + }); + + @override + _ProgressBarCardState createState() => _ProgressBarCardState(); +} + +class _ProgressBarCardState extends State { + @override + Widget build(BuildContext context) { + return DismissibleCard( + isBusy: widget.model.busy(widget.model.progress), + key: UniqueKey(), + onDismissed: (DismissDirection direction) { + widget.dismissCard(); + }, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.fromLTRB(17, 15, 0, 0), + child: Text(AppIntl.of(context)!.progress_bar_title, + style: Theme.of(context).textTheme.titleLarge), + )), + if (widget.model.progress >= 0.0) + Stack(children: [ + Container( + padding: const EdgeInsets.fromLTRB(17, 10, 15, 20), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(10)), + child: GestureDetector( + onTap: () => setState(() { + widget.changeProgressBarText(); + widget.setText(); + }), + child: LinearProgressIndicator( + value: widget.model.progress, + minHeight: 30, + valueColor: const AlwaysStoppedAnimation( + AppTheme.gradeGoodMax), + backgroundColor: AppTheme.etsDarkGrey, + ), + ), + ), + ), + GestureDetector( + onTap: () => setState(() { + widget.changeProgressBarText(); + widget.setText(); + }), + child: Container( + padding: const EdgeInsets.only(top: 16), + child: Center( + child: widget.progressBarText ?? + Text( + AppIntl.of(context)!.progress_bar_message( + widget.model.sessionDays[0], + widget.model.sessionDays[1]), + style: const TextStyle(color: Colors.white), + ), + ), + ), + ), + ]) + else + Container( + padding: const EdgeInsets.all(16), + child: Center( + child: Text(AppIntl.of(context)!.session_without), + ), + ), + ]), + ); + } +} diff --git a/lib/features/dashboard/widgets-dashboard/schedule_card.dart b/lib/features/dashboard/widgets-dashboard/schedule_card.dart new file mode 100644 index 000000000..586c2e259 --- /dev/null +++ b/lib/features/dashboard/widgets-dashboard/schedule_card.dart @@ -0,0 +1,79 @@ +import 'package:ets_api_clients/models.dart'; +import 'package:flutter/material.dart'; +import 'package:notredame/constants/preferences_flags.dart'; +import 'package:notredame/features/app/navigation/navigation_service.dart'; +import 'package:notredame/features/app/navigation/router_paths.dart'; +import 'package:notredame/features/app/widgets/dismissible_card.dart'; +import 'package:notredame/features/dashboard/widgets/course_activity_tile.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/dashboard/dashboard_viewmodel.dart'; + +class ScheduleCard extends StatelessWidget { + final DashboardViewModel model; + final PreferencesFlag flag; + final VoidCallback dismissCard; + final NavigationService navigationService; + + const ScheduleCard({ + required this.model, + required this.flag, + required this.dismissCard, + required this.navigationService, + super.key, + }); + + @override + Widget build(BuildContext context) { + var title = AppIntl.of(context)!.title_schedule; + if (model.todayDateEvents.isEmpty && model.tomorrowDateEvents.isNotEmpty) { + title = title + AppIntl.of(context)!.card_schedule_tomorrow; + } + return DismissibleCard( + isBusy: model.busy(model.todayDateEvents) || + model.busy(model.tomorrowDateEvents), + onDismissed: (DismissDirection direction) { + dismissCard(); + }, + key: UniqueKey(), + child: Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.fromLTRB(17, 15, 0, 0), + child: GestureDetector( + onTap: () => navigationService + .pushNamedAndRemoveUntil(RouterPaths.schedule), + child: Text(title, + style: Theme.of(context).textTheme.titleLarge), + ), + )), + if (model.todayDateEvents.isEmpty) + if (model.tomorrowDateEvents.isEmpty) + SizedBox( + height: 100, + child: Center( + child: Text(AppIntl.of(context)!.schedule_no_event))) + else + _buildEventList(model.tomorrowDateEvents) + else + _buildEventList(model.todayDateEvents) + ]), + ), + ); + } + + Widget _buildEventList(List events) { + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(0, 10, 0, 10), + itemBuilder: (_, index) => + CourseActivityTile(events[index] as CourseActivity), + separatorBuilder: (_, index) => (index < events.length) + ? const Divider(thickness: 1, indent: 30, endIndent: 30) + : const SizedBox(), + itemCount: events.length); + } +} diff --git a/lib/features/ets/events/author/author_view.dart b/lib/features/ets/events/author/author_view.dart index 465f2c2fd..ad2684ec5 100644 --- a/lib/features/ets/events/author/author_view.dart +++ b/lib/features/ets/events/author/author_view.dart @@ -4,355 +4,26 @@ import 'package:flutter/material.dart'; // Package imports: import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:notredame/features/ets/events/social/models/social_link.dart'; +import 'package:notredame/features/ets/events/author/widget/author_info_widget.dart'; +import 'package:notredame/features/ets/events/author/widget/avatar_widget.dart'; +import 'package:notredame/features/ets/events/author/widget/back_button_widget.dart'; +import 'package:notredame/features/ets/events/author/widget/no_more_news_card_widget.dart'; import 'package:stacked/stacked.dart'; // Project imports: -import 'package:notredame/utils/utils.dart'; import 'package:notredame/features/ets/events/author/author_viewmodel.dart'; -import 'package:notredame/utils/app_theme.dart'; -import 'package:notredame/features/ets/events/author/author_info_skeleton.dart'; import 'package:notredame/features/app/widgets/base_scaffold.dart'; import 'package:notredame/features/ets/events/news/widgets/news_card.dart'; import 'package:notredame/features/ets/events/news/widgets/news_card_skeleton.dart'; -import 'package:notredame/features/ets/events/social/social_links_card.dart'; -class AuthorView extends StatefulWidget { +class AuthorView extends StatelessWidget { final String authorId; - const AuthorView({required this.authorId}); + const AuthorView({required this.authorId, super.key}); - @override - State createState() => _AuthorViewState(); -} - -class _AuthorViewState extends State { - static const int _nbSkeletons = 3; - late String notifyBtnText; - - @override - Widget build(BuildContext context) => - ViewModelBuilder.reactive( - viewModelBuilder: () => AuthorViewModel( - authorId: widget.authorId, appIntl: AppIntl.of(context)!), - onViewModelReady: (model) { - model.fetchAuthorData(); - 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) => BaseScaffold( - showBottomBar: false, - body: RefreshIndicator( - onRefresh: () => Future.sync( - () => model.pagingController.refresh(), - ), - child: Theme( - data: Theme.of(context) - .copyWith(canvasColor: Colors.transparent), - child: Padding( - padding: const EdgeInsets.only(top: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - _buildBackButton(), - _buildAuthorInfo(model), - _buildAvatar(model, widget.authorId), - ], - ), - Expanded( - 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 _buildBackButton() { - return IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), - ); - } - - Widget _buildAuthorInfo(AuthorViewModel model) { - notifyBtnText = getNotifyMeBtnText(model); - - final author = model.author; - - List socialLinks = []; - if (author != null) { - socialLinks = [ - if (author.email != null) - SocialLink(id: 0, name: 'Email', link: author.email!), - if (author.facebookLink != null) - SocialLink(id: 1, name: 'Facebook', link: author.facebookLink!), - if (author.instagramLink != null) - SocialLink(id: 2, name: 'Instagram', link: author.instagramLink!), - if (author.tikTokLink != null) - SocialLink(id: 3, name: 'TikTok', link: author.tikTokLink!), - if (author.xLink != null) - SocialLink(id: 4, name: 'X', link: author.xLink!), - if (author.redditLink != null) - SocialLink(id: 5, name: 'Reddit', link: author.redditLink!), - if (author.discordLink != null) - SocialLink(id: 6, name: 'Discord', link: author.discordLink!), - if (author.linkedInLink != null) - SocialLink(id: 7, name: 'LinkedIn', link: author.linkedInLink!), - ]; - } - - return Padding( - padding: const EdgeInsets.only(top: 76), - child: model.isBusy - ? AuthorInfoSkeleton() - : SizedBox( - width: double.infinity, - child: Card( - color: Utils.getColorByBrightnessNullable( - context, AppTheme.newsSecondaryColor, null), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16)), - key: UniqueKey(), - child: Container( - padding: const EdgeInsets.fromLTRB(32, 64, 32, 16), - child: Column( - children: [ - if (author?.organization != null || - author?.organization != "") - Text( - author?.organization ?? "", - style: const TextStyle(fontSize: 26), - ), - if (author?.organization != null && - author?.organization != "") - const SizedBox(height: 8), - if (author?.profileDescription != null && - author?.profileDescription != "") - Text( - author?.profileDescription ?? "", - style: TextStyle( - color: Utils.getColorByBrightness( - context, - AppTheme.etsDarkGrey, - AppTheme.newsSecondaryColor, - ), - fontSize: 16, - ), - textAlign: TextAlign.center, - ), - if (author?.profileDescription != null && - author?.profileDescription != "") - const SizedBox(height: 8), - IconButton( - onPressed: () async { - await showModalBottomSheet( - isDismissible: true, - enableDrag: true, - isScrollControlled: true, - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ), - ), - builder: (context) => SocialLinks( - socialLinks: socialLinks, - ), - ); - }, - icon: FaIcon( - FontAwesomeIcons.link, - color: Utils.getColorByBrightness( - context, - AppTheme.newsAccentColorLight, - AppTheme.newsAccentColorDark, - ), - ), - style: ButtonStyle( - shape: - MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - backgroundColor: MaterialStateProperty.all( - Utils.getColorByBrightness( - context, - AppTheme.lightThemeBackground, - AppTheme.darkThemeBackground, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - String getNotifyMeBtnText(AuthorViewModel model) { - return model.isNotified - ? AppIntl.of(context)!.news_author_dont_notify_me - : AppIntl.of(context)!.news_author_notify_me; - } - - Widget _buildAvatar(AuthorViewModel model, String authorId) { - return model.isBusy - ? AvatarSkeleton() - : Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.only(top: 16), - child: SizedBox( - width: 120, - height: 120, - child: Hero( - tag: 'news_author_avatar', - child: CircleAvatar( - backgroundColor: Utils.getColorByBrightness(context, - AppTheme.lightThemeAccent, AppTheme.darkThemeAccent), - child: Stack( - fit: StackFit.expand, - children: [ - if (model.author?.avatarUrl != null && - model.author!.avatarUrl != "") - ClipRRect( - borderRadius: BorderRadius.circular(120), - child: Image.network( - model.author!.avatarUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Center( - child: Text( - model.author?.organization - ?.substring(0, 1) ?? - '', - style: TextStyle( - fontSize: 56, - color: Utils.getColorByBrightness( - context, - Colors.black, - Colors.white)), - ), - ); - }, - )), - if (model.author?.avatarUrl == null || - model.author!.avatarUrl == "") - Center( - child: Text( - model.author?.organization?.substring(0, 1) ?? '', - style: TextStyle( - fontSize: 56, - color: Utils.getColorByBrightness( - context, Colors.black, Colors.white)), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - Widget _buildSkeletonLoader() { - 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) { + Widget _buildErrorWidget( + PagingController pagingController, BuildContext context) { return Scaffold( body: SafeArea( minimum: const EdgeInsets.all(20), @@ -396,4 +67,77 @@ class _AuthorViewState extends State { ), ); } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => AuthorViewModel( + authorId: authorId, + appIntl: AppIntl.of(context)!, + ), + onViewModelReady: (model) { + model.fetchAuthorData(); + 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 BaseScaffold( + showBottomBar: false, + body: RefreshIndicator( + onRefresh: () => + Future.sync(() => model.pagingController.refresh()), + child: Theme( + data: Theme.of(context).copyWith(canvasColor: Colors.transparent), + child: Padding( + padding: const EdgeInsets.only(top: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Stack( + children: [ + BackButtonWidget(), + AuthorInfoWidget(), + AvatarWidget(), + ], + ), + Expanded( + 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) => + NewsCardSkeleton(), + newPageProgressIndicatorBuilder: (context) => + NewsCardSkeleton(), + noMoreItemsIndicatorBuilder: (context) => + const NoMoreNewsCardWidget(), + firstPageErrorIndicatorBuilder: (context) => + _buildErrorWidget( + model.pagingController, context), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } } diff --git a/lib/features/ets/events/author/author_viewmodel.dart b/lib/features/ets/events/author/author_viewmodel.dart index 2ce4fae6c..6724eb001 100644 --- a/lib/features/ets/events/author/author_viewmodel.dart +++ b/lib/features/ets/events/author/author_viewmodel.dart @@ -1,11 +1,8 @@ -// Package imports: import 'package:ets_api_clients/models.dart'; 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'; - -// Project imports: import 'package:notredame/features/app/repository/author_repository.dart'; import 'package:notredame/features/app/repository/news_repository.dart'; import 'package:notredame/utils/locator.dart'; @@ -14,15 +11,10 @@ class AuthorViewModel extends BaseViewModel implements Initialisable { final AuthorRepository _authorRepository = locator(); final NewsRepository _newsRepository = locator(); - /// Localization class of the application. final AppIntl appIntl; - final String authorId; - /// Author Organizer? _author; - - /// Return the author Organizer? get author => _author; final PagingController pagingController = @@ -35,7 +27,6 @@ class AuthorViewModel extends BaseViewModel implements Initialisable { @override Future initialise() async { - // This will be called when init state cycle runs pagingController.addPageRequestListener((pageKey) { fetchPage(pageKey); }); @@ -58,16 +49,17 @@ class AuthorViewModel extends BaseViewModel implements Initialisable { } void notifyMe() { - // TODO activate/deactivate notifications isNotified = !isNotified; if (isNotified) { Fluttertoast.showToast( - msg: appIntl.news_author_notified_for(author?.organization ?? ""), - toastLength: Toast.LENGTH_LONG); + msg: appIntl.news_author_notified_for(author?.organization ?? ""), + toastLength: Toast.LENGTH_LONG, + ); } else { Fluttertoast.showToast( - msg: appIntl.news_author_not_notified_for(author?.organization ?? ""), - toastLength: Toast.LENGTH_LONG); + msg: appIntl.news_author_not_notified_for(author?.organization ?? ""), + toastLength: Toast.LENGTH_LONG, + ); } } diff --git a/lib/features/ets/events/author/widget/author_info_widget.dart b/lib/features/ets/events/author/widget/author_info_widget.dart new file mode 100644 index 000000000..f9f1cae08 --- /dev/null +++ b/lib/features/ets/events/author/widget/author_info_widget.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:notredame/features/ets/events/author/author_info_skeleton.dart'; +import 'package:notredame/features/ets/events/social/models/social_link.dart'; +import 'package:notredame/features/ets/events/social/social_links_card.dart'; +import 'package:notredame/features/ets/events/author/author_viewmodel.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:notredame/utils/app_theme.dart'; +import 'package:stacked/stacked.dart'; + +class AuthorInfoWidget extends ViewModelWidget { + const AuthorInfoWidget({super.key}); + + @override + Widget build(BuildContext context, AuthorViewModel model) { + final author = model.author; + + List socialLinks = []; + if (author != null) { + socialLinks = [ + if (author.email != null) + SocialLink(id: 0, name: 'Email', link: author.email!), + if (author.facebookLink != null) + SocialLink(id: 1, name: 'Facebook', link: author.facebookLink!), + if (author.instagramLink != null) + SocialLink(id: 2, name: 'Instagram', link: author.instagramLink!), + if (author.tikTokLink != null) + SocialLink(id: 3, name: 'TikTok', link: author.tikTokLink!), + if (author.xLink != null) + SocialLink(id: 4, name: 'X', link: author.xLink!), + if (author.redditLink != null) + SocialLink(id: 5, name: 'Reddit', link: author.redditLink!), + if (author.discordLink != null) + SocialLink(id: 6, name: 'Discord', link: author.discordLink!), + if (author.linkedInLink != null) + SocialLink(id: 7, name: 'LinkedIn', link: author.linkedInLink!), + ]; + } + + return Padding( + padding: const EdgeInsets.only(top: 76), + child: model.isBusy + ? AuthorInfoSkeleton() + : SizedBox( + width: double.infinity, + child: Card( + color: Utils.getColorByBrightnessNullable( + context, AppTheme.newsSecondaryColor, null), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16)), + child: Container( + padding: const EdgeInsets.fromLTRB(32, 64, 32, 16), + child: Column( + children: [ + if (author?.organization != null || + author?.organization != "") + Text( + author?.organization ?? "", + style: const TextStyle(fontSize: 26), + ), + if (author?.organization != null && + author?.organization != "") + const SizedBox(height: 8), + if (author?.profileDescription != null && + author?.profileDescription != "") + Text( + author?.profileDescription ?? "", + style: TextStyle( + color: Utils.getColorByBrightness( + context, + AppTheme.etsDarkGrey, + AppTheme.newsSecondaryColor), + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + if (author?.profileDescription != null && + author?.profileDescription != "") + const SizedBox(height: 8), + IconButton( + onPressed: () async { + await showModalBottomSheet( + isDismissible: true, + enableDrag: true, + isScrollControlled: true, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(10)), + ), + builder: (context) => + SocialLinks(socialLinks: socialLinks), + ); + }, + icon: FaIcon( + FontAwesomeIcons.link, + color: Utils.getColorByBrightness( + context, + AppTheme.newsAccentColorLight, + AppTheme.newsAccentColorDark), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/ets/events/author/widget/avatar_widget.dart b/lib/features/ets/events/author/widget/avatar_widget.dart new file mode 100644 index 000000000..dcb1f3051 --- /dev/null +++ b/lib/features/ets/events/author/widget/avatar_widget.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:notredame/features/ets/events/author/author_info_skeleton.dart'; +import 'package:notredame/features/ets/events/author/author_viewmodel.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:notredame/utils/app_theme.dart'; +import 'package:stacked/stacked.dart'; + +class AvatarWidget extends ViewModelWidget { + const AvatarWidget({super.key}); + + @override + Widget build(BuildContext context, AuthorViewModel model) { + return model.isBusy + ? AvatarSkeleton() + : Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: SizedBox( + width: 120, + height: 120, + child: Hero( + tag: 'news_author_avatar', + child: CircleAvatar( + backgroundColor: Utils.getColorByBrightness(context, + AppTheme.lightThemeAccent, AppTheme.darkThemeAccent), + child: Stack( + fit: StackFit.expand, + children: [ + if (model.author?.avatarUrl != null && + model.author!.avatarUrl != "") + ClipRRect( + borderRadius: BorderRadius.circular(120), + child: Image.network( + model.author!.avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Text( + model.author?.organization + ?.substring(0, 1) ?? + '', + style: TextStyle( + fontSize: 56, + color: Utils.getColorByBrightness( + context, + Colors.black, + Colors.white)), + ), + ); + }, + ), + ), + if (model.author?.avatarUrl == null || + model.author!.avatarUrl == "") + Center( + child: Text( + model.author?.organization?.substring(0, 1) ?? '', + style: TextStyle( + fontSize: 56, + color: Utils.getColorByBrightness( + context, Colors.black, Colors.white)), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/ets/events/author/widget/back_button_widget.dart b/lib/features/ets/events/author/widget/back_button_widget.dart new file mode 100644 index 000000000..058c08147 --- /dev/null +++ b/lib/features/ets/events/author/widget/back_button_widget.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class BackButtonWidget extends StatelessWidget { + const BackButtonWidget({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ); + } +} diff --git a/lib/features/ets/events/author/widget/no_more_news_card_widget.dart b/lib/features/ets/events/author/widget/no_more_news_card_widget.dart new file mode 100644 index 000000000..70efd43ae --- /dev/null +++ b/lib/features/ets/events/author/widget/no_more_news_card_widget.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class NoMoreNewsCardWidget extends StatelessWidget { + const NoMoreNewsCardWidget({super.key}); + + @override + Widget build(BuildContext context) { + 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, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/ets/quick-link/widgets/security-info/emergency_view.dart b/lib/features/ets/quick-link/widgets/security-info/emergency_view.dart index eb4143221..17c8a7c6c 100644 --- a/lib/features/ets/quick-link/widgets/security-info/emergency_view.dart +++ b/lib/features/ets/quick-link/widgets/security-info/emergency_view.dart @@ -1,14 +1,7 @@ -// Flutter imports: import 'package:flutter/material.dart'; - -// Package imports: -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/ets/quick-link/widgets/security-info/widget/emergency_floating_button.dart'; import 'package:webview_flutter/webview_flutter.dart'; - -// Project imports: import 'package:notredame/features/app/presentation/webview_controller_extension.dart'; -import 'package:notredame/utils/utils.dart'; -import 'package:notredame/utils/app_theme.dart'; class EmergencyView extends StatefulWidget { final String title; @@ -25,26 +18,11 @@ class _EmergencyViewState extends State { Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: Text(widget.title)), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Utils.launchURL( - 'tel:${AppIntl.of(context)!.security_emergency_number}', - AppIntl.of(context)!) - .catchError((error) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(error.toString()))); - }); - }, - label: Text( - AppIntl.of(context)!.security_reach_security, - style: const TextStyle(color: Colors.white, fontSize: 20), - ), - icon: const Icon(Icons.phone, size: 30, color: Colors.white), - backgroundColor: AppTheme.etsLightRed, - ), + floatingActionButton: const EmergencyFloatingButton(), body: WebViewWidget( - controller: WebViewControllerExtension(WebViewController()) - ..loadHtmlFromAssets( - widget.description, Theme.of(context).brightness)), + controller: WebViewControllerExtension(WebViewController()) + ..loadHtmlFromAssets( + widget.description, Theme.of(context).brightness), + ), ); } diff --git a/lib/features/ets/quick-link/widgets/security-info/widget/emergency_floating_button.dart b/lib/features/ets/quick-link/widgets/security-info/widget/emergency_floating_button.dart new file mode 100644 index 000000000..750a25675 --- /dev/null +++ b/lib/features/ets/quick-link/widgets/security-info/widget/emergency_floating_button.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:notredame/utils/app_theme.dart'; + +class EmergencyFloatingButton extends StatelessWidget { + const EmergencyFloatingButton({super.key}); + + @override + Widget build(BuildContext context) { + return FloatingActionButton.extended( + onPressed: () { + Utils.launchURL('tel:${AppIntl.of(context)!.security_emergency_number}', + AppIntl.of(context)!) + .catchError((error) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(error.toString()))); + }); + }, + label: Text( + AppIntl.of(context)!.security_reach_security, + style: const TextStyle(color: Colors.white, fontSize: 20), + ), + icon: const Icon(Icons.phone, size: 30, color: Colors.white), + backgroundColor: AppTheme.etsLightRed, + ); + } +} diff --git a/lib/features/more/about/about_view.dart b/lib/features/more/about/about_view.dart index 54183a454..41f3c91b7 100644 --- a/lib/features/more/about/about_view.dart +++ b/lib/features/more/about/about_view.dart @@ -2,13 +2,9 @@ import 'package:flutter/material.dart'; // Package imports: -import 'package:easter_egg_trigger/easter_egg_trigger.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; - -// Project imports: -import 'package:notredame/constants/urls.dart'; -import 'package:notredame/utils/utils.dart'; +import 'package:notredame/features/more/about/widget/easter_egg_icon.dart'; // Importez le widget extrait +import 'package:notredame/features/more/about/widget/social_icons_row.dart'; // Importez le widget extrait class AboutView extends StatefulWidget { @override @@ -49,6 +45,10 @@ class _AboutViewState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { + return newMethod(context); + } + + Scaffold newMethod(BuildContext context) { return Scaffold( extendBodyBehindAppBar: true, appBar: AppBar( @@ -88,26 +88,9 @@ class _AboutViewState extends State with TickerProviderStateMixin { left: 0, child: Column( children: [ - EasterEggTrigger( - action: () => toggleTrigger(), - codes: const [ - EasterEggTriggers.SwipeUp, - EasterEggTriggers.SwipeRight, - EasterEggTriggers.SwipeDown, - EasterEggTriggers.SwipeLeft, - EasterEggTriggers.Tap - ], - child: SizedBox( - width: 100, - height: 100, - child: Hero( - tag: 'about', - child: Image.asset( - "assets/images/favicon_applets.png", - scale: 2.0, - ), - ), - ), + EasterEggIcon( + toggleTrigger: toggleTrigger, + easterEggTrigger: _easterEggTrigger, ), Padding( padding: const EdgeInsets.all(16.0), @@ -116,68 +99,7 @@ class _AboutViewState extends State with TickerProviderStateMixin { style: const TextStyle(color: Colors.white), ), ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - icon: const FaIcon( - FontAwesomeIcons.earthAmericas, - color: Colors.white, - ), - onPressed: () => Utils.launchURL( - Urls.clubWebsite, AppIntl.of(context)!)), - IconButton( - icon: const FaIcon( - FontAwesomeIcons.github, - color: Colors.white, - ), - onPressed: () => Utils.launchURL( - Urls.clubGithub, AppIntl.of(context)!)), - IconButton( - icon: const FaIcon( - FontAwesomeIcons.facebook, - color: Colors.white, - ), - onPressed: () => Utils.launchURL( - Urls.clubFacebook, AppIntl.of(context)!)), - IconButton( - icon: const FaIcon( - FontAwesomeIcons.twitter, - color: Colors.white, - ), - onPressed: () => Utils.launchURL( - Urls.clubTwitter, AppIntl.of(context)!)), - IconButton( - icon: const FaIcon( - FontAwesomeIcons.youtube, - color: Colors.white, - ), - onPressed: () => Utils.launchURL( - Urls.clubYoutube, AppIntl.of(context)!)), - IconButton( - icon: const FaIcon( - FontAwesomeIcons.discord, - color: Colors.white, - ), - onPressed: () => Utils.launchURL( - Urls.clubDiscord, AppIntl.of(context)!)), - ], - ), - ), - if (_easterEggTrigger) - SizedBox( - width: 200, - height: 200, - child: Hero( - tag: 'capra', - child: Image.asset( - "assets/images/capra_long.png", - scale: 1.0, - ), - ), - ), + const SocialIconsRow(), ], ), ), diff --git a/lib/features/more/about/widget/easter_egg_icon.dart b/lib/features/more/about/widget/easter_egg_icon.dart new file mode 100644 index 000000000..f84302dd5 --- /dev/null +++ b/lib/features/more/about/widget/easter_egg_icon.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:easter_egg_trigger/easter_egg_trigger.dart'; + +class EasterEggIcon extends StatelessWidget { + final Function toggleTrigger; + final bool easterEggTrigger; + + const EasterEggIcon( + {required this.toggleTrigger, required this.easterEggTrigger}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + EasterEggTrigger( + // ignore: avoid_dynamic_calls + action: () => toggleTrigger(), + codes: const [ + EasterEggTriggers.SwipeUp, + EasterEggTriggers.SwipeRight, + EasterEggTriggers.SwipeDown, + EasterEggTriggers.SwipeLeft, + EasterEggTriggers.Tap + ], + child: SizedBox( + width: 100, + height: 100, + child: Hero( + tag: 'about', + child: Image.asset( + "assets/images/favicon_applets.png", + scale: 2.0, + ), + ), + ), + ), + if (easterEggTrigger) + SizedBox( + width: 200, + height: 200, + child: Hero( + tag: 'capra', + child: Image.asset( + "assets/images/capra_long.png", + scale: 1.0, + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/more/about/widget/social_icons_row.dart b/lib/features/more/about/widget/social_icons_row.dart new file mode 100644 index 000000000..00f94582d --- /dev/null +++ b/lib/features/more/about/widget/social_icons_row.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:notredame/constants/urls.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class SocialIconsRow extends StatelessWidget { + const SocialIconsRow(); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + icon: const FaIcon( + FontAwesomeIcons.earthAmericas, + color: Colors.white, + ), + onPressed: () => + Utils.launchURL(Urls.clubWebsite, AppIntl.of(context)!), + ), + IconButton( + icon: const FaIcon( + FontAwesomeIcons.github, + color: Colors.white, + ), + onPressed: () => + Utils.launchURL(Urls.clubGithub, AppIntl.of(context)!), + ), + IconButton( + icon: const FaIcon( + FontAwesomeIcons.facebook, + color: Colors.white, + ), + onPressed: () => + Utils.launchURL(Urls.clubFacebook, AppIntl.of(context)!), + ), + IconButton( + icon: const FaIcon( + FontAwesomeIcons.twitter, + color: Colors.white, + ), + onPressed: () => + Utils.launchURL(Urls.clubTwitter, AppIntl.of(context)!), + ), + IconButton( + icon: const FaIcon( + FontAwesomeIcons.youtube, + color: Colors.white, + ), + onPressed: () => + Utils.launchURL(Urls.clubYoutube, AppIntl.of(context)!), + ), + IconButton( + icon: const FaIcon( + FontAwesomeIcons.discord, + color: Colors.white, + ), + onPressed: () => + Utils.launchURL(Urls.clubDiscord, AppIntl.of(context)!), + ), + ], + ), + ); + } +} diff --git a/lib/features/more/contributors/contributors_view.dart b/lib/features/more/contributors/contributors_view.dart index efecdf1ee..eb1589413 100644 --- a/lib/features/more/contributors/contributors_view.dart +++ b/lib/features/more/contributors/contributors_view.dart @@ -1,15 +1,9 @@ -// Flutter imports: import 'package:flutter/material.dart'; - -// Package imports: import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:github/github.dart'; +import 'package:notredame/features/more/contributors/widgets/contributor_title_widget.dart'; import 'package:stacked/stacked.dart'; - -// Project imports: -import 'package:notredame/utils/utils.dart'; +import 'package:github/github.dart'; import 'package:notredame/features/more/contributors/contributors_viewmodel.dart'; -import 'package:notredame/utils/loading.dart'; import 'package:notredame/features/app/widgets/base_scaffold.dart'; class ContributorsView extends StatelessWidget { @@ -27,19 +21,13 @@ class ContributorsView extends StatelessWidget { future: model.contributors, builder: (context, snapshot) { if (!snapshot.hasData) { - return buildLoading(); + return const Center(child: CircularProgressIndicator()); } return ListView.builder( padding: EdgeInsets.zero, itemCount: snapshot.data!.length, - itemBuilder: (context, index) => ListTile( - title: Text(snapshot.data![index].login ?? ''), - leading: CircleAvatar( - backgroundImage: NetworkImage( - snapshot.data![index].avatarUrl ?? '')), - onTap: () => Utils.launchURL( - snapshot.data![index].htmlUrl ?? '', - AppIntl.of(context)!), + itemBuilder: (context, index) => ContributorTileWidget( + contributor: snapshot.data![index], ), ); }, diff --git a/lib/features/more/contributors/widgets/contributor_title_widget.dart b/lib/features/more/contributors/widgets/contributor_title_widget.dart new file mode 100644 index 000000000..ded547c9c --- /dev/null +++ b/lib/features/more/contributors/widgets/contributor_title_widget.dart @@ -0,0 +1,26 @@ +// widgets/ContributorTileWidget.dart + +import 'package:flutter/material.dart'; +import 'package:github/github.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ContributorTileWidget extends StatelessWidget { + final Contributor contributor; + + const ContributorTileWidget({super.key, required this.contributor}); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(contributor.login ?? ''), + leading: CircleAvatar( + backgroundImage: NetworkImage(contributor.avatarUrl ?? ''), + ), + onTap: () => Utils.launchURL( + contributor.htmlUrl ?? '', + AppIntl.of(context)!, + ), + ); + } +} diff --git a/lib/features/more/faq/faq_view.dart b/lib/features/more/faq/faq_view.dart index cdbd740da..c15b0d800 100644 --- a/lib/features/more/faq/faq_view.dart +++ b/lib/features/more/faq/faq_view.dart @@ -1,14 +1,13 @@ -// Flutter imports: import 'package:flutter/material.dart'; - -// Package imports: -import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/more/faq/widget/action_card.dart'; +import 'package:notredame/features/more/faq/widget/faq_subtitle.dart'; +import 'package:notredame/features/more/faq/widget/faq_title.dart'; +import 'package:notredame/features/more/faq/widget/question_card.dart'; import 'package:stacked/stacked.dart'; +import 'package:carousel_slider/carousel_slider.dart'; -// Project imports: import 'package:notredame/features/more/faq/models/faq.dart'; -import 'package:notredame/features/more/faq/models/faq_actions.dart'; import 'package:notredame/features/more/faq/faq_viewmodel.dart'; class FaqView extends StatefulWidget { @@ -32,8 +31,11 @@ class _FaqViewState extends State { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - getTitle(), - getSubtitle(AppIntl.of(context)!.questions_and_answers), + FaqTitle(backgroundColor: widget.backgroundColor), + FaqSubtitle( + subtitle: AppIntl.of(context)!.questions_and_answers, + backgroundColor: widget.backgroundColor, + ), Padding( padding: const EdgeInsets.only(left: 15.0, right: 15.0), child: CarouselSlider( @@ -57,9 +59,11 @@ class _FaqViewState extends State { borderRadius: const BorderRadius.all(Radius.circular(8.0)), ), - child: getQuestionCard( - question.title[model.locale?.languageCode] ?? '', - question.description[ + child: QuestionCard( + title: + question.title[model.locale?.languageCode] ?? + '', + description: question.description[ model.locale?.languageCode] ?? '', ), @@ -69,7 +73,10 @@ class _FaqViewState extends State { }).toList(), ), ), - getSubtitle(AppIntl.of(context)!.actions), + FaqSubtitle( + subtitle: AppIntl.of(context)!.actions, + backgroundColor: widget.backgroundColor, + ), Expanded( child: ListView.builder( key: const Key("action_listview_key"), @@ -78,16 +85,19 @@ class _FaqViewState extends State { itemBuilder: (context, index) { final action = faq.actions[index]; - return getActionCard( - action.title[model.locale?.languageCode] ?? '', - action.description[model.locale?.languageCode] ?? '', - action.type, - action.link, - action.iconName, - action.iconColor, - action.circleColor, - context, - model); + return ActionCard( + title: action.title[model.locale?.languageCode] ?? '', + description: + action.description[model.locale?.languageCode] ?? + '', + type: action.type, + link: action.link, + iconName: action.iconName, + iconColor: action.iconColor, + circleColor: action.circleColor, + context: context, + model: model, + ); }, ), ) @@ -96,193 +106,4 @@ class _FaqViewState extends State { ); }, ); - - Padding getTitle() { - return Padding( - padding: const EdgeInsets.only(top: 60.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(left: 5.0), - child: GestureDetector( - onTap: () { - Navigator.of(context).pop(); - }, - child: Padding( - padding: const EdgeInsets.only(left: 10.0), - child: Icon( - Icons.arrow_back, - color: widget.backgroundColor == Colors.white - ? Colors.black - : Colors.white, - ), - ), - ), - ), - const SizedBox(width: 8.0), - Expanded( - child: Text( - AppIntl.of(context)!.need_help, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall!.copyWith( - color: widget.backgroundColor == Colors.white - ? Colors.black - : Colors.white, - ), - ), - ), - ], - ), - ); - } - - Padding getSubtitle(String subtitle) { - return Padding( - padding: const EdgeInsets.only(left: 18.0, top: 18.0, bottom: 10.0), - child: Text( - subtitle, - style: Theme.of(context).textTheme.headlineSmall!.copyWith( - color: widget.backgroundColor == Colors.white - ? Colors.black - : Colors.white, - ), - ), - ); - } - - Padding getQuestionCard(String title, String description) { - return Padding( - padding: const EdgeInsets.only(top: 20.0, left: 20.0, right: 20.0), - child: Align( - alignment: Alignment.topLeft, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - textScaler: TextScaler.noScaling, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 20, - color: Theme.of(context).brightness == Brightness.light - ? Colors.black - : Colors.white, - ), - textAlign: TextAlign.justify, - ), - Text( - description, - textScaler: TextScaler.noScaling, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - color: Theme.of(context).brightness == Brightness.light - ? Colors.black - : Colors.white, - ), - textAlign: TextAlign.justify, - ), - ], - ), - ), - ), - ); - } - - Padding getActionCard( - String title, - String description, - ActionType type, - String link, - IconData iconName, - Color iconColor, - Color circleColor, - BuildContext context, - FaqViewModel model) { - return Padding( - padding: const EdgeInsets.fromLTRB(15.0, 0.0, 15.0, 15.0), - child: ElevatedButton( - onPressed: () { - if (type.name == ActionType.webview.name) { - openWebview(model, link); - } else if (type.name == ActionType.email.name) { - openMail(model, context, link); - } - }, - style: ButtonStyle( - elevation: MaterialStateProperty.all(8.0), - shape: MaterialStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ), - )), - child: getActionCardInfo( - context, - title, - description, - iconName, - iconColor, - circleColor, - ), - ), - ); - } - - Row getActionCardInfo(BuildContext context, String title, String description, - IconData iconName, Color iconColor, Color circleColor) { - return Row( - children: [ - Column( - children: [ - CircleAvatar( - backgroundColor: circleColor, - radius: 25, - child: Icon(iconName, color: iconColor), - ), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(15, 15, 0, 15), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 18, - color: Theme.of(context).brightness == Brightness.light - ? Colors.black - : Colors.white, - ), - textAlign: TextAlign.left, - ), - const SizedBox(height: 10.0), - Text( - description, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - color: Theme.of(context).brightness == Brightness.light - ? Colors.black - : Colors.white, - ), - textAlign: TextAlign.justify, - ) - ], - ), - ), - ) - ], - ); - } - - Future openWebview(FaqViewModel model, String link) async { - model.launchWebsite(link, Theme.of(context).brightness); - } - - Future openMail( - FaqViewModel model, BuildContext context, String addressEmail) async { - model.openMail(addressEmail, context); - } } diff --git a/lib/features/more/faq/widget/action_card.dart b/lib/features/more/faq/widget/action_card.dart new file mode 100644 index 000000000..df22e9d29 --- /dev/null +++ b/lib/features/more/faq/widget/action_card.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:notredame/features/more/faq/models/faq_actions.dart'; +import 'package:notredame/features/more/faq/faq_viewmodel.dart'; + +class ActionCard extends StatelessWidget { + final String title; + final String description; + final ActionType type; + final String link; + final IconData iconName; + final Color iconColor; + final Color circleColor; + final BuildContext context; + final FaqViewModel model; + + const ActionCard({ + required this.title, + required this.description, + required this.type, + required this.link, + required this.iconName, + required this.iconColor, + required this.circleColor, + required this.context, + required this.model, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(15.0, 0.0, 15.0, 15.0), + child: ElevatedButton( + onPressed: () { + if (type.name == ActionType.webview.name) { + openWebview(model, link); + } else if (type.name == ActionType.email.name) { + openMail(model, context, link); + } + }, + style: ButtonStyle( + elevation: MaterialStateProperty.all(8.0), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + )), + child: getActionCardInfo( + context, + title, + description, + iconName, + iconColor, + circleColor, + ), + ), + ); + } + + Row getActionCardInfo(BuildContext context, String title, String description, + IconData iconName, Color iconColor, Color circleColor) { + return Row( + children: [ + Column( + children: [ + CircleAvatar( + backgroundColor: circleColor, + radius: 25, + child: Icon(iconName, color: iconColor), + ), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(15, 15, 0, 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 18, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.left, + ), + const SizedBox(height: 10.0), + Text( + description, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.justify, + ) + ], + ), + ), + ) + ], + ); + } + + Future openWebview(FaqViewModel model, String link) async { + model.launchWebsite(link, Theme.of(context).brightness); + } + + Future openMail( + FaqViewModel model, BuildContext context, String addressEmail) async { + model.openMail(addressEmail, context); + } +} diff --git a/lib/features/more/faq/widget/faq_subtitle.dart b/lib/features/more/faq/widget/faq_subtitle.dart new file mode 100644 index 000000000..822d8d945 --- /dev/null +++ b/lib/features/more/faq/widget/faq_subtitle.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class FaqSubtitle extends StatelessWidget { + final String subtitle; + final Color? backgroundColor; + + const FaqSubtitle({required this.subtitle, this.backgroundColor}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 18.0, top: 18.0, bottom: 10.0), + child: Text( + subtitle, + style: Theme.of(context).textTheme.headlineSmall!.copyWith( + color: + backgroundColor == Colors.white ? Colors.black : Colors.white, + ), + ), + ); + } +} diff --git a/lib/features/more/faq/widget/faq_title.dart b/lib/features/more/faq/widget/faq_title.dart new file mode 100644 index 000000000..6b719ca65 --- /dev/null +++ b/lib/features/more/faq/widget/faq_title.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class FaqTitle extends StatelessWidget { + final Color? backgroundColor; + + const FaqTitle({this.backgroundColor}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 60.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 5.0), + child: GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Icon( + Icons.arrow_back, + color: backgroundColor == Colors.white + ? Colors.black + : Colors.white, + ), + ), + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + AppIntl.of(context)!.need_help, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall!.copyWith( + color: backgroundColor == Colors.white + ? Colors.black + : Colors.white, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/more/faq/widget/question_card.dart b/lib/features/more/faq/widget/question_card.dart new file mode 100644 index 000000000..c48a30a3b --- /dev/null +++ b/lib/features/more/faq/widget/question_card.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class QuestionCard extends StatelessWidget { + final String title; + final String description; + + const QuestionCard({required this.title, required this.description}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 20.0, left: 20.0, right: 20.0), + child: Align( + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + textScaler: TextScaler.noScaling, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 20, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.justify, + ), + Text( + description, + textScaler: TextScaler.noScaling, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + color: Theme.of(context).brightness == Brightness.light + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.justify, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/more/feedback/feedback_view.dart b/lib/features/more/feedback/feedback_view.dart index f4298028f..d624cc0c9 100644 --- a/lib/features/more/feedback/feedback_view.dart +++ b/lib/features/more/feedback/feedback_view.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; // Package imports: import 'package:feedback/feedback.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/more/feedback/widgets-feedback/card_info.dart'; +import 'package:notredame/features/more/feedback/widgets-feedback/list_tag.dart'; import 'package:stacked/stacked.dart'; // Project imports: @@ -50,13 +52,13 @@ class _FeedbackViewState extends State { borderRadius: BorderRadius.circular(8.0), ), )), - child: getCardInfo( - context, - AppIntl.of(context)!.more_report_bug_bug, - AppIntl.of(context)!.more_report_bug_bug_subtitle, - Icons.bug_report, - const Color.fromRGBO(252, 196, 238, 1), - const Color.fromRGBO(153, 78, 174, 1), + child: CardInfo( + title: AppIntl.of(context)!.more_report_bug_bug, + subtitle: + AppIntl.of(context)!.more_report_bug_bug_subtitle, + icon: Icons.bug_report, + iconColor: const Color.fromRGBO(252, 196, 238, 1), + circleColor: const Color.fromRGBO(153, 78, 174, 1), ), ), ), @@ -78,13 +80,13 @@ class _FeedbackViewState extends State { borderRadius: BorderRadius.circular(8.0), ), )), - child: getCardInfo( - context, - AppIntl.of(context)!.more_report_bug_feature, - AppIntl.of(context)!.more_report_bug_feature_subtitle, - Icons.design_services, - const Color.fromRGBO(63, 219, 251, 1), - const Color.fromRGBO(14, 127, 188, 1), + child: CardInfo( + title: AppIntl.of(context)!.more_report_bug_feature, + subtitle: + AppIntl.of(context)!.more_report_bug_feature_subtitle, + icon: Icons.design_services, + iconColor: const Color.fromRGBO(63, 219, 251, 1), + circleColor: const Color.fromRGBO(14, 127, 188, 1), ), ), ), @@ -149,16 +151,16 @@ class _FeedbackViewState extends State { ), ), Row(children: [ - createListTag( - model.myIssues[index].createdAt, + ListTag( + text: model.myIssues[index].createdAt, color: Colors.transparent, textColor: isLightMode ? const Color.fromARGB(168, 0, 0, 0) : Colors.white, ), const SizedBox(width: 4), - createListTag( - model.myIssues[index].isOpen + ListTag( + text: model.myIssues[index].isOpen ? AppIntl.of(context)! .ticket_status_open : AppIntl.of(context)! @@ -285,64 +287,4 @@ class _FeedbackViewState extends State { ? AppTheme.lightThemeBackground : AppTheme.darkThemeAccent; } - - Row getCardInfo(BuildContext context, String title, String subtitle, - IconData icon, Color iconColor, Color circleColor) { - return Row( - children: [ - Column( - children: [ - CircleAvatar( - backgroundColor: circleColor, - radius: 25, - child: Icon(icon, color: iconColor), - ), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(15, 15, 0, 15), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context) - .textTheme - .bodyMedium! - .copyWith(fontSize: 19), - textAlign: TextAlign.left, - ), - Text( - subtitle, - style: Theme.of(context) - .textTheme - .bodyMedium! - .copyWith(fontSize: 16), - textAlign: TextAlign.left, - ) - ], - ), - ), - ) - ], - ); - } - - Widget createListTag(String text, {Color? textColor, Color? color}) { - return Container( - decoration: BoxDecoration( - // border radius - borderRadius: BorderRadius.circular(6), - color: color), - child: Padding( - padding: const EdgeInsets.all(6.0), - child: Text( - text, - style: TextStyle(color: textColor), - ), - ), - ); - } } diff --git a/lib/features/more/feedback/widgets-feedback/card_info.dart b/lib/features/more/feedback/widgets-feedback/card_info.dart new file mode 100644 index 000000000..35bb6550d --- /dev/null +++ b/lib/features/more/feedback/widgets-feedback/card_info.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class CardInfo extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final Color iconColor; + final Color circleColor; + + const CardInfo({ + super.key, + required this.title, + required this.subtitle, + required this.icon, + required this.iconColor, + required this.circleColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Column( + children: [ + CircleAvatar( + backgroundColor: circleColor, + radius: 25, + child: Icon(icon, color: iconColor), + ), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(15, 15, 0, 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 19), + textAlign: TextAlign.left, + ), + Text( + subtitle, + style: Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: 16), + textAlign: TextAlign.left, + ) + ], + ), + ), + ) + ], + ); + } +} diff --git a/lib/features/more/feedback/widgets-feedback/list_tag.dart b/lib/features/more/feedback/widgets-feedback/list_tag.dart new file mode 100644 index 000000000..a53f450e5 --- /dev/null +++ b/lib/features/more/feedback/widgets-feedback/list_tag.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class ListTag extends StatelessWidget { + final String text; + final Color? textColor; + final Color? color; + + const ListTag({ + super.key, + required this.text, + this.textColor, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: color, + ), + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Text( + text, + style: TextStyle(color: textColor), + ), + ), + ); + } +} diff --git a/lib/features/more/more_view.dart b/lib/features/more/more_view.dart index bb5711d5c..54fcd845c 100644 --- a/lib/features/more/more_view.dart +++ b/lib/features/more/more_view.dart @@ -1,22 +1,25 @@ +// more_view.dart + // Flutter imports: -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; // Package imports: -import 'package:feature_discovery/feature_discovery.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/more/widget/about_applets_tile.dart'; +import 'package:notredame/features/more/widget/contributors_list_tile.dart'; +import 'package:notredame/features/more/widget/in_app_review_tile.dart'; +import 'package:notredame/features/more/widget/open_source_licenses_list_tile.dart'; +import 'package:notredame/features/more/widget/report_bug_tile.dart'; import 'package:stacked/stacked.dart'; // Project imports: -import 'package:notredame/features/welcome/discovery/models/discovery_ids.dart'; import 'package:notredame/features/app/navigation/router_paths.dart'; import 'package:notredame/features/app/analytics/analytics_service.dart'; import 'package:notredame/utils/utils.dart'; import 'package:notredame/features/more/more_viewmodel.dart'; import 'package:notredame/utils/locator.dart'; import 'package:notredame/utils/app_theme.dart'; -import 'package:notredame/features/welcome/discovery/discovery_components.dart'; import 'package:notredame/features/app/widgets/base_scaffold.dart'; class MoreView extends StatefulWidget { @@ -38,38 +41,6 @@ class _MoreViewState extends State { }); } - /// Returns right icon color for discovery depending on theme. - Widget getProperIconAccordingToTheme(IconData icon) { - return (Theme.of(context).brightness == Brightness.dark && - isDiscoveryOverlayActive) - ? Icon(icon, color: Colors.black) - : Icon(icon); - } - - /// License text box - List aboutBoxChildren(BuildContext context) { - final textStyle = Theme.of(context).textTheme.bodyMedium!; - return [ - const SizedBox(height: 24), - RichText( - text: TextSpan( - children: [ - TextSpan( - style: textStyle, text: AppIntl.of(context)!.flutter_license), - TextSpan( - style: textStyle.copyWith(color: Colors.blue), - text: AppIntl.of(context)!.flutter_website, - recognizer: TapGestureRecognizer() - ..onTap = () => Utils.launchURL( - AppIntl.of(context)!.flutter_website, - AppIntl.of(context)!)), - TextSpan(style: textStyle, text: '.'), - ], - ), - ), - ]; - } - @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( @@ -83,78 +54,13 @@ class _MoreViewState extends State { body: ListView( padding: EdgeInsets.zero, children: [ - ListTile( - title: Text(AppIntl.of(context)!.more_about_applets_title), - leading: _buildDiscoveryFeatureDescriptionWidget( - context, - Hero( - tag: 'about', - child: Image.asset( - "assets/images/favicon_applets.png", - height: 24, - width: 24, - ), - ), - DiscoveryIds.detailsMoreThankYou, - model), - onTap: () { - _analyticsService.logEvent(tag, "About App|ETS clicked"); - model.navigationService.pushNamed(RouterPaths.about); - }), - ListTile( - title: Text(AppIntl.of(context)!.more_report_bug), - leading: _buildDiscoveryFeatureDescriptionWidget( - context, - getProperIconAccordingToTheme(Icons.bug_report), - DiscoveryIds.detailsMoreBugReport, - model), - onTap: () { - _analyticsService.logEvent(tag, "Report a bug clicked"); - model.navigationService.pushNamed(RouterPaths.feedback); - }), - ListTile( - title: Text(AppIntl.of(context)!.in_app_review_title), - leading: const Icon(Icons.rate_review), - onTap: () { - _analyticsService.logEvent(tag, "Rate us clicked"); - MoreViewModel.launchInAppReview(); - }), - ListTile( - title: Text(AppIntl.of(context)!.more_contributors), - leading: _buildDiscoveryFeatureDescriptionWidget( - context, - getProperIconAccordingToTheme(Icons.people_outline), - DiscoveryIds.detailsMoreContributors, - model), - onTap: () { - _analyticsService.logEvent(tag, "Contributors clicked"); - model.navigationService - .pushNamed(RouterPaths.contributors); - }), - ListTile( - title: Text(AppIntl.of(context)!.more_open_source_licenses), - leading: const Icon(Icons.code), - onTap: () { - _analyticsService.logEvent(tag, "Rate us clicked"); - Navigator.of(context).push(PageRouteBuilder( - pageBuilder: (context, _, __) => AboutDialog( - applicationIcon: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - width: 75, - child: Image.asset( - 'assets/images/favicon_applets.png')), - ), - applicationName: - AppIntl.of(context)!.more_open_source_licenses, - applicationVersion: model.appVersion, - applicationLegalese: - '\u{a9} ${DateTime.now().year} App|ETS', - children: aboutBoxChildren(context), - ), - opaque: false, - )); - }), + AboutAppletsTile(), + ReportBugTile( + isDiscoveryOverlayActive: isDiscoveryOverlayActive), + const InAppReviewTile(), + ContributorsTile( + isDiscoveryOverlayActive: isDiscoveryOverlayActive), + const OpenSourceLicensesTile(), if (model.privacyPolicyToggle) ListTile( title: Text(AppIntl.of(context)!.privacy_policy), @@ -166,11 +72,8 @@ class _MoreViewState extends State { }), ListTile( title: Text(AppIntl.of(context)!.need_help), - leading: _buildDiscoveryFeatureDescriptionWidget( - context, + leading: getProperIconAccordingToTheme(Icons.question_answer), - DiscoveryIds.detailsMoreFaq, - model), onTap: () { _analyticsService.logEvent(tag, "FAQ clicked"); model.navigationService.pushNamed(RouterPaths.faq, @@ -179,11 +82,7 @@ class _MoreViewState extends State { }), ListTile( title: Text(AppIntl.of(context)!.settings_title), - leading: _buildDiscoveryFeatureDescriptionWidget( - context, - getProperIconAccordingToTheme(Icons.settings), - DiscoveryIds.detailsMoreSettings, - model), + leading: getProperIconAccordingToTheme(Icons.settings), onTap: () { _analyticsService.logEvent(tag, "Settings clicked"); model.navigationService.pushNamed(RouterPaths.settings); @@ -223,35 +122,10 @@ class _MoreViewState extends State { }); } - DescribedFeatureOverlay _buildDiscoveryFeatureDescriptionWidget( - BuildContext context, - Widget icon, - String featuredId, - MoreViewModel model) { - final discovery = getDiscoveryByFeatureId( - context, DiscoveryGroupIds.pageMore, featuredId); - - return DescribedFeatureOverlay( - overflowMode: OverflowMode.wrapBackground, - contentLocation: ContentLocation.below, - featureId: discovery.featureId, - title: Text(discovery.title, textAlign: TextAlign.justify), - description: discovery.details, - backgroundColor: AppTheme.appletsDarkPurple, - tapTarget: icon, - pulseDuration: const Duration(seconds: 5), - child: icon, - onComplete: () { - setState(() { - isDiscoveryOverlayActive = false; - }); - return model.discoveryCompleted(); - }, - onOpen: () async { - setState(() { - isDiscoveryOverlayActive = true; - }); - return true; - }); + Widget getProperIconAccordingToTheme(IconData icon) { + return (Theme.of(context).brightness == Brightness.dark && + isDiscoveryOverlayActive) + ? Icon(icon, color: Colors.black) + : Icon(icon); } } diff --git a/lib/features/more/more_viewmodel.dart b/lib/features/more/more_viewmodel.dart index f78f4d5ee..f55c1e4bc 100644 --- a/lib/features/more/more_viewmodel.dart +++ b/lib/features/more/more_viewmodel.dart @@ -20,6 +20,7 @@ import 'package:notredame/features/more/feedback/in_app_review_service.dart'; import 'package:notredame/features/app/integration/launch_url_service.dart'; import 'package:notredame/features/app/navigation/navigation_service.dart'; import 'package:notredame/features/app/storage/preferences_service.dart'; +import 'package:notredame/features/app/analytics/analytics_service.dart'; import 'package:notredame/features/app/analytics/remote_config_service.dart'; import 'package:notredame/utils/locator.dart'; import 'package:notredame/features/welcome/discovery/discovery_components.dart'; @@ -47,6 +48,9 @@ class MoreViewModel extends FutureViewModel { /// Used to redirect on the dashboard. final NavigationService navigationService = locator(); + /// Analytics service + final AnalyticsService _analyticsService = locator(); + String? _appVersion; final AppIntl _appIntl; @@ -158,4 +162,7 @@ class MoreViewModel extends FutureViewModel { /// Get the privacy policy toggle bool get privacyPolicyToggle => _remoteConfigService.privacyPolicyToggle; + + /// Getter for analyticsService + AnalyticsService get analyticsService => _analyticsService; } diff --git a/lib/features/more/settings/choose_language_view.dart b/lib/features/more/settings/choose_language_view.dart index 61c640c20..13fa3a86c 100644 --- a/lib/features/more/settings/choose_language_view.dart +++ b/lib/features/more/settings/choose_language_view.dart @@ -1,11 +1,7 @@ -// Flutter imports: import 'package:flutter/material.dart'; - -// Package imports: import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/more/settings/widget/language_list_widget.dart'; import 'package:stacked/stacked.dart'; - -// Project imports: import 'package:notredame/utils/utils.dart'; import 'package:notredame/features/more/settings/choose_language_viewmodel.dart'; import 'package:notredame/utils/app_theme.dart'; @@ -16,80 +12,59 @@ class ChooseLanguageView extends StatefulWidget { } class _ChooseLanguageViewState extends State { - ListView languagesListView(ChooseLanguageViewModel model) { - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.all(8), - itemCount: model.languages.length, - itemBuilder: (BuildContext context, int index) { - return Card( - color: Utils.getColorByBrightness( - context, Colors.white, Colors.grey[900]!), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - ListTile( - title: Text(model.languages[index]), - trailing: Icon( - model.languageSelectedIndex == index ? Icons.check : null), - onTap: () { - model.changeLanguage(index); - }, - ), - ])); - }, - ); - } - @override Widget build(BuildContext context) { return ViewModelBuilder.reactive( - viewModelBuilder: () => - ChooseLanguageViewModel(intl: AppIntl.of(context)!), - builder: (context, model, child) => Scaffold( - backgroundColor: Utils.getColorByBrightness( - context, AppTheme.etsLightRed, AppTheme.primaryDark), - body: Center( - child: ListView( - shrinkWrap: true, - children: [ - Icon( - Icons.language, - size: 80, - color: Utils.getColorByBrightness( - context, Colors.white, AppTheme.etsLightRed), - ), - Padding( - padding: const EdgeInsets.only(left: 20, top: 60), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - AppIntl.of(context)!.choose_language_title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white), - ), - ), + viewModelBuilder: () => + ChooseLanguageViewModel(intl: AppIntl.of(context)!), + builder: (context, model, child) => Scaffold( + backgroundColor: Utils.getColorByBrightness( + context, AppTheme.etsLightRed, AppTheme.primaryDark), + body: Center( + child: ListView( + shrinkWrap: true, + children: [ + Icon( + Icons.language, + size: 80, + color: Utils.getColorByBrightness( + context, Colors.white, AppTheme.etsLightRed), + ), + Padding( + padding: const EdgeInsets.only(left: 20, top: 60), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + AppIntl.of(context)!.choose_language_title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, ), - Padding( - padding: - const EdgeInsets.only(left: 20, top: 10, bottom: 30), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - AppIntl.of(context)!.choose_language_subtitle, - style: const TextStyle( - fontSize: 16, color: Colors.white), - ), - ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 20, top: 10, bottom: 30), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + AppIntl.of(context)!.choose_language_subtitle, + style: const TextStyle( + fontSize: 16, + color: Colors.white, ), - Padding( - padding: const EdgeInsets.only(bottom: 80), - child: languagesListView(model), - ) - ], + ), ), ), - )); + Padding( + padding: const EdgeInsets.only(bottom: 80), + child: LanguageListViewWidget(model: model), + ), + ], + ), + ), + ), + ); } } diff --git a/lib/features/more/settings/widget/language_card_widget.dart b/lib/features/more/settings/widget/language_card_widget.dart new file mode 100644 index 000000000..3e32973bb --- /dev/null +++ b/lib/features/more/settings/widget/language_card_widget.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:notredame/utils/utils.dart'; + +class LanguageCardWidget extends StatelessWidget { + final String language; + final bool isSelected; + final VoidCallback onTap; + + const LanguageCardWidget({ + super.key, + required this.language, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + color: + Utils.getColorByBrightness(context, Colors.white, Colors.grey[900]!), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(language), + trailing: Icon(isSelected ? Icons.check : null), + onTap: onTap, + ), + ], + ), + ); + } +} diff --git a/lib/features/more/settings/widget/language_list_widget.dart b/lib/features/more/settings/widget/language_list_widget.dart new file mode 100644 index 000000000..0ddfa3b84 --- /dev/null +++ b/lib/features/more/settings/widget/language_list_widget.dart @@ -0,0 +1,28 @@ +// widgets/LanguageListViewWidget.dart + +import 'package:flutter/material.dart'; +import 'package:notredame/features/more/settings/choose_language_viewmodel.dart'; +import 'package:notredame/features/more/settings/widget/language_card_widget.dart'; + +class LanguageListViewWidget extends StatelessWidget { + final ChooseLanguageViewModel model; + + const LanguageListViewWidget({super.key, required this.model}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: model.languages.length, + itemBuilder: (BuildContext context, int index) { + return LanguageCardWidget( + language: model.languages[index], + isSelected: model.languageSelectedIndex == index, + onTap: () => model.changeLanguage(index), + ); + }, + ); + } +} diff --git a/lib/features/more/widget/about_applets_tile.dart b/lib/features/more/widget/about_applets_tile.dart new file mode 100644 index 000000000..84d4d805f --- /dev/null +++ b/lib/features/more/widget/about_applets_tile.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/app/navigation/router_paths.dart'; +import 'package:stacked/stacked.dart'; +import 'package:notredame/features/more/more_viewmodel.dart'; +import 'package:notredame/utils/locator.dart'; +import 'package:notredame/features/app/analytics/analytics_service.dart'; + +class AboutAppletsTile extends ViewModelWidget { + final AnalyticsService _analyticsService = locator(); + + @override + Widget build(BuildContext context, MoreViewModel model) { + return ListTile( + title: Text(AppIntl.of(context)!.more_about_applets_title), + leading: Hero( + tag: 'about', + child: Image.asset( + "assets/images/favicon_applets.png", + height: 24, + width: 24, + ), + ), + onTap: () { + _analyticsService.logEvent("MoreView", "About App|ETS clicked"); + model.navigationService.pushNamed(RouterPaths.about); + }, + ); + } +} diff --git a/lib/features/more/widget/contributors_list_tile.dart b/lib/features/more/widget/contributors_list_tile.dart new file mode 100644 index 000000000..00c47c29a --- /dev/null +++ b/lib/features/more/widget/contributors_list_tile.dart @@ -0,0 +1,32 @@ +// widgets/contributors_tile.dart + +import 'package:flutter/material.dart'; +import 'package:notredame/features/app/analytics/analytics_service.dart'; +import 'package:stacked/stacked.dart'; +import 'package:notredame/features/app/navigation/router_paths.dart'; +import 'package:notredame/features/more/more_viewmodel.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/utils/locator.dart'; + +class ContributorsTile extends ViewModelWidget { + final AnalyticsService _analyticsService = locator(); + static const String tag = "MoreView"; + final bool isDiscoveryOverlayActive; + + ContributorsTile({super.key, required this.isDiscoveryOverlayActive}); + + @override + Widget build(BuildContext context, MoreViewModel model) { + return ListTile( + title: Text(AppIntl.of(context)!.more_contributors), + leading: (Theme.of(context).brightness == Brightness.dark && + isDiscoveryOverlayActive) + ? const Icon(Icons.people_outline, color: Colors.black) + : const Icon(Icons.people_outline), + onTap: () { + _analyticsService.logEvent(tag, "Contributors clicked"); + model.navigationService.pushNamed(RouterPaths.contributors); + }, + ); + } +} diff --git a/lib/features/more/widget/in_app_review_tile.dart b/lib/features/more/widget/in_app_review_tile.dart new file mode 100644 index 000000000..fe53efde4 --- /dev/null +++ b/lib/features/more/widget/in_app_review_tile.dart @@ -0,0 +1,22 @@ +// widgets/in_app_review_tile.dart + +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; +import 'package:notredame/features/more/more_viewmodel.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class InAppReviewTile extends ViewModelWidget { + const InAppReviewTile({super.key}); + + @override + Widget build(BuildContext context, MoreViewModel model) { + return ListTile( + title: Text(AppIntl.of(context)!.in_app_review_title), + leading: const Icon(Icons.rate_review), + onTap: () { + model.analyticsService.logEvent("MoreView", "Rate us clicked"); + MoreViewModel.launchInAppReview(); + }, + ); + } +} diff --git a/lib/features/more/widget/open_source_licenses_list_tile.dart b/lib/features/more/widget/open_source_licenses_list_tile.dart new file mode 100644 index 000000000..95131d75b --- /dev/null +++ b/lib/features/more/widget/open_source_licenses_list_tile.dart @@ -0,0 +1,61 @@ +// widgets/open_source_licenses_tile.dart + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:stacked/stacked.dart'; +import 'package:notredame/features/more/more_viewmodel.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class OpenSourceLicensesTile extends ViewModelWidget { + const OpenSourceLicensesTile({super.key}); + + @override + Widget build(BuildContext context, MoreViewModel model) { + return ListTile( + title: Text(AppIntl.of(context)!.more_open_source_licenses), + leading: const Icon(Icons.code), + onTap: () { + model.analyticsService.logEvent("MoreView", "Rate us clicked"); + Navigator.of(context).push(PageRouteBuilder( + pageBuilder: (context, _, __) => AboutDialog( + applicationIcon: Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + width: 75, + child: Image.asset('assets/images/favicon_applets.png')), + ), + applicationName: AppIntl.of(context)!.more_open_source_licenses, + applicationVersion: model.appVersion, + applicationLegalese: '\u{a9} ${DateTime.now().year} App|ETS', + children: aboutBoxChildren(context, model), + ), + opaque: false, + )); + }, + ); + } + + List aboutBoxChildren(BuildContext context, MoreViewModel model) { + final textStyle = Theme.of(context).textTheme.bodyMedium!; + return [ + const SizedBox(height: 24), + RichText( + text: TextSpan( + children: [ + TextSpan( + style: textStyle, text: AppIntl.of(context)!.flutter_license), + TextSpan( + style: textStyle.copyWith(color: Colors.blue), + text: AppIntl.of(context)!.flutter_website, + recognizer: TapGestureRecognizer() + ..onTap = () => Utils.launchURL( + AppIntl.of(context)!.flutter_website, + AppIntl.of(context)!)), + TextSpan(style: textStyle, text: '.'), + ], + ), + ), + ]; + } +} diff --git a/lib/features/more/widget/report_bug_tile.dart b/lib/features/more/widget/report_bug_tile.dart new file mode 100644 index 000000000..b2b3cb2be --- /dev/null +++ b/lib/features/more/widget/report_bug_tile.dart @@ -0,0 +1,32 @@ +// widgets/report_bug_tile.dart + +import 'package:flutter/material.dart'; +import 'package:notredame/features/app/analytics/analytics_service.dart'; +import 'package:stacked/stacked.dart'; +import 'package:notredame/features/app/navigation/router_paths.dart'; +import 'package:notredame/features/more/more_viewmodel.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/utils/locator.dart'; + +class ReportBugTile extends ViewModelWidget { + final AnalyticsService _analyticsService = locator(); + static const String tag = "MoreView"; + final bool isDiscoveryOverlayActive; + + ReportBugTile({super.key, required this.isDiscoveryOverlayActive}); + + @override + Widget build(BuildContext context, MoreViewModel model) { + return ListTile( + title: Text(AppIntl.of(context)!.more_report_bug), + leading: (Theme.of(context).brightness == Brightness.dark && + isDiscoveryOverlayActive) + ? const Icon(Icons.bug_report, color: Colors.black) + : const Icon(Icons.bug_report), + onTap: () { + _analyticsService.logEvent(tag, "Report a bug clicked"); + model.navigationService.pushNamed(RouterPaths.feedback); + }, + ); + } +} diff --git a/lib/features/student/grades/grade_details/grade_details_view.dart b/lib/features/student/grades/grade_details/grade_details_view.dart index 226b9561c..227516229 100644 --- a/lib/features/student/grades/grade_details/grade_details_view.dart +++ b/lib/features/student/grades/grade_details/grade_details_view.dart @@ -5,16 +5,14 @@ import 'package:flutter/scheduler.dart'; // Package imports: import 'package:ets_api_clients/models.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/student/grades/grade_details/widget/class_info.dart'; +import 'package:notredame/features/student/grades/grade_details/widget/grade_evaluations.dart'; import 'package:stacked/stacked.dart'; // Project imports: -import 'package:notredame/utils/utils.dart'; import 'package:notredame/features/student/grades/grade_details/grades_details_viewmodel.dart'; import 'package:notredame/utils/app_theme.dart'; import 'package:notredame/features/app/widgets/base_scaffold.dart'; -import 'package:notredame/features/student/grades/widgets/grade_circular_progress.dart'; -import 'package:notredame/features/student/grades/widgets/grade_evaluation_tile.dart'; -import 'package:notredame/features/student/grades/widgets/grade_not_available.dart'; class GradesDetailsView extends StatefulWidget { final Course course; @@ -69,7 +67,6 @@ class _GradesDetailsViewState extends State onStretchTrigger: () { return Future.value(); }, - titleSpacing: 0, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.of(context).pop(), @@ -105,14 +102,16 @@ class _GradesDetailsViewState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildClassInfo(model.course.title), + ClassInfo(info: model.course.title), if (model.course.teacherName != null) - _buildClassInfo(AppIntl.of(context)! - .grades_teacher(model.course.teacherName!)), - _buildClassInfo(AppIntl.of(context)! - .grades_group_number(model.course.group)), - _buildClassInfo(AppIntl.of(context)! - .credits_number( + ClassInfo( + info: AppIntl.of(context)!.grades_teacher( + model.course.teacherName!)), + ClassInfo( + info: AppIntl.of(context)! + .grades_group_number(model.course.group)), + ClassInfo( + info: AppIntl.of(context)!.credits_number( model.course.numberOfCredits)), ], ), @@ -125,236 +124,14 @@ class _GradesDetailsViewState extends State body: SafeArea( child: Padding( padding: const EdgeInsets.all(5.0), - child: _buildGradeEvaluations(model), - ), - ), - ), - ), - ), - ); - - Widget _buildGradeEvaluations(GradesDetailsViewModel model) { - if (model.isBusy) { - return const Center(child: CircularProgressIndicator()); - } else if (model.course.inReviewPeriod && - !(model.course.allReviewsCompleted ?? true)) { - return Center( - child: GradeNotAvailable( - key: const Key("EvaluationNotCompleted"), - onPressed: model.refresh, - isEvaluationPeriod: true), - ); - } else if (model.course.summary != null) { - return RefreshIndicator( - onRefresh: () => model.refresh(), - child: ListView( - padding: const EdgeInsets.all(5.0), - children: [ - Column( - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - flex: 50, - child: GradeCircularProgress( - 1.0, - completed: _completed, - key: const Key("GradeCircularProgress_summary"), - finalGrade: model.course.grade, - studentGrade: Utils.getGradeInPercentage( - model.course.summary?.currentMark, - model.course.summary?.markOutOf, - ), - averageGrade: Utils.getGradeInPercentage( - model.course.summary?.passMark, - model.course.summary?.markOutOf, - ), - ), - ), - Expanded( - flex: 40, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildGradesSummary( - model.course.summary?.currentMark, - model.course.summary?.markOutOf, - AppIntl.of(context)!.grades_current_rating, - Colors.green, - context, - ), - Padding( - padding: const EdgeInsets.only(top: 15.0), - child: _buildGradesSummary( - model.course.summary?.passMark, - model.course.summary?.markOutOf, - AppIntl.of(context)!.grades_average, - Colors.red, - context, - ), - ), - ], - ), - ), - ], - ), + child: GradeEvaluations( + model: model, + completed: _completed, ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - flex: 3, - child: _buildCourseGradeSummary( - AppIntl.of(context)!.grades_median, - validateGrade( - context, - model.course.summary?.median.toString(), - AppIntl.of(context)!.grades_grade_in_percentage( - Utils.getGradeInPercentage( - model.course.summary?.median, - model.course.summary?.markOutOf)), - ), - ), - ), - Expanded( - flex: 3, - child: _buildCourseGradeSummary( - AppIntl.of(context)!.grades_standard_deviation, - validateGrade( - context, - model.course.summary?.standardDeviation.toString(), - model.course.summary?.standardDeviation.toString(), - ), - ), - ), - Expanded( - flex: 3, - child: _buildCourseGradeSummary( - AppIntl.of(context)!.grades_percentile_rank, - validateGrade( - context, - model.course.summary?.percentileRank.toString(), - model.course.summary?.percentileRank.toString(), - ), - ), - ), - ]), - Column(children: [ - for (final CourseEvaluation evaluation - in model.course.summary?.evaluations ?? []) - GradeEvaluationTile( - evaluation, - completed: _completed, - key: Key("GradeEvaluationTile_${evaluation.title}"), - isFirstEvaluation: - evaluation == model.course.summary?.evaluations.first, - ), - ]), - ], + ), ), - ], - ), - ); - } else { - return Center( - child: GradeNotAvailable( - key: const Key("GradeNotAvailable"), onPressed: model.refresh), - ); - } - } - - Align _buildClassInfo(String info) => Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 15.0), - child: Text( - info, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith(color: Colors.white, fontSize: 16), - overflow: TextOverflow.ellipsis, ), ), ); - - /// Build the student grade or the average grade with their title - Column _buildGradesSummary(double? currentGrade, double? maxGrade, - String recipient, Color color, BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - AppIntl.of(context)!.grades_grade_with_percentage( - currentGrade ?? 0.0, - maxGrade ?? 0.0, - Utils.getGradeInPercentage( - currentGrade, - maxGrade, - ), - ), - style: Theme.of(context) - .textTheme - .titleLarge! - .copyWith(color: color)), - ), - Text(recipient, - style: - Theme.of(context).textTheme.bodyLarge!.copyWith(color: color)), - ], - ); - } - - String validateGrade(BuildContext context, String? grade, String? text) { - if (grade == "null" || grade == null || text == "null" || text == null) { - return AppIntl.of(context)!.grades_not_available; - } - - return text; - } - - /// Build the card of the Medidian, Standard deviation or Percentile Rank - SizedBox _buildCourseGradeSummary(String? title, String number) { - return SizedBox( - height: 110, - width: MediaQuery.of(context).size.width / 3.1, - child: Card( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - flex: 5, - child: Padding( - padding: const EdgeInsets.only(top: 14.0), - child: Text( - title ?? "", - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - ), - ), - ), - ), - Expanded( - flex: 5, - child: Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Text( - number, - style: const TextStyle(fontSize: 19), - ), - ), - ), - ], - ), - ), - ); - } } diff --git a/lib/features/student/grades/grade_details/widget/class_info.dart b/lib/features/student/grades/grade_details/widget/class_info.dart new file mode 100644 index 000000000..b79325d8f --- /dev/null +++ b/lib/features/student/grades/grade_details/widget/class_info.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class ClassInfo extends StatelessWidget { + final String info; + + const ClassInfo({required this.info}); + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 15.0), + child: Text( + info, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(color: Colors.white, fontSize: 16), + overflow: TextOverflow.ellipsis, + ), + ), + ); +} diff --git a/lib/features/student/grades/grade_details/widget/course_grade_summary.dart b/lib/features/student/grades/grade_details/widget/course_grade_summary.dart new file mode 100644 index 000000000..0ff274d8f --- /dev/null +++ b/lib/features/student/grades/grade_details/widget/course_grade_summary.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class CourseGradeSummary extends StatelessWidget { + final String? title; + final String number; + + const CourseGradeSummary({required this.title, required this.number}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 110, + width: MediaQuery.of(context).size.width / 3.1, + child: Card( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 5, + child: Padding( + padding: const EdgeInsets.only(top: 14.0), + child: Text( + title ?? "", + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + ), + ), + ), + ), + Expanded( + flex: 5, + child: Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text( + number, + style: const TextStyle(fontSize: 19), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/student/grades/grade_details/widget/grade_evaluations.dart b/lib/features/student/grades/grade_details/widget/grade_evaluations.dart new file mode 100644 index 000000000..c73039e66 --- /dev/null +++ b/lib/features/student/grades/grade_details/widget/grade_evaluations.dart @@ -0,0 +1,179 @@ +import 'package:ets_api_clients/models.dart'; +import 'package:flutter/material.dart'; +import 'package:notredame/features/student/grades/grade_details/grades_details_viewmodel.dart'; +import 'package:notredame/features/student/grades/grade_details/widget/course_grade_summary.dart'; +import 'package:notredame/features/student/grades/grade_details/widget/grade_summary.dart'; +import 'package:notredame/features/student/grades/widgets/grade_circular_progress.dart'; +import 'package:notredame/features/student/grades/widgets/grade_evaluation_tile.dart'; +import 'package:notredame/features/student/grades/widgets/grade_not_available.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class GradeEvaluations extends StatelessWidget { + final GradesDetailsViewModel model; + final bool completed; + + const GradeEvaluations({required this.model, required this.completed}); + + @override + Widget build(BuildContext context) { + if (model.isBusy) { + return _buildLoadingIndicator(); + } else if (model.course.inReviewPeriod && + !(model.course.allReviewsCompleted ?? true)) { + return _buildEvaluationNotCompleted(); + } else if (model.course.summary != null) { + return _buildGradeDetails(context); + } else { + return _buildGradeNotAvailable(); + } + } + + Widget _buildLoadingIndicator() { + return const Center( + child: CircularProgressIndicator(), + ); + } + + Widget _buildEvaluationNotCompleted() { + return Center( + child: GradeNotAvailable( + key: const Key("EvaluationNotCompleted"), + onPressed: model.refresh, + isEvaluationPeriod: true, + ), + ); + } + + Widget _buildGradeNotAvailable() { + return Center( + child: GradeNotAvailable( + key: const Key("GradeNotAvailable"), + onPressed: model.refresh, + ), + ); + } + + Widget _buildGradeDetails(BuildContext context) { + return RefreshIndicator( + onRefresh: () => model.refresh(), + child: ListView( + padding: const EdgeInsets.all(5.0), + children: [ + _buildSummaryCard(context), + _buildAdditionalDetails(context), + ], + ), + ); + } + + Widget _buildSummaryCard(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + flex: 50, + child: GradeCircularProgress( + 1.0, + completed: completed, + key: const Key("GradeCircularProgress_summary"), + finalGrade: model.course.grade, + studentGrade: Utils.getGradeInPercentage( + model.course.summary?.currentMark, + model.course.summary?.markOutOf, + ), + averageGrade: Utils.getGradeInPercentage( + model.course.summary?.passMark, + model.course.summary?.markOutOf, + ), + ), + ), + Expanded( + flex: 40, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GradesSummary( + currentGrade: model.course.summary?.currentMark, + maxGrade: model.course.summary?.markOutOf, + recipient: AppIntl.of(context)!.grades_current_rating, + color: Colors.green, + ), + Padding( + padding: const EdgeInsets.only(top: 15.0), + child: GradesSummary( + currentGrade: model.course.summary?.passMark, + maxGrade: model.course.summary?.markOutOf, + recipient: AppIntl.of(context)!.grades_average, + color: Colors.red, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildAdditionalDetails(BuildContext context) { + return Column( + children: [ + _buildAdditionalSummary(context), + _buildEvaluationTiles(), + ], + ); + } + + Widget _buildAdditionalSummary(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + flex: 3, + child: CourseGradeSummary( + title: AppIntl.of(context)!.grades_median, + number: model.course.summary?.median.toString() ?? + AppIntl.of(context)!.grades_not_available, + ), + ), + Expanded( + flex: 3, + child: CourseGradeSummary( + title: AppIntl.of(context)!.grades_standard_deviation, + number: model.course.summary?.standardDeviation.toString() ?? + AppIntl.of(context)!.grades_not_available, + ), + ), + Expanded( + flex: 3, + child: CourseGradeSummary( + title: AppIntl.of(context)!.grades_percentile_rank, + number: model.course.summary?.percentileRank.toString() ?? + AppIntl.of(context)!.grades_not_available, + ), + ), + ], + ); + } + + Widget _buildEvaluationTiles() { + return Column( + children: [ + for (final CourseEvaluation evaluation + in model.course.summary?.evaluations ?? []) + GradeEvaluationTile( + evaluation, + completed: completed, + key: Key("GradeEvaluationTile_${evaluation.title}"), + isFirstEvaluation: + evaluation == model.course.summary?.evaluations.first, + ), + ], + ); + } +} diff --git a/lib/features/student/grades/grade_details/widget/grade_summary.dart b/lib/features/student/grades/grade_details/widget/grade_summary.dart new file mode 100644 index 000000000..2882a5b71 --- /dev/null +++ b/lib/features/student/grades/grade_details/widget/grade_summary.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class GradesSummary extends StatelessWidget { + final double? currentGrade; + final double? maxGrade; + final String recipient; + final Color color; + + const GradesSummary({ + required this.currentGrade, + required this.maxGrade, + required this.recipient, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FittedBox( + fit: BoxFit.fitWidth, + child: Text( + AppIntl.of(context)!.grades_grade_with_percentage( + currentGrade ?? 0.0, + maxGrade ?? 0.0, + Utils.getGradeInPercentage( + currentGrade, + maxGrade, + ), + ), + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith(color: color)), + ), + Text(recipient, + style: + Theme.of(context).textTheme.bodyLarge!.copyWith(color: color)), + ], + ); + } +} diff --git a/lib/features/student/grades/grades_view.dart b/lib/features/student/grades/grades_view.dart index 7d30ba83d..a4651f0af 100644 --- a/lib/features/student/grades/grades_view.dart +++ b/lib/features/student/grades/grades_view.dart @@ -1,22 +1,16 @@ -// Flutter imports: +// GradesView.dart + import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; - -// Package imports: -import 'package:ets_api_clients/models.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:notredame/features/student/grades/widget-grade-view/empty_grades_message.dart'; +import 'package:notredame/features/student/grades/widget-grade-view/grade_list.dart'; import 'package:stacked/stacked.dart'; -// Project imports: -import 'package:notredame/features/app/navigation/router_paths.dart'; import 'package:notredame/features/app/analytics/analytics_service.dart'; -import 'package:notredame/features/app/navigation/navigation_service.dart'; import 'package:notredame/features/student/grades/grades_viewmodel.dart'; import 'package:notredame/utils/locator.dart'; -import 'package:notredame/utils/app_theme.dart'; import 'package:notredame/utils/loading.dart'; -import 'package:notredame/features/student/grades/widgets/grade_button.dart'; class GradesView extends StatefulWidget { @override @@ -26,9 +20,6 @@ class GradesView extends StatefulWidget { class _GradesViewState extends State { final AnalyticsService _analyticsService = locator(); - /// Used to redirect on the dashboard. - final NavigationService _navigationService = locator(); - @override void initState() { super.initState(); @@ -49,40 +40,11 @@ class _GradesViewState extends State { onRefresh: () => model.refresh(), child: Stack( children: [ - // This widget is here to make this widget a Scrollable. Needed - // by the RefreshIndicator ListView(), if (model.coursesBySession.isEmpty) - Center( - child: Text(AppIntl.of(context)!.grades_msg_no_grades, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge)) + EmptyGradesMessage() else - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: AnimationLimiter( - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: model.coursesBySession.length, - itemBuilder: (BuildContext context, int index) => - AnimationConfiguration.staggeredList( - position: index, - duration: const Duration(milliseconds: 750), - child: SlideAnimation( - verticalOffset: 50.0, - child: FadeInAnimation( - child: _buildSessionCourses( - index, - _sessionName(model.sessionOrder[index], - AppIntl.of(context)!), - model.coursesBySession[ - model.sessionOrder[index]]!, - model), - ), - ), - )), - ), - ), + GradeList(model: model), if (model.isBusy) buildLoading(isInteractionLimitedWhileLoading: false) else @@ -92,56 +54,4 @@ class _GradesViewState extends State { ); }); } - - /// Build a session which is the name of the session and one [GradeButton] for - /// each [Course] in [courses] - Widget _buildSessionCourses(int index, String sessionName, - List courses, GradesViewModel model) => - Padding( - padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - sessionName, - style: const TextStyle( - fontSize: 25, - color: AppTheme.etsLightRed, - ), - ), - IconButton( - icon: const Icon(Icons.today, color: AppTheme.etsDarkGrey), - onPressed: () => _navigationService.pushNamed( - RouterPaths.defaultSchedule, - arguments: model.sessionOrder[index]), - ), - ], - ), - const SizedBox(height: 16.0), - Wrap( - children: courses - .map((course) => - GradeButton(course, showDiscovery: index == 0)) - .toList(), - ), - ], - ), - ); - - /// Build the complete name of the session for the user local. - String _sessionName(String shortName, AppIntl intl) { - switch (shortName[0]) { - case 'H': - return "${intl.session_winter} ${shortName.substring(1)}"; - case 'A': - return "${intl.session_fall} ${shortName.substring(1)}"; - case 'É': - return "${intl.session_summer} ${shortName.substring(1)}"; - default: - return intl.session_without; - } - } } diff --git a/lib/features/student/grades/widget-grade-view/empty_grades_message.dart b/lib/features/student/grades/widget-grade-view/empty_grades_message.dart new file mode 100644 index 000000000..3341b4abd --- /dev/null +++ b/lib/features/student/grades/widget-grade-view/empty_grades_message.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class EmptyGradesMessage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: Text(AppIntl.of(context)!.grades_msg_no_grades, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge), + ); + } +} diff --git a/lib/features/student/grades/widget-grade-view/grade_list.dart b/lib/features/student/grades/widget-grade-view/grade_list.dart new file mode 100644 index 000000000..4697d4a18 --- /dev/null +++ b/lib/features/student/grades/widget-grade-view/grade_list.dart @@ -0,0 +1,57 @@ +// widgets/GradeList.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:notredame/features/student/grades/grades_viewmodel.dart'; +import 'package:notredame/features/student/grades/widget-grade-view/grade_session_courses.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class GradeList extends StatelessWidget { + final GradesViewModel model; + + const GradeList({required this.model}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: AnimationLimiter( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: model.coursesBySession.length, + itemBuilder: (BuildContext context, int index) => + AnimationConfiguration.staggeredList( + position: index, + duration: const Duration(milliseconds: 750), + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: GradeSessionCourses( + index: index, + sessionName: _sessionName( + model.sessionOrder[index], AppIntl.of(context)!), + courses: + model.coursesBySession[model.sessionOrder[index]]!, + sessionOrder: model.sessionOrder, + ), + ), + ), + )), + ), + ); + } + + /// Build the complete name of the session for the user local. + String _sessionName(String shortName, AppIntl intl) { + switch (shortName[0]) { + case 'H': + return "${intl.session_winter} ${shortName.substring(1)}"; + case 'A': + return "${intl.session_fall} ${shortName.substring(1)}"; + case 'É': + return "${intl.session_summer} ${shortName.substring(1)}"; + default: + return intl.session_without; + } + } +} diff --git a/lib/features/student/grades/widget-grade-view/grade_session_courses.dart b/lib/features/student/grades/widget-grade-view/grade_session_courses.dart new file mode 100644 index 000000000..26f4a62c2 --- /dev/null +++ b/lib/features/student/grades/widget-grade-view/grade_session_courses.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:ets_api_clients/models.dart'; +import 'package:notredame/features/app/navigation/router_paths.dart'; +import 'package:notredame/utils/app_theme.dart'; +import 'package:notredame/features/student/grades/widgets/grade_button.dart'; +import 'package:notredame/features/app/navigation/navigation_service.dart'; +import 'package:notredame/utils/locator.dart'; + +class GradeSessionCourses extends StatelessWidget { + final int index; + final String sessionName; + final List courses; + final List sessionOrder; + + GradeSessionCourses({ + required this.index, + required this.sessionName, + required this.courses, + required this.sessionOrder, + }); + + final NavigationService _navigationService = locator(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + sessionName, + style: const TextStyle( + fontSize: 25, + color: AppTheme.etsLightRed, + ), + ), + IconButton( + icon: const Icon(Icons.today, color: AppTheme.etsDarkGrey), + onPressed: () => _navigationService.pushNamed( + RouterPaths.defaultSchedule, + arguments: sessionOrder[index]), + ), + ], + ), + const SizedBox(height: 16.0), + Wrap( + children: courses + .map((course) => GradeButton(course, showDiscovery: index == 0)) + .toList(), + ), + ], + ), + ); + } +} diff --git a/lib/features/welcome/login/login_view.dart b/lib/features/welcome/login/login_view.dart index a71d7618b..297fe58bf 100644 --- a/lib/features/welcome/login/login_view.dart +++ b/lib/features/welcome/login/login_view.dart @@ -1,22 +1,18 @@ -// Flutter imports: -import 'package:flutter/material.dart'; +// LoginView.dart -// Package imports: +import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:fluttertoast/fluttertoast.dart'; +import 'package:notredame/features/welcome/login/widget/app_ets_logo.dart'; +import 'package:notredame/features/welcome/login/widget/forgot_password_link.dart'; +import 'package:notredame/features/welcome/login/widget/login_button.dart'; +import 'package:notredame/features/welcome/login/widget/login_widget.dart'; +import 'package:notredame/features/welcome/login/widget/need_help_link.dart'; +import 'package:notredame/features/welcome/login/widget/universal_code_field.dart'; import 'package:stacked/stacked.dart'; -// Project imports: -import 'package:notredame/features/app/navigation/router_paths.dart'; -import 'package:notredame/features/app/integration/launch_url_service.dart'; -import 'package:notredame/features/app/navigation/navigation_service.dart'; -import 'package:notredame/features/app/analytics/remote_config_service.dart'; -import 'package:notredame/features/welcome/login/login_mask.dart'; -import 'package:notredame/utils/utils.dart'; import 'package:notredame/features/welcome/login/login_viewmodel.dart'; -import 'package:notredame/utils/locator.dart'; import 'package:notredame/utils/app_theme.dart'; +import 'package:notredame/utils/utils.dart'; import 'package:notredame/features/welcome/widgets/password_text_field.dart'; class LoginView extends StatefulWidget { @@ -29,13 +25,6 @@ class _LoginViewState extends State { final FocusScopeNode _focusNode = FocusScopeNode(); - final NavigationService _navigationService = locator(); - - final LaunchUrlService _launchUrlService = locator(); - - final RemoteConfigService _remoteConfigService = - locator(); - /// Unique key of the login form form final GlobalKey formKey = GlobalKey(); @@ -51,227 +40,51 @@ class _LoginViewState extends State { context, AppTheme.etsLightRed, AppTheme.primaryDark), resizeToAvoidBottomInset: false, body: Builder( - builder: (BuildContext context) => SafeArea( - minimum: const EdgeInsets.all(20.0), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Form( - key: formKey, - onChanged: () { - setState(() { - formKey.currentState?.validate(); - }); - }, - child: AutofillGroup( - child: FocusScope( - node: _focusNode, - child: Column( - children: [ - const SizedBox( - height: 48, - ), - Hero( - tag: 'ets_logo', - child: SvgPicture.asset( - "assets/images/ets_white_logo.svg", - excludeFromSemantics: true, - width: 90, - height: 90, - colorFilter: ColorFilter.mode( - Theme.of(context).brightness == - Brightness.light - ? Colors.white - : AppTheme.etsLightRed, - BlendMode.srcIn), - )), - const SizedBox( - height: 48, - ), - TextFormField( - autofillHints: const [ - AutofillHints.username - ], - cursorColor: Colors.white, - keyboardType: - TextInputType.visiblePassword, - decoration: InputDecoration( - enabledBorder: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.white70)), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.white, - width: borderRadiusOnFocus)), - focusedErrorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: errorTextColor, - width: borderRadiusOnFocus)), - errorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: errorTextColor, - width: borderRadiusOnFocus)), - labelText: AppIntl.of(context)! - .login_prompt_universal_code, - labelStyle: const TextStyle( - color: Colors.white54), - errorStyle: - TextStyle(color: errorTextColor), - suffixIcon: Tooltip( - key: tooltipkey, - triggerMode: - TooltipTriggerMode.manual, - message: AppIntl.of(context)! - .universal_code_example, - preferBelow: true, - child: IconButton( - icon: const Icon(Icons.help, - color: Colors.white), - onPressed: () { - tooltipkey.currentState - ?.ensureTooltipVisible(); - }, - )), - ), - autofocus: true, - style: - const TextStyle(color: Colors.white), - onEditingComplete: _focusNode.nextFocus, - validator: model.validateUniversalCode, - initialValue: model.universalCode, - inputFormatters: [ - LoginMask(), - ], - ), - const SizedBox( - height: 16, - ), - PasswordFormField( - validator: model.validatePassword, - onEditionComplete: - _focusNode.nextFocus), - Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: InkWell( - child: Text( - AppIntl.of(context)! - .forgot_password, - style: const TextStyle( - decoration: - TextDecoration.underline, - color: Colors.white), - ), - onTap: () { - final signetsPasswordResetUrl = - _remoteConfigService - .signetsPasswordResetUrl; - if (signetsPasswordResetUrl != "") { - _launchUrlService.launchInBrowser( - _remoteConfigService - .signetsPasswordResetUrl, - Theme.of(context).brightness); - } else { - Fluttertoast.showToast( - msg: AppIntl.of(context)! - .error); - } - }, - ), - ), - ), - const SizedBox( - height: 24, - ), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: !model.canSubmit - ? null - : () async { - final String error = - await model.authenticate(); - - setState(() { - if (error.isNotEmpty) { - Fluttertoast.showToast( - msg: error); - } - formKey.currentState?.reset(); - }); - }, - style: ButtonStyle( - backgroundColor: - MaterialStateProperty.all( - model.canSubmit - ? colorButton - : Colors.white38), - padding: MaterialStateProperty.all( - const EdgeInsets.symmetric( - vertical: 16)), - ), - child: Text( - AppIntl.of(context)! - .login_action_sign_in, - style: TextStyle( - color: model.canSubmit - ? submitTextColor - : Colors.white60, - fontSize: 18), - ), - ), - ), - Center( - child: Padding( - padding: const EdgeInsets.only(top: 24), - child: InkWell( - child: Text( - AppIntl.of(context)!.need_help, - style: const TextStyle( - decoration: - TextDecoration.underline, - color: Colors.white), - ), - onTap: () async { - _navigationService.pushNamed( - RouterPaths.faq, - arguments: - Utils.getColorByBrightness( - context, - AppTheme.etsLightRed, - AppTheme.primaryDark)); - }, - ), - ), - ), - ], - ), - ), - ), - ), - Wrap( - direction: Axis.vertical, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: -20, - children: [ - Text( - AppIntl.of(context)!.login_applets_logo, - style: const TextStyle(color: Colors.white), - ), - Image.asset( - 'assets/images/applets_white_logo.png', - excludeFromSemantics: true, - width: 100, - height: 100, + builder: (BuildContext context) => SafeArea( + minimum: const EdgeInsets.all(20.0), + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Form( + key: formKey, + onChanged: () { + setState(() { + formKey.currentState?.validate(); + }); + }, + child: AutofillGroup( + child: FocusScope( + node: _focusNode, + child: Column( + children: [ + const SizedBox(height: 48), + const LogoWidget(), + const SizedBox(height: 48), + UniversalCodeField( + borderRadiusOnFocus: borderRadiusOnFocus, + tooltipKey: tooltipkey, + model: model, ), + const SizedBox(height: 16), + PasswordFormField( + validator: model.validatePassword, + onEditionComplete: _focusNode.nextFocus), + ForgotPasswordLink(), + const SizedBox(height: 24), + LoginButton(formKey: formKey, model: model), + NeedHelpLink(), ], ), - ], + ), ), ), - )), + const AppEtsLogo(), + ], + ), + ), + ), + ), ), ); @@ -280,13 +93,4 @@ class _LoginViewState extends State { _focusNode.dispose(); super.dispose(); } - - Color get errorTextColor => - Utils.getColorByBrightness(context, Colors.amberAccent, Colors.redAccent); - - Color get colorButton => - Utils.getColorByBrightness(context, Colors.white, AppTheme.etsLightRed); - - Color get submitTextColor => - Utils.getColorByBrightness(context, AppTheme.etsLightRed, Colors.white); } diff --git a/lib/features/welcome/login/widget/app_ets_logo.dart b/lib/features/welcome/login/widget/app_ets_logo.dart new file mode 100644 index 000000000..6184c2463 --- /dev/null +++ b/lib/features/welcome/login/widget/app_ets_logo.dart @@ -0,0 +1,29 @@ +// widgets/AppletsLogo.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class AppEtsLogo extends StatelessWidget { + const AppEtsLogo({super.key}); + + @override + Widget build(BuildContext context) { + return Wrap( + direction: Axis.vertical, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: -20, + children: [ + Text( + AppIntl.of(context)!.login_applets_logo, + style: const TextStyle(color: Colors.white), + ), + Image.asset( + 'assets/images/applets_white_logo.png', + excludeFromSemantics: true, + width: 100, + height: 100, + ), + ], + ); + } +} diff --git a/lib/features/welcome/login/widget/forgot_password_link.dart b/lib/features/welcome/login/widget/forgot_password_link.dart new file mode 100644 index 000000000..4282e44fc --- /dev/null +++ b/lib/features/welcome/login/widget/forgot_password_link.dart @@ -0,0 +1,44 @@ +// widgets/ForgotPasswordLink.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:notredame/features/app/integration/launch_url_service.dart'; +import 'package:notredame/features/app/analytics/remote_config_service.dart'; +import 'package:notredame/utils/locator.dart'; + +class ForgotPasswordLink extends StatelessWidget { + final LaunchUrlService _launchUrlService = locator(); + final RemoteConfigService _remoteConfigService = + locator(); + + ForgotPasswordLink({super.key}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: InkWell( + child: Text( + AppIntl.of(context)!.forgot_password, + style: const TextStyle( + decoration: TextDecoration.underline, color: Colors.white), + ), + onTap: () { + final signetsPasswordResetUrl = + _remoteConfigService.signetsPasswordResetUrl; + if (signetsPasswordResetUrl != "") { + _launchUrlService.launchInBrowser( + _remoteConfigService.signetsPasswordResetUrl, + Theme.of(context).brightness); + } else { + Fluttertoast.showToast(msg: AppIntl.of(context)!.error); + } + }, + ), + ), + ); + } +} diff --git a/lib/features/welcome/login/widget/login_button.dart b/lib/features/welcome/login/widget/login_button.dart new file mode 100644 index 000000000..30b287d34 --- /dev/null +++ b/lib/features/welcome/login/widget/login_button.dart @@ -0,0 +1,53 @@ +// widgets/LoginButton.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:notredame/features/welcome/login/login_viewmodel.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:notredame/utils/app_theme.dart'; + +class LoginButton extends StatelessWidget { + final GlobalKey formKey; + final LoginViewModel model; + + const LoginButton({super.key, required this.formKey, required this.model}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: !model.canSubmit + ? null + : () async { + final String error = await model.authenticate(); + + if (error.isNotEmpty) { + Fluttertoast.showToast(msg: error); + } + formKey.currentState?.reset(); + }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + model.canSubmit ? colorButton(context) : Colors.white38), + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(vertical: 16)), + ), + child: Text( + AppIntl.of(context)!.login_action_sign_in, + style: TextStyle( + color: + model.canSubmit ? submitTextColor(context) : Colors.white60, + fontSize: 18), + ), + ), + ); + } + + Color colorButton(BuildContext context) => + Utils.getColorByBrightness(context, Colors.white, AppTheme.etsLightRed); + + Color submitTextColor(BuildContext context) => + Utils.getColorByBrightness(context, AppTheme.etsLightRed, Colors.white); +} diff --git a/lib/features/welcome/login/widget/login_widget.dart b/lib/features/welcome/login/widget/login_widget.dart new file mode 100644 index 000000000..83670f454 --- /dev/null +++ b/lib/features/welcome/login/widget/login_widget.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:notredame/utils/app_theme.dart'; + +class LogoWidget extends StatelessWidget { + const LogoWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Hero( + tag: 'ets_logo', + child: SvgPicture.asset( + "assets/images/ets_white_logo.svg", + excludeFromSemantics: true, + width: 90, + height: 90, + colorFilter: ColorFilter.mode( + Theme.of(context).brightness == Brightness.light + ? Colors.white + : AppTheme.etsLightRed, + BlendMode.srcIn), + ), + ); + } +} diff --git a/lib/features/welcome/login/widget/need_help_link.dart b/lib/features/welcome/login/widget/need_help_link.dart new file mode 100644 index 000000000..38d5db0ca --- /dev/null +++ b/lib/features/welcome/login/widget/need_help_link.dart @@ -0,0 +1,36 @@ +// widgets/NeedHelpLink.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/app/navigation/navigation_service.dart'; +import 'package:notredame/features/app/navigation/router_paths.dart'; +import 'package:notredame/utils/utils.dart'; +import 'package:notredame/utils/locator.dart'; +import 'package:notredame/utils/app_theme.dart'; + +class NeedHelpLink extends StatelessWidget { + final NavigationService _navigationService = locator(); + + NeedHelpLink({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: InkWell( + child: Text( + AppIntl.of(context)!.need_help, + style: const TextStyle( + decoration: TextDecoration.underline, color: Colors.white), + ), + onTap: () async { + _navigationService.pushNamed(RouterPaths.faq, + arguments: Utils.getColorByBrightness( + context, AppTheme.etsLightRed, AppTheme.primaryDark)); + }, + ), + ), + ); + } +} diff --git a/lib/features/welcome/login/widget/universal_code_field.dart b/lib/features/welcome/login/widget/universal_code_field.dart new file mode 100644 index 000000000..4a8f8ab01 --- /dev/null +++ b/lib/features/welcome/login/widget/universal_code_field.dart @@ -0,0 +1,65 @@ +// widgets/UniversalCodeField.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:notredame/features/welcome/login/login_mask.dart'; +import 'package:notredame/features/welcome/login/login_viewmodel.dart'; +import 'package:notredame/utils/utils.dart'; + +class UniversalCodeField extends StatelessWidget { + final double borderRadiusOnFocus; + final GlobalKey tooltipKey; + final LoginViewModel model; + + const UniversalCodeField({ + super.key, + required this.borderRadiusOnFocus, + required this.tooltipKey, + required this.model, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + autofillHints: const [AutofillHints.username], + cursorColor: Colors.white, + keyboardType: TextInputType.visiblePassword, + decoration: InputDecoration( + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.white70)), + focusedBorder: OutlineInputBorder( + borderSide: + BorderSide(color: Colors.white, width: borderRadiusOnFocus)), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorTextColor(context), width: borderRadiusOnFocus)), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: errorTextColor(context), width: borderRadiusOnFocus)), + labelText: AppIntl.of(context)!.login_prompt_universal_code, + labelStyle: const TextStyle(color: Colors.white54), + errorStyle: TextStyle(color: errorTextColor(context)), + suffixIcon: Tooltip( + key: tooltipKey, + triggerMode: TooltipTriggerMode.manual, + message: AppIntl.of(context)!.universal_code_example, + preferBelow: true, + child: IconButton( + icon: const Icon(Icons.help, color: Colors.white), + onPressed: () { + tooltipKey.currentState?.ensureTooltipVisible(); + }, + )), + ), + autofocus: true, + style: const TextStyle(color: Colors.white), + onEditingComplete: () => FocusScope.of(context).nextFocus(), + validator: model.validateUniversalCode, + initialValue: model.universalCode, + inputFormatters: [LoginMask()], + ); + } + + Color errorTextColor(BuildContext context) => + Utils.getColorByBrightness(context, Colors.amberAccent, Colors.redAccent); +} diff --git a/test/ui/views/goldenFiles/authorView_1.png b/test/ui/views/goldenFiles/authorView_1.png index 164e40b8a..948071e01 100644 Binary files a/test/ui/views/goldenFiles/authorView_1.png and b/test/ui/views/goldenFiles/authorView_1.png differ diff --git a/test/ui/views/goldenFiles/authorView_2.png b/test/ui/views/goldenFiles/authorView_2.png index e6c42a77d..0f6ed6d25 100644 Binary files a/test/ui/views/goldenFiles/authorView_2.png and b/test/ui/views/goldenFiles/authorView_2.png differ diff --git a/test/ui/views/goldenFiles/gradesDetailsView_1.png b/test/ui/views/goldenFiles/gradesDetailsView_1.png index cf73c8e49..16b9206a5 100644 Binary files a/test/ui/views/goldenFiles/gradesDetailsView_1.png and b/test/ui/views/goldenFiles/gradesDetailsView_1.png differ diff --git a/test/ui/views/goldenFiles/gradesDetailsView_2.png b/test/ui/views/goldenFiles/gradesDetailsView_2.png index 6691db661..7e96ce9c8 100644 Binary files a/test/ui/views/goldenFiles/gradesDetailsView_2.png and b/test/ui/views/goldenFiles/gradesDetailsView_2.png differ diff --git a/test/ui/views/goldenFiles/gradesDetailsView_evaluation_not_completed.png b/test/ui/views/goldenFiles/gradesDetailsView_evaluation_not_completed.png index 1941b8006..83df07b19 100644 Binary files a/test/ui/views/goldenFiles/gradesDetailsView_evaluation_not_completed.png and b/test/ui/views/goldenFiles/gradesDetailsView_evaluation_not_completed.png differ diff --git a/test/ui/views/goldenFiles/newsDetailsView_1.png b/test/ui/views/goldenFiles/newsDetailsView_1.png index bec28632d..1bfdf664f 100644 Binary files a/test/ui/views/goldenFiles/newsDetailsView_1.png and b/test/ui/views/goldenFiles/newsDetailsView_1.png differ diff --git a/test/viewmodels/more_viewmodel_test.dart b/test/viewmodels/more_viewmodel_test.dart index 37f35847b..593b3ee07 100644 --- a/test/viewmodels/more_viewmodel_test.dart +++ b/test/viewmodels/more_viewmodel_test.dart @@ -2,7 +2,10 @@ import 'package:ets_api_clients/models.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; import 'package:mockito/mockito.dart'; +import 'package:notredame/features/app/analytics/analytics_service.dart'; +import 'package:notredame/features/app/analytics/remote_config_service.dart'; // Project imports: import 'package:notredame/features/app/navigation/router_paths.dart'; @@ -113,6 +116,7 @@ void main() { group('MoreViewModel - ', () { setUp(() async { + setupAnalyticsServiceMock(); cacheManagerMock = setupCacheManagerMock(); settingsManagerMock = setupSettingsManagerMock(); courseRepositoryMock = setupCourseRepositoryMock(); @@ -135,12 +139,16 @@ void main() { }); tearDown(() { + unregister(); unregister(); unregister(); unregister(); + unregister(); unregister(); unregister(); unregister(); + unregister(); + unregister(); }); group('logout - ', () {