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 602e81808..ce85ded5d 100644 Binary files a/test/ui/views/goldenFiles/profileView_1.png and b/test/ui/views/goldenFiles/profileView_1.png differ diff --git a/test/ui/views/profile_view_test.dart b/test/ui/views/profile_view_test.dart index 5ba85864d..a8022a9a7 100644 --- a/test/ui/views/profile_view_test.dart +++ b/test/ui/views/profile_view_test.dart @@ -4,6 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +// MODELS +import 'package:ets_api_clients/models.dart'; + // MANAGERS import 'package:notredame/core/managers/user_repository.dart'; @@ -22,6 +25,27 @@ import '../../mock/managers/user_repository_mock.dart'; void main() { AppIntl intl; UserRepository userRepository; + + final profileStudent = ProfileStudent( + firstName: "John", + lastName: "Doe", + permanentCode: "ABC123", + balance: "123456789"); + + // Make a test program object + final program = Program( + name: "Program name", + code: "1234", + average: "4.20", + accumulatedCredits: "123", + registeredCredits: "123", + completedCourses: "123", + failedCourses: "123", + equivalentCourses: "123", + status: "Actif"); + + final programList = [program]; + group('Profile view - ', () { setUp(() async { intl = await setupAppIntl(); @@ -29,9 +53,17 @@ void main() { userRepository = setupUserRepositoryMock(); setupAnalyticsServiceMock(); - UserRepositoryMock.stubGetInfo(userRepository as UserRepositoryMock); + UserRepositoryMock.stubGetInfo(userRepository as UserRepositoryMock, + toReturn: profileStudent); + UserRepositoryMock.stubProfileStudent( + userRepository as UserRepositoryMock, + toReturn: profileStudent); - UserRepositoryMock.stubGetPrograms(userRepository as UserRepositoryMock); + UserRepositoryMock.stubGetPrograms(userRepository as UserRepositoryMock, + toReturn: programList); + + UserRepositoryMock.stubPrograms(userRepository as UserRepositoryMock, + toReturn: programList); }); tearDown(() { @@ -39,42 +71,47 @@ void main() { unregister(); }); - 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(