From ffe0290a07620c451ead5242f92ae5f393b278e9 Mon Sep 17 00:00:00 2001 From: Andrei-Constantin Mirciu <38398944+andreicmirciu@users.noreply.github.com> Date: Sun, 20 Jun 2021 19:32:05 +0300 Subject: [PATCH] Feedback checklist page (#196) * Add class feedback form initial view * Add emoji radio bar for answering questions * Insert questions into a card widget * Fix formatting * Initial version of feedback form. * Use AnimatedContainer for emoji form field * Clean unused code * Replace class icon * Extract questions from Firestore * Remove context from provider * Add question type * Begin saving questions' answers * Improve answers' saving process * Save all questions' answers * Minor code improvements * Better generalize questions/answers * Rename variables * Show feedback button only in debug mode * Create custom AutocompletePerson widget * Initiate creation of separate widgets * Improve questions instantiation logic * Remove S.of(context) * Add input questions' validator * Add input questions' validator * Create multiple question types * Create separate question display widget * Refactor emojis animation * Realign message at the beginning * Center anonymous form notice. * Improve emoji animation and remove Selectable. * Modify feedback policy message * Allow users to submit one time only feedback for a class * Add tests for feedback page * Align questions to the left * Remove unused onChanged methods * Remove unused code * Add initial checklist concept * Remove rating questions validator * Remove card widget from free text answers * Replace text form field with slider * Modify feedback page tests * Create feedback checklist page * Improve slider responses range * Make teacher field editable * Fix failing tests * Implement remote config functionality * Fix failing tests * Fix formatting * Modify checkboxes active color * Rename checklist categories * Make checkboxes clickable * Listen for changes when submitting feedback * WIP * Change question type from input to slider * Make dropdown answers localizable * Modify SizedBox height * Improve home page feedback card * Fix failing integration tests * Display checklist page button when feedback is enabled * Fix failing settings tests * Fix failing authentication tests * Fix linter errors * Add feedback icon tooltip * Change homepage card interface * Rename feedback answer class * Rename feedback provider method * Make remote config option accessible globally * Move remote config calls to setUpAndChooseStartScreen method * Make separate methods for each question type * Change the order of parameters * Remove business logic from UI component * Revert selectable changes * Replace dynamic type with Map * Fix linter errors * Fix failing tests * Rename feedback sections * Rename feedback checklist class * Remove old remote config settings * Remove didChangeDependencies() method * Inline list build method * Rename homepage feedback nudge * Rename variables * Replace dynamic with bool type * Replace InfoCard with ActionChip widget * Use null-checkers * Check if question index exists in database * Revise fetchCategories() method comment * Make provider methods private * Move remote_config.dart to resources/ * Disable feedback form button if data is still fetching * Remove getRemoteConfig() method from utils.dart * Redesign RemoteConfigService class * Fix feedback nudge visibility * Fix failing tests * Remove RemoteConfig constructor and instance variable. * Allow slider fields with no answer. * Remove validation from dropdown. * Fix failing tests * Remove possibility to add empty answers in database * Sort feedback categories alphabetically * Bump version * Fix exception when slider's TextFormField value is incorrect * Bump version * Prepare release for v1.2.12 * Resolve null situations * Resolve merge conflicts * Rename methods * Revert timetable_page.dart * Change feedback checklist page icon * Remove unnecessary initState() method * Inline "where" method * Remove unnecessary Column widget * Rename fetchCompletedFeedback() method * Rename onTap() method * Remove unnecessary "then" call in async function * Rename feedback section * Change "review" to "feedback" * Rename class_feedback_checklist.dart * Make feedback nudge text white * Add TODO item for text wrapping property Co-authored-by: Ioana Alexandru --- .../android/en-GB/changelogs/10016.txt | 4 + .../android/en-US/changelogs/10016.txt | 4 + .../metadata/android/ro/changelogs/10016.txt | 4 + lib/generated/intl/messages_en.dart | 24 ++- lib/generated/intl/messages_ro.dart | 22 ++- lib/generated/l10n.dart | 44 ++++- lib/l10n/intl_en.arb | 6 +- lib/l10n/intl_ro.arb | 4 + .../service/feedback_provider.dart | 41 +++++ .../view/class_feedback_checklist.dart | 163 ++++++++++++++++++ lib/pages/classes/view/classes_page.dart | 12 ++ lib/pages/home/feedback_nudge.dart | 85 +++++++++ lib/pages/home/home_page.dart | 6 + pubspec.lock | 2 +- pubspec.yaml | 2 +- test/authentication_test.dart | 57 ++++++ test/integration_test.dart | 4 + test/settings_test.dart | 58 +++++++ 18 files changed, 520 insertions(+), 22 deletions(-) create mode 100644 android/fastlane/metadata/android/en-GB/changelogs/10016.txt create mode 100644 android/fastlane/metadata/android/en-US/changelogs/10016.txt create mode 100644 android/fastlane/metadata/android/ro/changelogs/10016.txt create mode 100644 lib/pages/class_feedback/view/class_feedback_checklist.dart create mode 100644 lib/pages/home/feedback_nudge.dart diff --git a/android/fastlane/metadata/android/en-GB/changelogs/10016.txt b/android/fastlane/metadata/android/en-GB/changelogs/10016.txt new file mode 100644 index 000000000..27f51db82 --- /dev/null +++ b/android/fastlane/metadata/android/en-GB/changelogs/10016.txt @@ -0,0 +1,4 @@ +Added + - You can now submit feedback for all of your classes by pressing the icon in the top right corner of a class page. This option is only available for a limited time, so don't miss the opportunity! + - You can now view a list with all your classes where you completed the feedback form or not. + - You are now notified on the Homepage about how many feedback forms are still waiting to be completed. \ No newline at end of file diff --git a/android/fastlane/metadata/android/en-US/changelogs/10016.txt b/android/fastlane/metadata/android/en-US/changelogs/10016.txt new file mode 100644 index 000000000..27f51db82 --- /dev/null +++ b/android/fastlane/metadata/android/en-US/changelogs/10016.txt @@ -0,0 +1,4 @@ +Added + - You can now submit feedback for all of your classes by pressing the icon in the top right corner of a class page. This option is only available for a limited time, so don't miss the opportunity! + - You can now view a list with all your classes where you completed the feedback form or not. + - You are now notified on the Homepage about how many feedback forms are still waiting to be completed. \ No newline at end of file diff --git a/android/fastlane/metadata/android/ro/changelogs/10016.txt b/android/fastlane/metadata/android/ro/changelogs/10016.txt new file mode 100644 index 000000000..a68b5f140 --- /dev/null +++ b/android/fastlane/metadata/android/ro/changelogs/10016.txt @@ -0,0 +1,4 @@ +Adăugat + - Acum poți oferi feedback pentru toate materiile tale, apăsând pe iconița din colțul din dreapta sus de pe pagina unei materii. Această opțiune este disponibilă pentru un timp limitat, așa că nu rata ocazia! + - Acum poți vedea o listă cu toate materiile la care ai completat sau nu formularul de feedback. + - Acum ești notificat pe pagina Acasă referitor la câte formulare de feedback mai așteaptă să fie completate. \ No newline at end of file diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index d7081d715..cbb3db62b 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -37,13 +37,15 @@ class MessageLookup extends MessageLookupByLibrary { static m8(shortcutName) => "Are you sure you want to delete \"${shortcutName}\"?"; - static m9(name) => "Welcome, ${name}!"; + static m9(number) => "You need to complete ${number} more feedback forms!"; - static m10(email) => "There is already an account associated with ${email}."; + static m10(name) => "Welcome, ${name}!"; - static m11(n) => "Only ${n} options can be selected at a time."; + static m11(email) => "There is already an account associated with ${email}."; - static m12(provider) => "Please log in with ${provider} to continue."; + static m12(n) => "Only ${n} options can be selected at a time."; + + static m13(provider) => "Please log in with ${provider} to continue."; final messages = _notInlinedMessages(_notInlinedMessages); static _notInlinedMessages(_) => { @@ -204,6 +206,7 @@ class MessageLookup extends MessageLookupByLibrary { "messageEventDeleted" : MessageLookupByLibrary.simpleMessage("Event deleted successfully."), "messageEventEdited" : MessageLookupByLibrary.simpleMessage("Event modified successfully."), "messageFeedbackHasBeenSent" : MessageLookupByLibrary.simpleMessage("The review has been sent successfully."), + "messageFeedbackLeft" : m9, "messageGetStartedByPressing" : MessageLookupByLibrary.simpleMessage("Get started by pressing the"), "messageIAgreeToThe" : MessageLookupByLibrary.simpleMessage("I agree to the "), "messageNewUser" : MessageLookupByLibrary.simpleMessage("New user?"), @@ -222,13 +225,14 @@ class MessageLookup extends MessageLookupByLibrary { "messageWebsiteDeleted" : MessageLookupByLibrary.simpleMessage("Website deleted successfully."), "messageWebsiteEdited" : MessageLookupByLibrary.simpleMessage("Website modified successfully."), "messageWebsitePreview" : MessageLookupByLibrary.simpleMessage("Try tapping/long-pressing/hovering the preview to test the new website."), - "messageWelcomeName" : m9, + "messageWelcomeName" : m10, "messageWelcomeSimple" : MessageLookupByLibrary.simpleMessage("Welcome!"), "messageYouCanContribute" : MessageLookupByLibrary.simpleMessage("You can contribute to the app data, but you first need to request permissions."), "navigationAskPermissions" : MessageLookupByLibrary.simpleMessage("Ask for permissions"), - "navigationClassFeedback" : MessageLookupByLibrary.simpleMessage("Review"), + "navigationClassFeedback" : MessageLookupByLibrary.simpleMessage("Feedback"), "navigationClassInfo" : MessageLookupByLibrary.simpleMessage("Class information"), "navigationClasses" : MessageLookupByLibrary.simpleMessage("Classes"), + "navigationClassesFeedbackChecklist" : MessageLookupByLibrary.simpleMessage("Feedback checklist"), "navigationEventDetails" : MessageLookupByLibrary.simpleMessage("Event details"), "navigationFilter" : MessageLookupByLibrary.simpleMessage("Filter"), "navigationHome" : MessageLookupByLibrary.simpleMessage("Home"), @@ -244,6 +248,8 @@ class MessageLookup extends MessageLookupByLibrary { "sectionEvents" : MessageLookupByLibrary.simpleMessage("Events"), "sectionEventsComingUp" : MessageLookupByLibrary.simpleMessage("Events coming up"), "sectionFAQ" : MessageLookupByLibrary.simpleMessage("FAQ"), + "sectionFeedbackCompleted" : MessageLookupByLibrary.simpleMessage("Feedback completed"), + "sectionFeedbackNeeded" : MessageLookupByLibrary.simpleMessage("Feedback needed"), "sectionFrequentlyAccessedWebsites" : MessageLookupByLibrary.simpleMessage("Favourite websites"), "sectionGrading" : MessageLookupByLibrary.simpleMessage("Grading"), "sectionShortcuts" : MessageLookupByLibrary.simpleMessage("Shortcuts"), @@ -287,7 +293,7 @@ class MessageLookup extends MessageLookupByLibrary { "uniEventTypeTest" : MessageLookupByLibrary.simpleMessage("Test"), "warningAgreeTo" : MessageLookupByLibrary.simpleMessage("You need to agree to the "), "warningAuthenticationNeeded" : MessageLookupByLibrary.simpleMessage("Please authenticate in order to access this feature."), - "warningEmailInUse" : m10, + "warningEmailInUse" : m11, "warningEventNotEditable" : MessageLookupByLibrary.simpleMessage("This event cannot be edited."), "warningFavouriteWebsitesInitializationFailed" : MessageLookupByLibrary.simpleMessage("Could not read favourite websites."), "warningFeedbackAlreadySent" : MessageLookupByLibrary.simpleMessage("You have already submitted feedback for this class!"), @@ -304,7 +310,7 @@ class MessageLookup extends MessageLookupByLibrary { "warningNoPrivateWebsite" : MessageLookupByLibrary.simpleMessage("You have not created any private websites yet."), "warningNoneYet" : MessageLookupByLibrary.simpleMessage("None yet"), "warningNothingToEdit" : MessageLookupByLibrary.simpleMessage("There is nothing you have permission to edit."), - "warningOnlyNOptionsAtATime" : m11, + "warningOnlyNOptionsAtATime" : m12, "warningPasswordLength" : MessageLookupByLibrary.simpleMessage("The password must be 8 characters long or more."), "warningPasswordLowercase" : MessageLookupByLibrary.simpleMessage("The password must include at least one lowercase letter."), "warningPasswordNumber" : MessageLookupByLibrary.simpleMessage("The password must include at least one number."), @@ -315,7 +321,7 @@ class MessageLookup extends MessageLookupByLibrary { "warningSamePassword" : MessageLookupByLibrary.simpleMessage("The password must be different from the old one."), "warningTryAgainLater" : MessageLookupByLibrary.simpleMessage("Please try again later."), "warningUnableToReachNewsFeed" : MessageLookupByLibrary.simpleMessage("Unable to reach the news feed."), - "warningUseProvider" : m12, + "warningUseProvider" : m13, "warningWebsiteNameExists" : MessageLookupByLibrary.simpleMessage("A website with the same name already exists."), "warningYouNeedToSelectAssistant" : MessageLookupByLibrary.simpleMessage("You need to select your assistant for this class."), "warningYouNeedToSelectAtLeastOne" : MessageLookupByLibrary.simpleMessage("You need to select at least one option."), diff --git a/lib/generated/intl/messages_ro.dart b/lib/generated/intl/messages_ro.dart index ea097ccf1..9afea20da 100644 --- a/lib/generated/intl/messages_ro.dart +++ b/lib/generated/intl/messages_ro.dart @@ -37,13 +37,15 @@ class MessageLookup extends MessageLookupByLibrary { static m8(shortcutName) => "Sunteți sigur că doriți să ștergeți \"${shortcutName}\"?"; - static m9(name) => "Bine ai venit, ${name}!"; + static m9(number) => "Trebuie să completezi încă ${number} formulare de feedback!"; - static m10(email) => "Există deja un cont asociat cu adresa ${email}."; + static m10(name) => "Bine ai venit, ${name}!"; - static m11(n) => "Doar ${n} opțiuni pot fi selectate la un moment dat."; + static m11(email) => "Există deja un cont asociat cu adresa ${email}."; - static m12(provider) => "Folosiți ${provider} pentru a vă conecta."; + static m12(n) => "Doar ${n} opțiuni pot fi selectate la un moment dat."; + + static m13(provider) => "Folosiți ${provider} pentru a vă conecta."; final messages = _notInlinedMessages(_notInlinedMessages); static _notInlinedMessages(_) => { @@ -204,6 +206,7 @@ class MessageLookup extends MessageLookupByLibrary { "messageEventDeleted" : MessageLookupByLibrary.simpleMessage("Eveniment șters cu succes."), "messageEventEdited" : MessageLookupByLibrary.simpleMessage("Eveniment modificat cu succes."), "messageFeedbackHasBeenSent" : MessageLookupByLibrary.simpleMessage("Feedback trimis cu succes."), + "messageFeedbackLeft" : m9, "messageGetStartedByPressing" : MessageLookupByLibrary.simpleMessage("Începeți prin a apăsa butonul"), "messageIAgreeToThe" : MessageLookupByLibrary.simpleMessage("Sunt de acord cu "), "messageNewUser" : MessageLookupByLibrary.simpleMessage("Utilizator nou?"), @@ -222,13 +225,14 @@ class MessageLookup extends MessageLookupByLibrary { "messageWebsiteDeleted" : MessageLookupByLibrary.simpleMessage("Website-ul a fost șters cu succes."), "messageWebsiteEdited" : MessageLookupByLibrary.simpleMessage("Website modificat cu succes."), "messageWebsitePreview" : MessageLookupByLibrary.simpleMessage("Încercați să apăsați, să faceți hover sau să țineți apăsat ca să testați noul website."), - "messageWelcomeName" : m9, + "messageWelcomeName" : m10, "messageWelcomeSimple" : MessageLookupByLibrary.simpleMessage("Bine ai venit!"), "messageYouCanContribute" : MessageLookupByLibrary.simpleMessage("Poți contribui la datele din aplicație, dar trebuie mai întâi să ceri permisiuni."), "navigationAskPermissions" : MessageLookupByLibrary.simpleMessage("Cere permisiuni"), "navigationClassFeedback" : MessageLookupByLibrary.simpleMessage("Feedback"), "navigationClassInfo" : MessageLookupByLibrary.simpleMessage("Informații materie"), "navigationClasses" : MessageLookupByLibrary.simpleMessage("Materii"), + "navigationClassesFeedbackChecklist" : MessageLookupByLibrary.simpleMessage("Listă feedback"), "navigationEventDetails" : MessageLookupByLibrary.simpleMessage("Detalii eveniment"), "navigationFilter" : MessageLookupByLibrary.simpleMessage("Filtru"), "navigationHome" : MessageLookupByLibrary.simpleMessage("Acasă"), @@ -244,6 +248,8 @@ class MessageLookup extends MessageLookupByLibrary { "sectionEvents" : MessageLookupByLibrary.simpleMessage("Evenimente"), "sectionEventsComingUp" : MessageLookupByLibrary.simpleMessage("Evenimente următoare"), "sectionFAQ" : MessageLookupByLibrary.simpleMessage("Întrebări frecvente"), + "sectionFeedbackCompleted" : MessageLookupByLibrary.simpleMessage("Feedback completat"), + "sectionFeedbackNeeded" : MessageLookupByLibrary.simpleMessage("Feedback necesar"), "sectionFrequentlyAccessedWebsites" : MessageLookupByLibrary.simpleMessage("Website-uri favorite"), "sectionGrading" : MessageLookupByLibrary.simpleMessage("Punctaj"), "sectionShortcuts" : MessageLookupByLibrary.simpleMessage("Scurtături"), @@ -287,7 +293,7 @@ class MessageLookup extends MessageLookupByLibrary { "uniEventTypeTest" : MessageLookupByLibrary.simpleMessage("Test"), "warningAgreeTo" : MessageLookupByLibrary.simpleMessage("Trebuie să fiți de acord cu "), "warningAuthenticationNeeded" : MessageLookupByLibrary.simpleMessage("Autentificați-vă pentru a accesa această funcționalitate."), - "warningEmailInUse" : m10, + "warningEmailInUse" : m11, "warningEventNotEditable" : MessageLookupByLibrary.simpleMessage("Acest eveniment nu poate fi modificat."), "warningFavouriteWebsitesInitializationFailed" : MessageLookupByLibrary.simpleMessage("Nu se pot citi date despre site-urile favorite."), "warningFeedbackAlreadySent" : MessageLookupByLibrary.simpleMessage("Ați trimis deja feedback pentru această materie!"), @@ -304,7 +310,7 @@ class MessageLookup extends MessageLookupByLibrary { "warningNoPrivateWebsite" : MessageLookupByLibrary.simpleMessage("Nu ați creat nici un website privat încă."), "warningNoneYet" : MessageLookupByLibrary.simpleMessage("Nu există încă"), "warningNothingToEdit" : MessageLookupByLibrary.simpleMessage("Nu există nimic pentru care să aveți permisiuni de editare."), - "warningOnlyNOptionsAtATime" : m11, + "warningOnlyNOptionsAtATime" : m12, "warningPasswordLength" : MessageLookupByLibrary.simpleMessage("Parola trebuie să aibă cel puțin 8 caractere."), "warningPasswordLowercase" : MessageLookupByLibrary.simpleMessage("Parola trebuie să conțină cel putin o minusculă."), "warningPasswordNumber" : MessageLookupByLibrary.simpleMessage("Parola trebuie să conțină cel puțin un număr."), @@ -315,7 +321,7 @@ class MessageLookup extends MessageLookupByLibrary { "warningSamePassword" : MessageLookupByLibrary.simpleMessage("Parola trebuie sa fie diferită de cea veche."), "warningTryAgainLater" : MessageLookupByLibrary.simpleMessage("Încercați mai târziu."), "warningUnableToReachNewsFeed" : MessageLookupByLibrary.simpleMessage("Nu am putut încărca fluxul de știri."), - "warningUseProvider" : m12, + "warningUseProvider" : m13, "warningWebsiteNameExists" : MessageLookupByLibrary.simpleMessage("Există deja un site cu același nume."), "warningYouNeedToSelectAssistant" : MessageLookupByLibrary.simpleMessage("Trebuie să selectați asistentul de la această materie."), "warningYouNeedToSelectAtLeastOne" : MessageLookupByLibrary.simpleMessage("Trebuie să selectați cel puțin o opțiune."), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 15dc010ea..f5bd70a8b 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -615,6 +615,26 @@ class S { ); } + /// `Feedback needed` + String get sectionFeedbackNeeded { + return Intl.message( + 'Feedback needed', + name: 'sectionFeedbackNeeded', + desc: '', + args: [], + ); + } + + /// `Feedback completed` + String get sectionFeedbackCompleted { + return Intl.message( + 'Feedback completed', + name: 'sectionFeedbackCompleted', + desc: '', + args: [], + ); + } + /// `Main page` String get shortcutTypeMain { return Intl.message( @@ -1725,16 +1745,26 @@ class S { ); } - /// `Review` + /// `Feedback` String get navigationClassFeedback { return Intl.message( - 'Review', + 'Feedback', name: 'navigationClassFeedback', desc: '', args: [], ); } + /// `Feedback checklist` + String get navigationClassesFeedbackChecklist { + return Intl.message( + 'Feedback checklist', + name: 'navigationClassesFeedbackChecklist', + desc: '', + args: [], + ); + } + /// `Show all` String get filterMenuShowAll { return Intl.message( @@ -2605,6 +2635,16 @@ class S { ); } + /// `You need to complete {number} more feedback forms!` + String messageFeedbackLeft(Object number) { + return Intl.message( + 'You need to complete $number more feedback forms!', + name: 'messageFeedbackLeft', + desc: '', + args: [number], + ); + } + /// `Please check your inbox for the password reset e-mail.` String get infoPasswordResetEmailSent { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f3d0a93e9..f86d5114f 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -59,6 +59,8 @@ "sectionEventsComingUp": "Events coming up", "sectionFAQ": "FAQ", "sectionGrading": "Grading", + "sectionFeedbackNeeded": "Feedback needed", + "sectionFeedbackCompleted": "Feedback completed", "shortcutTypeMain": "Main page", "shortcutTypeClassbook": "Classbook", @@ -176,7 +178,8 @@ "navigationEventDetails": "Event details", "navigationNewsFeed": "News feed", "navigationClassInfo": "Class information", - "navigationClassFeedback": "Review", + "navigationClassFeedback": "Feedback", + "navigationClassesFeedbackChecklist": "Feedback checklist", "filterMenuShowAll": "Show all", "filterMenuShowMine": "Show only mine", @@ -271,6 +274,7 @@ "messageYouCanContribute": "You can contribute to the app data, but you first need to request permissions.", "messageThereAreNoEventsForSelected": "There are no events for the selected ", "messagePictureUpdatedSuccess": "Profile picture updated successfully.", + "messageFeedbackLeft": "You need to complete {number} more feedback forms!", "infoPasswordResetEmailSent": "Please check your inbox for the password reset e-mail.", "infoRelevance": "Try to choose the most restrictive category.", diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index 11644261d..4bb11c143 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -59,6 +59,8 @@ "sectionEventsComingUp": "Evenimente următoare", "sectionFAQ": "Întrebări frecvente", "sectionGrading": "Punctaj", + "sectionFeedbackNeeded": "Feedback necesar", + "sectionFeedbackCompleted": "Feedback completat", "shortcutTypeMain": "Pagina principală", "shortcutTypeClassbook": "Catalog", @@ -177,6 +179,7 @@ "navigationNewsFeed": "Știri", "navigationClassInfo": "Informații materie", "navigationClassFeedback": "Feedback", + "navigationClassesFeedbackChecklist": "Listă feedback", "filterMenuShowAll": "Arată tot", "filterMenuShowMine": "Arată doar pe ale mele", @@ -271,6 +274,7 @@ "messageYouCanContribute": "Poți contribui la datele din aplicație, dar trebuie mai întâi să ceri permisiuni.", "messageThereAreNoEventsForSelected": "Nu există evenimente pentru selecția de ", "messagePictureUpdatedSuccess": "Poza a fost actualizată cu succes.", + "messageFeedbackLeft": "Trebuie să completezi încă {number} formulare de feedback!", "infoPasswordResetEmailSent": "Please check your inbox for the password reset e-mail.", "infoRelevance": "Încercați să selectați cea mai restrictivă categorie.", diff --git a/lib/pages/class_feedback/service/feedback_provider.dart b/lib/pages/class_feedback/service/feedback_provider.dart index b58e9122b..fd61bffac 100644 --- a/lib/pages/class_feedback/service/feedback_provider.dart +++ b/lib/pages/class_feedback/service/feedback_provider.dart @@ -10,6 +10,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/cupertino.dart'; import 'package:acs_upb_mobile/widgets/toast.dart'; import 'package:acs_upb_mobile/generated/l10n.dart'; +import 'package:acs_upb_mobile/pages/classes/model/class.dart'; extension ClassFeedbackAnswerExtension on FeedbackAnswer { Map toData() { @@ -174,6 +175,7 @@ class FeedbackProvider with ChangeNotifier { true && userSubmittedFeedbackSuccessfully ?? true) || (responseAddedSuccessfully && userSubmittedFeedbackSuccessfully)) { + notifyListeners(); return true; } return false; @@ -198,4 +200,43 @@ class FeedbackProvider with ChangeNotifier { return false; } } + + Future> getClassesWithCompletedFeedback(String uid) async { + try { + final DocumentSnapshot snap = + await FirebaseFirestore.instance.collection('users').doc(uid).get(); + if (snap.data()['classesFeedback'] != null) { + return Map.from(snap.data()['classesFeedback']); + } + return null; + } catch (e) { + AppToast.show(S.current.errorSomethingWentWrong); + return null; + } + } + + Future countClassesWithoutFeedback( + String uid, Set userClasses) async { + try { + final Map classesFeedbackCompleted = + await getClassesWithCompletedFeedback(uid); + String feedbackFormsLeft; + + if (userClasses != null && classesFeedbackCompleted != null) { + feedbackFormsLeft = userClasses + .where( + (element) => !classesFeedbackCompleted.containsKey(element.id)) + .toSet() + .length + .toString(); + } else if (userClasses != null && classesFeedbackCompleted == null) { + feedbackFormsLeft = userClasses.length.toString(); + } + + return feedbackFormsLeft; + } catch (e) { + AppToast.show(S.current.errorSomethingWentWrong); + return null; + } + } } diff --git a/lib/pages/class_feedback/view/class_feedback_checklist.dart b/lib/pages/class_feedback/view/class_feedback_checklist.dart new file mode 100644 index 000000000..e7c093c4b --- /dev/null +++ b/lib/pages/class_feedback/view/class_feedback_checklist.dart @@ -0,0 +1,163 @@ +import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; +import 'package:acs_upb_mobile/pages/class_feedback/service/feedback_provider.dart'; +import 'package:acs_upb_mobile/pages/class_feedback/view/class_feedback_view.dart'; +import 'package:acs_upb_mobile/pages/classes/model/class.dart'; +import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; +import 'package:acs_upb_mobile/widgets/error_page.dart'; +import 'package:acs_upb_mobile/widgets/scaffold.dart'; +import 'package:acs_upb_mobile/widgets/toast.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:acs_upb_mobile/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ClassFeedbackChecklist extends StatefulWidget { + const ClassFeedbackChecklist({Key key, this.classes}) : super(key: key); + final Set classes; + + @override + _ClassFeedbackChecklistState createState() => _ClassFeedbackChecklistState(); +} + +class _ClassFeedbackChecklistState extends State { + Map classesFeedback = {}; + + Future fetchCompletedFeedback() async { + final feedbackProvider = Provider.of(context); + final authProvider = Provider.of(context, listen: false); + classesFeedback = await feedbackProvider + .getClassesWithCompletedFeedback(authProvider.uid); + if (mounted) setState(() {}); + } + + Widget checklistPage() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + Text( + S.current.sectionFeedbackNeeded, + style: Theme.of(context).textTheme.headline6, + ), + const SizedBox(height: 6), + FeedbackClassList( + classes: widget.classes + .where((element) => + !(classesFeedback?.containsKey(element.id) ?? false)) + ?.toSet(), + done: false, + ), + const Divider(thickness: 4), + const SizedBox(height: 12), + Text( + S.current.sectionFeedbackCompleted, + style: Theme.of(context).textTheme.headline6, + ), + const SizedBox(height: 6), + if (classesFeedback != null) + FeedbackClassList( + classes: widget.classes + .where((element) => classesFeedback.containsKey(element.id)) + ?.toSet(), + done: true, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + fetchCompletedFeedback(); + return AppScaffold( + title: Text(S.current.navigationClassesFeedbackChecklist), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + children: [ + if (widget.classes != null) + checklistPage() + else + ErrorPage( + errorMessage: S.current.messageNoClassesYet, + imgPath: 'assets/illustrations/undraw_empty.png', + info: [ + TextSpan(text: '${S.current.messageGetStartedByPressing} '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.edit_outlined, + size: + Theme.of(context).textTheme.subtitle1.fontSize + 2, + ), + ), + TextSpan(text: ' ${S.current.messageButtonAbove}.'), + ], + ), + ], + ), + ), + ), + ); + } +} + +class FeedbackClassList extends StatelessWidget { + const FeedbackClassList({Key key, this.classes, this.done}) : super(key: key); + final Set classes; + final bool done; + + @override + Widget build(BuildContext context) { + return Column( + children: classes + .map( + (classHeader) => + FeedbackClassListItem(classHeader: classHeader, done: done), + ) + .toList()); + } +} + +class FeedbackClassListItem extends StatelessWidget { + const FeedbackClassListItem({Key key, this.classHeader, this.done}) + : super(key: key); + + final ClassHeader classHeader; + final bool done; + + void onTap(BuildContext context) { + if (!done) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: Provider.of(context), + child: ClassFeedbackView(classHeader: classHeader), + ), + ), + ); + } else { + AppToast.show(S.current.warningFeedbackAlreadySent); + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Checkbox( + value: done, + onChanged: (_) => onTap(context), + activeColor: Colors.grey, + ), + title: Text( + classHeader.name, + style: done + ? Theme.of(context).textTheme.subtitle1.copyWith( + decoration: TextDecoration.lineThrough, + color: Theme.of(context).disabledColor) + : Theme.of(context).textTheme.subtitle1, + ), + onTap: () => onTap(context), + ); + } +} diff --git a/lib/pages/classes/view/classes_page.dart b/lib/pages/classes/view/classes_page.dart index bc1b3c1f0..16dea2e1f 100644 --- a/lib/pages/classes/view/classes_page.dart +++ b/lib/pages/classes/view/classes_page.dart @@ -1,5 +1,6 @@ import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; import 'package:acs_upb_mobile/generated/l10n.dart'; +import 'package:acs_upb_mobile/pages/class_feedback/view/class_feedback_checklist.dart'; import 'package:acs_upb_mobile/pages/classes/model/class.dart'; import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; import 'package:acs_upb_mobile/pages/classes/view/class_view.dart'; @@ -11,6 +12,7 @@ import 'package:acs_upb_mobile/widgets/spoiler.dart'; import 'package:flutter/material.dart'; import 'package:pedantic/pedantic.dart'; import 'package:provider/provider.dart'; +import 'package:acs_upb_mobile/resources/remote_config.dart'; class ClassesPage extends StatefulWidget { const ClassesPage({Key key}) : super(key: key); @@ -60,6 +62,16 @@ class _ClassesPageState extends State { // TODO(IoanaAlexandru): Simply show all classes if user is not authenticated needsToBeAuthenticated: true, actions: [ + if (RemoteConfigService.feedbackEnabled) + AppScaffoldAction( + icon: Icons.rate_review_outlined, + tooltip: S.current.navigationClassesFeedbackChecklist, + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ClassFeedbackChecklist(classes: headers), + ), + ), + ), AppScaffoldAction( icon: Icons.edit_outlined, tooltip: S.current.actionChooseClasses, diff --git a/lib/pages/home/feedback_nudge.dart b/lib/pages/home/feedback_nudge.dart new file mode 100644 index 000000000..a49bf0ba0 --- /dev/null +++ b/lib/pages/home/feedback_nudge.dart @@ -0,0 +1,85 @@ +import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; +import 'package:acs_upb_mobile/pages/class_feedback/service/feedback_provider.dart'; +import 'package:acs_upb_mobile/pages/class_feedback/view/class_feedback_checklist.dart'; +import 'package:acs_upb_mobile/pages/classes/model/class.dart'; +import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:acs_upb_mobile/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class FeedbackNudge extends StatefulWidget { + @override + _FeedbackNudgeState createState() => _FeedbackNudgeState(); +} + +class _FeedbackNudgeState extends State { + Set userClasses; + Map userClassesFeedbackProvided; + String feedbackFormsLeft; + + // TODO(AndreiMirciu): Find a better approach on how to calculate the number of feedback forms that need to be completed + Future fetchInfo() async { + final ClassProvider classProvider = + Provider.of(context, listen: false); + final authProvider = Provider.of(context, listen: false); + final feedbackProvider = + Provider.of(context, listen: false); + + userClasses = + (await classProvider.fetchClassHeaders(uid: authProvider.uid)).toSet(); + userClassesFeedbackProvided = await feedbackProvider + .getClassesWithCompletedFeedback(authProvider.uid); + feedbackFormsLeft = await feedbackProvider.countClassesWithoutFeedback( + authProvider.uid, userClasses); + + if (mounted) setState(() {}); + } + + @override + void initState() { + super.initState(); + fetchInfo(); + } + + @override + Widget build(BuildContext context) { + return Visibility( + visible: feedbackFormsLeft != null && feedbackFormsLeft != '0', + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: ActionChip( + padding: const EdgeInsets.all(12), + tooltip: S.current.navigationClassesFeedbackChecklist, + backgroundColor: Theme.of(context).accentColor, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ClassFeedbackChecklist(classes: userClasses), + ), + ); + }, + label: Row( + children: [ + Expanded( + // TODO(AndreiMirciu): Fix text wrapping property for both languages + child: Text( + S.current.messageFeedbackLeft(feedbackFormsLeft), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + Icon( + Icons.arrow_forward_ios_outlined, + size: Theme.of(context).textTheme.subtitle2.fontSize, + color: Colors.white, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index a8265c14e..affa1ea76 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -3,9 +3,11 @@ import 'package:acs_upb_mobile/generated/l10n.dart'; import 'package:acs_upb_mobile/navigation/routes.dart'; import 'package:acs_upb_mobile/pages/home/faq_card.dart'; import 'package:acs_upb_mobile/pages/home/favourite_websites_card.dart'; +import 'package:acs_upb_mobile/pages/home/feedback_nudge.dart'; import 'package:acs_upb_mobile/pages/home/news_feed_card.dart'; import 'package:acs_upb_mobile/pages/home/profile_card.dart'; import 'package:acs_upb_mobile/pages/home/upcoming_events_card.dart'; +import 'package:acs_upb_mobile/resources/remote_config.dart'; import 'package:acs_upb_mobile/widgets/scaffold.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -32,6 +34,10 @@ class HomePage extends StatelessWidget { body: ListView( children: [ if (authProvider.isAuthenticated) ProfileCard(), + if (authProvider.isAuthenticated && + !authProvider.isAnonymous && + RemoteConfigService.feedbackEnabled) + FeedbackNudge(), if (authProvider.isAuthenticated && !authProvider.isAnonymous) UpcomingEventsCard(onShowMore: () => tabController?.animateTo(1)), if (authProvider.isAuthenticated && !authProvider.isAnonymous) diff --git a/pubspec.lock b/pubspec.lock index 7e6b01e89..713b2771e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -748,7 +748,7 @@ packages: name: pedantic url: "https://pub.dartlang.org" source: hosted - version: "1.11.0" + version: "1.11.1" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bd0b9957b..b4e05745e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ description: A mobile application for students at ACS UPB. # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # # ACS UPB Mobile uses semantic versioning. You can read more in the CONTRIBUTING.md file. -version: 1.2.11+15 +version: 1.2.12+16 environment: sdk: ">=2.7.0 <3.0.0" diff --git a/test/authentication_test.dart b/test/authentication_test.dart index 4ce8b1b45..dcbc2baf3 100644 --- a/test/authentication_test.dart +++ b/test/authentication_test.dart @@ -3,6 +3,9 @@ import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; import 'package:acs_upb_mobile/authentication/view/login_view.dart'; import 'package:acs_upb_mobile/authentication/view/sign_up_view.dart'; import 'package:acs_upb_mobile/main.dart'; +import 'package:acs_upb_mobile/pages/class_feedback/service/feedback_provider.dart'; +import 'package:acs_upb_mobile/pages/classes/model/class.dart'; +import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; import 'package:acs_upb_mobile/pages/faq/model/question.dart'; import 'package:acs_upb_mobile/pages/faq/service/question_provider.dart'; import 'package:acs_upb_mobile/pages/filter/model/filter.dart'; @@ -40,6 +43,10 @@ class MockQuestionProvider extends Mock implements QuestionProvider {} class MockNewsProvider extends Mock implements NewsProvider {} +class MockFeedbackProvider extends Mock implements FeedbackProvider {} + +class MockClassProvider extends Mock implements ClassProvider {} + void main() { AuthProvider mockAuthProvider; WebsiteProvider mockWebsiteProvider; @@ -48,6 +55,8 @@ void main() { MockQuestionProvider mockQuestionProvider; UniEventProvider mockEventProvider; MockNewsProvider mockNewsProvider; + FeedbackProvider mockFeedbackProvider; + ClassProvider mockClassProvider; setUp(() async { WidgetsFlutterBinding.ensureInitialized(); @@ -116,6 +125,51 @@ void main() { when(mockEventProvider.getUpcomingEvents(LocalDate.today(), limit: anyNamed('limit'))) .thenAnswer((_) => Future.value([])); + + mockFeedbackProvider = MockFeedbackProvider(); + // ignore: invalid_use_of_protected_member + when(mockFeedbackProvider.hasListeners).thenReturn(true); + when(mockFeedbackProvider.userSubmittedFeedbackForClass(any, any)) + .thenAnswer((_) => Future.value(false)); + when(mockFeedbackProvider.getClassesWithCompletedFeedback(any)) + .thenAnswer((_) => Future.value({'M1': true, 'M2': true})); + + mockClassProvider = MockClassProvider(); + // ignore: invalid_use_of_protected_member + when(mockClassProvider.hasListeners).thenReturn(false); + final userClassHeaders = [ + ClassHeader( + id: '3', + name: 'Programming', + acronym: 'PC', + category: 'A', + ), + ClassHeader( + id: '4', + name: 'Physics', + acronym: 'PH', + category: 'D', + ) + ]; + when(mockClassProvider.userClassHeadersCache).thenReturn(userClassHeaders); + when(mockClassProvider.fetchClassHeaders(uid: anyNamed('uid'))) + .thenAnswer((_) => Future.value([ + ClassHeader( + id: '1', + name: 'Maths 1', + acronym: 'M1', + category: 'A/B', + ), + ClassHeader( + id: '2', + name: 'Maths 2', + acronym: 'M2', + category: 'A/C', + ), + ] + + userClassHeaders)); + when(mockClassProvider.fetchUserClassIds(any)) + .thenAnswer((_) => Future.value(['3', '4'])); }); group('Login', () { @@ -623,6 +677,9 @@ void main() { ChangeNotifierProvider( create: (_) => mockQuestionProvider), ChangeNotifierProvider(create: (_) => mockNewsProvider), + ChangeNotifierProvider( + create: (_) => mockFeedbackProvider), + ChangeNotifierProvider(create: (_) => mockClassProvider), ], child: MyApp(navigationObservers: [mockObserver]))); await tester.pumpAndSettle(); diff --git a/test/integration_test.dart b/test/integration_test.dart index cda5399b2..5ba8930a3 100644 --- a/test/integration_test.dart +++ b/test/integration_test.dart @@ -457,6 +457,10 @@ Future main() async { .thenAnswer((_) => Future.value(false)); when(mockFeedbackProvider.submitFeedback(any, any, any, any, any)) .thenAnswer((_) => Future.value(true)); + when(mockFeedbackProvider.getClassesWithCompletedFeedback(any)) + .thenAnswer((_) => Future.value({'M1': true, 'M2': true})); + when(mockFeedbackProvider.countClassesWithoutFeedback(any, any)) + .thenAnswer((_) => Future.value('2')); mockQuestionProvider = MockQuestionProvider(); // ignore: invalid_use_of_protected_member diff --git a/test/settings_test.dart b/test/settings_test.dart index d0ab4cdc4..b01dd17d4 100644 --- a/test/settings_test.dart +++ b/test/settings_test.dart @@ -1,6 +1,9 @@ import 'package:acs_upb_mobile/authentication/model/user.dart'; import 'package:acs_upb_mobile/authentication/service/auth_provider.dart'; import 'package:acs_upb_mobile/main.dart'; +import 'package:acs_upb_mobile/pages/class_feedback/service/feedback_provider.dart'; +import 'package:acs_upb_mobile/pages/classes/model/class.dart'; +import 'package:acs_upb_mobile/pages/classes/service/class_provider.dart'; import 'package:acs_upb_mobile/pages/faq/model/question.dart'; import 'package:acs_upb_mobile/pages/faq/service/question_provider.dart'; import 'package:acs_upb_mobile/pages/news_feed/model/news_feed_item.dart'; @@ -37,6 +40,10 @@ class MockNewsProvider extends Mock implements NewsProvider {} class MockUniEventProvider extends Mock implements UniEventProvider {} +class MockFeedbackProvider extends Mock implements FeedbackProvider {} + +class MockClassProvider extends Mock implements ClassProvider {} + void main() { AuthProvider mockAuthProvider; WebsiteProvider mockWebsiteProvider; @@ -44,6 +51,8 @@ void main() { RequestProvider mockRequestProvider; MockNewsProvider mockNewsProvider; UniEventProvider mockEventProvider; + FeedbackProvider mockFeedbackProvider; + ClassProvider mockClassProvider; Widget buildApp() => MultiProvider(providers: [ ChangeNotifierProvider(create: (_) => mockAuthProvider), @@ -55,6 +64,9 @@ void main() { create: (_) => mockQuestionProvider), Provider(create: (_) => mockRequestProvider), ChangeNotifierProvider(create: (_) => mockNewsProvider), + ChangeNotifierProvider( + create: (_) => mockFeedbackProvider), + ChangeNotifierProvider(create: (_) => mockClassProvider), ], child: const MyApp()); group('Settings', () { @@ -122,6 +134,52 @@ void main() { when(mockEventProvider.getUpcomingEvents(LocalDate.today(), limit: anyNamed('limit'))) .thenAnswer((_) => Future.value([])); + + mockFeedbackProvider = MockFeedbackProvider(); + // ignore: invalid_use_of_protected_member + when(mockFeedbackProvider.hasListeners).thenReturn(true); + when(mockFeedbackProvider.userSubmittedFeedbackForClass(any, any)) + .thenAnswer((_) => Future.value(false)); + when(mockFeedbackProvider.getClassesWithCompletedFeedback(any)) + .thenAnswer((_) => Future.value({'M1': true, 'M2': true})); + + mockClassProvider = MockClassProvider(); + // ignore: invalid_use_of_protected_member + when(mockClassProvider.hasListeners).thenReturn(false); + final userClassHeaders = [ + ClassHeader( + id: '3', + name: 'Programming', + acronym: 'PC', + category: 'A', + ), + ClassHeader( + id: '4', + name: 'Physics', + acronym: 'PH', + category: 'D', + ) + ]; + when(mockClassProvider.userClassHeadersCache) + .thenReturn(userClassHeaders); + when(mockClassProvider.fetchClassHeaders(uid: anyNamed('uid'))) + .thenAnswer((_) => Future.value([ + ClassHeader( + id: '1', + name: 'Maths 1', + acronym: 'M1', + category: 'A/B', + ), + ClassHeader( + id: '2', + name: 'Maths 2', + acronym: 'M2', + category: 'A/C', + ), + ] + + userClassHeaders)); + when(mockClassProvider.fetchUserClassIds(any)) + .thenAnswer((_) => Future.value(['3', '4'])); }); testWidgets('Dark Mode', (WidgetTester tester) async {