From 1472dccd1c7d7e4371fc229962c3529225c9f01d Mon Sep 17 00:00:00 2001 From: Camille Brulotte <37625944+camillebrulotte@users.noreply.github.com> Date: Thu, 7 Sep 2023 16:51:48 -0400 Subject: [PATCH] Update profil page (#833) * Add ui * Make program completion work * Fix bug * [CI UPDATE GOLDENS] * Fix loading error * Fixed loading * Profile view test * Student program test * ViewModel test * Fix imports * Imports * [BOT] Applying version. * [BOT] Applying format. * Clean imports * [CI UPDATE GOLDENS] * Added padding to the loading * Update goldens * Add missing import * [BOT] Update golden files --------- Co-authored-by: Antoine Martineau Co-authored-by: camillebrulotte Co-authored-by: clubapplets-server --- l10n/intl_en.arb | 5 +- l10n/intl_fr.arb | 5 +- lib/core/constants/programs_credits.dart | 25 ++ lib/core/managers/course_repository.dart | 1 - lib/core/viewmodels/profile_viewmodel.dart | 37 ++ lib/ui/views/profile_view.dart | 369 +++++++++++++++++--- lib/ui/widgets/student_program.dart | 133 +++++-- pubspec.yaml | 2 +- test/ui/views/goldenFiles/profileView_1.png | Bin 1779 -> 6838 bytes test/ui/views/profile_view_test.dart | 75 +++- test/ui/widgets/student_program_test.dart | 60 ++-- test/viewmodels/profile_viewmodel_test.dart | 75 ++++ 12 files changed, 640 insertions(+), 147 deletions(-) create mode 100644 lib/core/constants/programs_credits.dart diff --git a/l10n/intl_en.arb b/l10n/intl_en.arb index cd00445e2..616262ef6 100644 --- a/l10n/intl_en.arb +++ b/l10n/intl_en.arb @@ -143,7 +143,7 @@ "profile_first_name": "First Name", "profile_last_name": "Last Name", "profile_permanent_code": "Permanent Code", - "profile_balance": "Balance", + "profile_balance": "My balance", "profile_code_program": "Code", "profile_average_program": "Average", "profile_number_accumulated_credits_program": "Accumulated Credits", @@ -152,6 +152,9 @@ "profile_number_failed_courses_program": "Failed Courses", "profile_number_equivalent_courses_program": "Equivalent Courses", "profile_status_program": "Status", + "profile_program_completion": "Program completion", + "profile_program_completion_not_available": "N/A", + "profile_other_programs": "Other Programs", "ets_security_title": "Security", "ets_monets_title": "MonÉTS", diff --git a/l10n/intl_fr.arb b/l10n/intl_fr.arb index 9e2659b6c..2f304e4cb 100644 --- a/l10n/intl_fr.arb +++ b/l10n/intl_fr.arb @@ -143,7 +143,7 @@ "profile_first_name": "Prénom", "profile_last_name": "Nom de famille", "profile_permanent_code": "Code permanent", - "profile_balance": "Solde", + "profile_balance": "Votre solde", "profile_code_program": "Code", "profile_average_program": "Moyenne", "profile_number_accumulated_credits_program": "Crédits complétés", @@ -152,6 +152,9 @@ "profile_number_failed_courses_program": "Cours échoués", "profile_number_equivalent_courses_program": "Cours d'équivalence", "profile_status_program": "Statut", + "profile_program_completion": "Complétion du programme", + "profile_program_completion_not_available": "ND", + "profile_other_programs": "Autres Programmes", "ets_security_title": "Sécurité", "ets_monets_title": "MonÉTS", diff --git a/lib/core/constants/programs_credits.dart b/lib/core/constants/programs_credits.dart new file mode 100644 index 000000000..186b4aed7 --- /dev/null +++ b/lib/core/constants/programs_credits.dart @@ -0,0 +1,25 @@ +class ProgramCredits { + Map programsCredits = { + "7625": 117, + "7694": 115, + "7084": 116, + "7684": 115, + "6556": 117, + "6557": 118, + "7086": 116, + // TODO: Ajouter le code lorsque le programme sera en vigeur + // (https://www.etsmtl.ca/etudes/premier-cycle/Baccalaureat-informatique-distribuee) + // "À venir" : 90", + "5766": 30, + "4567": 30, + "4412": 30, + "4563": 30, + "4684": 30, + "4329": 30, + "4288": 30, + "Maîtrise": 45, + "Programme court": 15, + "Certificat": 30, + "DESS": 30, + }; +} diff --git a/lib/core/managers/course_repository.dart b/lib/core/managers/course_repository.dart index e1a02767e..f3182a02a 100644 --- a/lib/core/managers/course_repository.dart +++ b/lib/core/managers/course_repository.dart @@ -1,6 +1,5 @@ // FLUTTER / DART / THIRD-PARTIES import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; diff --git a/lib/core/viewmodels/profile_viewmodel.dart b/lib/core/viewmodels/profile_viewmodel.dart index b80d03aab..853cd9754 100644 --- a/lib/core/viewmodels/profile_viewmodel.dart +++ b/lib/core/viewmodels/profile_viewmodel.dart @@ -4,12 +4,18 @@ import 'package:stacked/stacked.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +// SERVICES +import 'package:notredame/core/services/analytics_service.dart'; + // MANAGERS import 'package:notredame/core/managers/user_repository.dart'; // MODELS import 'package:ets_api_clients/models.dart'; +// CONSTANTS +import 'package:notredame/core/constants/programs_credits.dart'; + // OTHERS import 'package:notredame/locator.dart'; @@ -17,6 +23,8 @@ class ProfileViewModel extends FutureViewModel> { /// Load the user final UserRepository _userRepository = locator(); + final AnalyticsService analyticsService = locator(); + /// Localization class of the application. final AppIntl _appIntl; @@ -38,6 +46,35 @@ class ProfileViewModel extends FutureViewModel> { ProfileViewModel({@required AppIntl intl}) : _appIntl = intl; + double get programProgression { + final ProgramCredits programCredits = ProgramCredits(); + int percentage = 0; + + if (programList.isNotEmpty) { + final int numberOfCreditsCompleted = + int.parse(programList[programList.length - 1].accumulatedCredits); + final String code = programList[programList.length - 1].code; + bool foundMatch = false; + + programCredits.programsCredits.forEach((key, value) { + if (key == code || + programList[programList.length - 1].name.startsWith(key)) { + percentage = (numberOfCreditsCompleted / value * 100).round(); + foundMatch = true; + } + }); + + if (!foundMatch) { + final String programName = programList[programList.length - 1].name; + analyticsService.logEvent("profile_view", + 'The program $programName (code: $code) does not match any program'); + percentage = 0; + } + } + + return percentage.toDouble(); + } + @override // ignore: type_annotate_public_apis void onError(error) { diff --git a/lib/ui/views/profile_view.dart b/lib/ui/views/profile_view.dart index d0385109f..c90eb2912 100644 --- a/lib/ui/views/profile_view.dart +++ b/lib/ui/views/profile_view.dart @@ -2,10 +2,14 @@ import 'package:flutter/material.dart'; import 'package:stacked/stacked.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:percent_indicator/percent_indicator.dart'; // VIEW-MODEL import 'package:notredame/core/viewmodels/profile_viewmodel.dart'; +// MODEL +import 'package:ets_api_clients/models.dart'; + // SERVICES import 'package:notredame/core/services/analytics_service.dart'; @@ -37,66 +41,317 @@ class _ProfileViewState extends State { @override Widget build(BuildContext context) => ViewModelBuilder.reactive( - viewModelBuilder: () => ProfileViewModel(intl: AppIntl.of(context)), - builder: (context, model, child) { - return RefreshIndicator( - onRefresh: () => model.refresh(), - child: Stack( + viewModelBuilder: () => ProfileViewModel(intl: AppIntl.of(context)), + builder: (context, model, child) { + return RefreshIndicator( + onRefresh: () => model.refresh(), + child: SingleChildScrollView( + child: Column( children: [ - ListView(padding: EdgeInsets.zero, children: [ - ListTile( - title: Text( - AppIntl.of(context).profile_student_status_title, - style: const TextStyle(color: AppTheme.etsLightRed), - ), - ), - ListTile( - title: Text(AppIntl.of(context).profile_balance), - trailing: Text(model.profileStudent.balance), - ), - const Divider( - thickness: 2, - indent: 10, - endIndent: 10, - ), - ListTile( - title: Text( - AppIntl.of(context).profile_personal_information_title, - style: const TextStyle(color: AppTheme.etsLightRed), - ), - ), - ListTile( - title: Text(AppIntl.of(context).profile_first_name), - trailing: Text(model.profileStudent.firstName), - ), - ListTile( - title: Text(AppIntl.of(context).profile_last_name), - trailing: Text(model.profileStudent.lastName), - ), - ListTile( - title: Text(AppIntl.of(context).profile_permanent_code), - trailing: Text(model.profileStudent.permanentCode)), - ListTile( - title: Text( - AppIntl.of(context).login_prompt_universal_code), - trailing: Text(model.universalAccessCode)), - ListView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - reverse: true, - physics: const ScrollPhysics(), - itemCount: model.programList.length, - itemBuilder: (BuildContext context, int index) { - return StudentProgram(model.programList[index]); - }, - ), - ]), if (model.isBusy) - buildLoading(isInteractionLimitedWhileLoading: false) + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: + buildLoading(isInteractionLimitedWhileLoading: false), + ) else - const SizedBox() + buildPage(context, model) + ], + ), + ), + ); + }, + ); +} + +Widget buildPage(BuildContext context, ProfileViewModel model) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(5.0, 10.0, 5.0, 5.0), + child: SizedBox( + height: 90, + child: getMainInfoCard(model), + ), + ), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 8.0), + child: getMyInfosCard(model, context), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 4.0), + child: getMyBalanceCard(model, context), + ), ], ), - ); - }); + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0.0, 0.0, 8.0, 4.0), + child: getProgramCompletion(model, context), + ), + ], + ), + ), + ], + ), + const Divider( + thickness: 2, + indent: 10, + endIndent: 10, + ), + getCurrentProgramTile(model.programList, context), + const Divider( + thickness: 2, + indent: 10, + endIndent: 10, + ), + Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0), + child: Text( + AppIntl.of(context).profile_other_programs, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.etsLightRed), + ), + ), + ], + ), + Column( + children: [ + for (var i = 0; i < model.programList.length - 1; i++) + StudentProgram(model.programList[i]), + ], + ), + const SizedBox(height: 10.0), + ], + ); + +Card getMainInfoCard(ProfileViewModel model) { + var programName = ""; + if (model.programList.isNotEmpty) { + programName = model.programList.last.name; + } + + return Card( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(5.0), + child: Text( + '${model.profileStudent.firstName} ${model.profileStudent.lastName}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(5.0), + child: Text( + programName, + style: const TextStyle( + fontSize: 16, + ), + ), + ), + ], + ), + ), + ); +} + +Card getMyInfosCard(ProfileViewModel model, BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 3.0), + child: Text( + AppIntl.of(context).profile_permanent_code, + style: const TextStyle( + fontSize: 16, + ), + ), + ), + Center( + child: Text( + model.profileStudent.permanentCode, + style: const TextStyle(fontSize: 14), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 3.0), + child: Text( + AppIntl.of(context).login_prompt_universal_code, + style: const TextStyle(fontSize: 16), + ), + ), + Center( + child: Text( + model.universalAccessCode, + style: const TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ); +} + +Card getMyBalanceCard(ProfileViewModel model, BuildContext context) { + final stringBalance = model.profileStudent.balance; + var balance = 0.0; + + if (stringBalance.isNotEmpty) { + balance = double.parse(stringBalance + .substring(0, stringBalance.length - 1) + .replaceAll(",", ".")); + } + + return Card( + color: balance > 0 ? Colors.red : Colors.green, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0, left: 16.0, bottom: 3.0), + child: Text( + AppIntl.of(context).profile_balance, + style: const TextStyle( + fontSize: 16, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Center( + child: Text( + stringBalance, + style: const TextStyle(fontSize: 18), + ), + ), + ), + ], + ), + ); +} + +Card getProgramCompletion(ProfileViewModel model, BuildContext context) { + return Card( + child: SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + AppIntl.of(context).profile_program_completion, + style: const TextStyle( + fontSize: 16, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 30.0, bottom: 37.0), + child: Center(child: getLoadingIndicator(model, context))), + ], + ), + ), + ); +} + +CircularPercentIndicator getLoadingIndicator( + ProfileViewModel model, BuildContext context) { + final double percentage = model.programProgression; + + return CircularPercentIndicator( + animation: true, + animationDuration: 1100, + radius: 40, + lineWidth: 10, + percent: percentage / 100, + circularStrokeCap: CircularStrokeCap.round, + center: Text( + percentage != 0 + ? '$percentage%' + : AppIntl.of(context).profile_program_completion_not_available, + style: const TextStyle(fontSize: 20), + ), + progressColor: Colors.green, + ); +} + +Column getCurrentProgramTile(List programList, BuildContext context) { + if (programList.isNotEmpty) { + final program = programList.last; + + final List dataTitles = [ + AppIntl.of(context).profile_code_program, + AppIntl.of(context).profile_average_program, + AppIntl.of(context).profile_number_accumulated_credits_program, + AppIntl.of(context).profile_number_registered_credits_program, + AppIntl.of(context).profile_number_completed_courses_program, + AppIntl.of(context).profile_number_failed_courses_program, + AppIntl.of(context).profile_number_equivalent_courses_program, + AppIntl.of(context).profile_status_program + ]; + + final List dataFetched = [ + program.code, + program.average, + program.accumulatedCredits, + program.registeredCredits, + program.completedCourses, + program.failedCourses, + program.equivalentCourses, + program.status + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 8.0), + child: Text( + program.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppTheme.etsLightRed), + ), + ), + ...List.generate(dataTitles.length, (index) { + return Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(dataTitles[index]), + Text(dataFetched[index]), + ], + ), + ); + }), + ], + ); + } else { + return Column(); + } } diff --git a/lib/ui/widgets/student_program.dart b/lib/ui/widgets/student_program.dart index 161ecdfd7..6c19f01c0 100644 --- a/lib/ui/widgets/student_program.dart +++ b/lib/ui/widgets/student_program.dart @@ -1,4 +1,5 @@ // FLUTTER / DART / THIRD-PARTIES +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -8,13 +9,45 @@ import 'package:ets_api_clients/models.dart'; // OTHER import 'package:notredame/ui/utils/app_theme.dart'; -class StudentProgram extends StatelessWidget { +class StudentProgram extends StatefulWidget { final Program _program; - const StudentProgram(this._program); + @override + State createState() => _StudentProgramState(); +} + +class _StudentProgramState extends State + with TickerProviderStateMixin { + bool showProgramDetails = false; + AnimationController controller; + Animation rotateAnimation; + + @override + void initState() { + super.initState(); + + controller = AnimationController( + vsync: this, + duration: const Duration( + milliseconds: 200, + ), + value: 1.0); + + rotateAnimation = Tween(begin: pi, end: 0.0) + .animate(CurvedAnimation(parent: controller, curve: Curves.easeIn)); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final bool isLightMode = Theme.of(context).brightness == Brightness.light; + final List dataTitles = [ AppIntl.of(context).profile_code_program, AppIntl.of(context).profile_average_program, @@ -25,41 +58,81 @@ class StudentProgram extends StatelessWidget { AppIntl.of(context).profile_number_equivalent_courses_program, AppIntl.of(context).profile_status_program ]; + final List dataFetched = [ - _program.code, - _program.average, - _program.accumulatedCredits, - _program.registeredCredits, - _program.completedCourses, - _program.failedCourses, - _program.equivalentCourses, - _program.status + widget._program.code, + widget._program.average, + widget._program.accumulatedCredits, + widget._program.registeredCredits, + widget._program.completedCourses, + widget._program.failedCourses, + widget._program.equivalentCourses, + widget._program.status ]; - return Column( - children: [ - const Divider( - thickness: 2, - indent: 10, - endIndent: 10, + + return Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + unselectedWidgetColor: Colors.red, + colorScheme: ColorScheme.fromSwatch().copyWith( + onSecondary: Colors.red, ), - ListTile( - title: Text( - _program.name, - style: const TextStyle(color: AppTheme.etsLightRed), + ), + child: ExpansionTile( + onExpansionChanged: (value) { + setState(() { + showProgramDetails = !showProgramDetails; + + if (showProgramDetails) { + controller.reverse(from: pi); + } else { + controller.forward(from: 0.0); + } + }); + }, + title: Text( + widget._program.name, + style: TextStyle( + color: isLightMode ? Colors.black : Colors.white, ), ), - ListView.builder( - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemBuilder: (context, index) { - return ListTile( - title: Text(dataTitles[index]), - trailing: Text(dataFetched[index]), + trailing: Padding( + padding: const EdgeInsets.only(top: 5.0), + child: AnimatedBuilder( + animation: rotateAnimation, + builder: (BuildContext context, Widget child) { + return Transform.rotate( + angle: rotateAnimation.value, + child: const Icon( + Icons.keyboard_arrow_down_sharp, + color: AppTheme.etsLightRed, + ), ); }, - itemCount: dataTitles.length) - ], + child: const Icon( + Icons.keyboard_arrow_down_sharp, + color: AppTheme.etsLightRed, + ), + ), + ), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: List.generate(dataTitles.length, (index) { + return Padding( + padding: const EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(dataTitles[index]), + Text(dataFetched[index]), + ], + ), + ); + }), + ), + ], + ), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 8b405ba2a..3658fad4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ description: The 4th generation of ÉTSMobile, the main gateway between the Éco # pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 4.23.2+1 +version: 4.24.0+1 environment: sdk: ">=2.10.0 <3.0.0" diff --git a/test/ui/views/goldenFiles/profileView_1.png b/test/ui/views/goldenFiles/profileView_1.png index 602e818087a56f94a7f5b8c24fb41b1e5a1a7479..ce85ded5d9e7abeae67ba9bb11de004ce11557b7 100644 GIT binary patch literal 6838 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 az_Oae(Ep)|gLhAm<40|elpeb9^Zx=gUT(1f literal 1779 zcmeAS@N?(olHy`uVBq!ia0y~yVB`kkYaDDqk%X^n76U29;vjb?hIQv;UIIBR#ZI0f z96(URky;G-!WxGYS+^qJd?RqU#>FNogw z!FK+U?2kAfJvNmOh8O!RpMPWdAfDXwx_@yygIse2LnrCzWa@jy`1kVLXEZnO`SWY% z>)QYSBdY)3-@hS}1k+B=D9&@|XS}oB@YvNfMwKMGqv?Cubxs#`TUwmhwa&f&K=UAR z{q$cCo(saG+K?IVM-kL5XIl z=w|hSO*-3WJl=Ax^zPSxH~0U)_uf67BtIHHztbnjv?D*cXZ2Yok0W$Pde!_9)1HbI z+pH;#E#Le8_uJofHS&Wxu$w1GUH|$*up@_*Xx;SNu6uK_Jac;DfW{6iv?~=1+ znn+TVm)%b(); }); - testWidgets('contains student status', (WidgetTester tester) async { + testWidgets('contains main info', (WidgetTester tester) async { await tester.pumpWidget(localizedWidget(child: ProfileView())); await tester.pumpAndSettle(); - expect(find.widgetWithText(ListTile, intl.profile_student_status_title), + expect( + find.text("${profileStudent.firstName} ${profileStudent.lastName}"), findsOneWidget); - expect( - find.widgetWithText(ListTile, intl.profile_balance), findsOneWidget); + expect(find.text(program.name), findsNWidgets(2)); }); testWidgets('contains personal info', (WidgetTester tester) async { await tester.pumpWidget(localizedWidget(child: ProfileView())); await tester.pumpAndSettle(); - expect( - find.widgetWithText( - ListTile, intl.profile_personal_information_title), - findsOneWidget); + expect(find.text(profileStudent.permanentCode), findsOneWidget); - expect(find.widgetWithText(ListTile, intl.profile_first_name), - findsOneWidget); + expect(find.text(intl.profile_permanent_code), findsOneWidget); - expect(find.widgetWithText(ListTile, intl.profile_last_name), - findsOneWidget); + expect(find.text(intl.login_prompt_universal_code), findsOneWidget); + }); - expect(find.widgetWithText(ListTile, intl.profile_permanent_code), - findsOneWidget); + testWidgets('contains balance info', (WidgetTester tester) async { + await tester.pumpWidget(localizedWidget(child: ProfileView())); + await tester.pumpAndSettle(); - expect(find.widgetWithText(ListTile, intl.login_prompt_universal_code), - findsOneWidget); + expect(find.text(profileStudent.balance), findsOneWidget); + + expect(find.text(intl.profile_balance), findsOneWidget); + }); + + testWidgets('contains program completion', (WidgetTester tester) async { + await tester.pumpWidget(localizedWidget(child: ProfileView())); + await tester.pumpAndSettle(); + + expect(find.text(intl.profile_program_completion), findsOneWidget); }); group("golden - ", () { testWidgets("default view (no events)", (WidgetTester tester) async { - tester.binding.window.physicalSizeTestValue = const Size(800, 1410); + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); await tester.pumpWidget(localizedWidget(child: ProfileView())); await tester.pumpAndSettle(); diff --git a/test/ui/widgets/student_program_test.dart b/test/ui/widgets/student_program_test.dart index a3070ba53..1a77f8767 100644 --- a/test/ui/widgets/student_program_test.dart +++ b/test/ui/widgets/student_program_test.dart @@ -33,60 +33,40 @@ void main() { tearDown(() {}); group('has all categories - ', () { - testWidgets('contains one divider and one column', - (WidgetTester tester) async { + testWidgets('contains one expansion tile', (WidgetTester tester) async { await tester .pumpWidget(localizedWidget(child: StudentProgram(_program))); await tester.pumpAndSettle(); - final divider = find.byType(Divider); - expect(divider, findsNWidgets(1)); + expect(find.byType(ExpansionTile), findsOneWidget); - final column = find.byType(Column); - expect(column, findsNWidgets(1)); + expect(find.text(_program.name), findsOneWidget); }); - testWidgets('contains 9 listTile', (WidgetTester tester) async { + testWidgets('contains infos', (WidgetTester tester) async { await tester .pumpWidget(localizedWidget(child: StudentProgram(_program))); await tester.pumpAndSettle(); - final listTiles = find.byType(ListTile); - expect(listTiles, findsNWidgets(9)); + // Find the ExpansionTile and tap it to expand + final expansionTile = find.byType(ExpansionTile); + expect(expansionTile, findsOneWidget); + await tester.tap(expansionTile); + await tester.pumpAndSettle(); - expect(find.widgetWithText(ListTile, intl.profile_code_program), + expect(find.text(intl.profile_code_program), findsOneWidget); + expect(find.text(intl.profile_average_program), findsOneWidget); + expect(find.text(intl.profile_number_accumulated_credits_program), findsOneWidget); - - expect(find.widgetWithText(ListTile, intl.profile_average_program), + expect(find.text(intl.profile_number_registered_credits_program), findsOneWidget); - - expect( - find.widgetWithText( - ListTile, intl.profile_number_accumulated_credits_program), + expect(find.text(intl.profile_number_completed_courses_program), findsOneWidget); - - expect( - find.widgetWithText( - ListTile, intl.profile_number_registered_credits_program), + expect(find.text(intl.profile_number_failed_courses_program), findsOneWidget); - - expect( - find.widgetWithText( - ListTile, intl.profile_number_completed_courses_program), - findsOneWidget); - - expect( - find.widgetWithText( - ListTile, intl.profile_number_failed_courses_program), - findsOneWidget); - - expect( - find.widgetWithText( - ListTile, intl.profile_number_equivalent_courses_program), - findsOneWidget); - - expect(find.widgetWithText(ListTile, intl.profile_status_program), + expect(find.text(intl.profile_number_equivalent_courses_program), findsOneWidget); + expect(find.text(intl.profile_status_program), findsOneWidget); }); testWidgets('contains 17 Text fields', (WidgetTester tester) async { @@ -94,6 +74,12 @@ void main() { .pumpWidget(localizedWidget(child: StudentProgram(_program))); await tester.pumpAndSettle(); + // Find the ExpansionTile and tap it to expand + final expansionTile = find.byType(ExpansionTile); + expect(expansionTile, findsOneWidget); + await tester.tap(expansionTile); + await tester.pumpAndSettle(); + final text = find.byType(Text); expect(text, findsNWidgets(17)); }); diff --git a/test/viewmodels/profile_viewmodel_test.dart b/test/viewmodels/profile_viewmodel_test.dart index 352bfcbd4..0d5e4a06e 100644 --- a/test/viewmodels/profile_viewmodel_test.dart +++ b/test/viewmodels/profile_viewmodel_test.dart @@ -1,6 +1,7 @@ // FLUTTER / DART / THIRD-PARTIES import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:notredame/core/constants/programs_credits.dart'; // MANAGERS import 'package:notredame/core/managers/settings_manager.dart'; @@ -67,6 +68,7 @@ void main() { setUp(() async { // Setting up mocks userRepository = setupUserRepositoryMock(); + setupAnalyticsServiceMock(); viewModel = ProfileViewModel(intl: await setupAppIntl()); }); @@ -147,6 +149,79 @@ void main() { }); }); + group("programProgression - ", () { + test("calculates program progression correctly", () { + // Create a list of programs for testing + final List testPrograms = [ + Program( + name: 'Program A', + code: '7625', // Program code with matching entry in ProgramCredits + average: '3.50', + accumulatedCredits: '30', + registeredCredits: '60', + completedCourses: '10', + failedCourses: '0', + equivalentCourses: '0', + status: 'Actif', + ), + Program( + name: 'Program B', + code: '7694', // Program code with matching entry in ProgramCredits + average: '3.20', + accumulatedCredits: '45', + registeredCredits: '90', + completedCourses: '20', + failedCourses: '5', + equivalentCourses: '0', + status: 'Actif', + ), + ]; + + UserRepositoryMock.stubPrograms(userRepository as UserRepositoryMock, + toReturn: testPrograms); + + // Create an instance of ProgramCredits + final ProgramCredits programCredits = ProgramCredits(); + + // Calculate the program progression + final double progression = viewModel.programProgression; + + // Calculate the expected progression based on the defined ProgramCredits + final double expectedProgression = + (45 / programCredits.programsCredits['7694'] * 100).roundToDouble(); + + // Verify that the calculated progression matches the expected value + expect(progression, expectedProgression); + }); + + test("handles no matching program code", () { + // Create a list of programs with no matching program code + final List testPrograms = [ + Program( + name: 'Program X', + code: + '9999', // Program code with no matching entry in ProgramCredits + average: '3.00', + accumulatedCredits: '20', + registeredCredits: '40', + completedCourses: '5', + failedCourses: '2', + equivalentCourses: '0', + status: 'Actif', + ), + ]; + + UserRepositoryMock.stubPrograms(userRepository as UserRepositoryMock, + toReturn: testPrograms); + + // Calculate the program progression + final double progression = viewModel.programProgression; + + // The expected progression should be 0 when there is no matching program code + expect(progression, 0.0); + }); + }); + group('refresh -', () { test('Call SignetsAPI to get the user info and programs', () async { UserRepositoryMock.stubProfileStudent(