diff --git a/apps/build.gradle b/apps/build.gradle index 8d5d077915..c9cbf4d9db 100644 --- a/apps/build.gradle +++ b/apps/build.gradle @@ -33,7 +33,6 @@ buildscript { classpath Plugins.KOTLIN classpath Plugins.FIREBASE_CRASHLYTICS if (project.coverageEnabled) { classpath Plugins.JACOCO_ANDROID } - classpath Plugins.SQLDELIGHT classpath Plugins.HILT classpath Plugins.HEAP } diff --git a/apps/flutter_parent/android/app/build.gradle b/apps/flutter_parent/android/app/build.gradle index 5fd86f30e5..0190a20214 100644 --- a/apps/flutter_parent/android/app/build.gradle +++ b/apps/flutter_parent/android/app/build.gradle @@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdk 33 + compileSdk 34 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -46,7 +46,7 @@ android { defaultConfig { applicationId "com.instructure.parentapp" minSdkVersion 26 - targetSdk 33 + targetSdk 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" diff --git a/apps/flutter_parent/lib/l10n/res/intl_hi.arb b/apps/flutter_parent/lib/l10n/res/intl_hi.arb new file mode 100644 index 0000000000..bc0b681b83 --- /dev/null +++ b/apps/flutter_parent/lib/l10n/res/intl_hi.arb @@ -0,0 +1,2753 @@ +{ + "@@last_modified": "2023-08-25T11:04:20.901151", + "alertsLabel": "चेतावनियां", + "@alertsLabel": { + "description": "The label for the Alerts tab", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "calendarLabel": "कैलेंडर", + "@calendarLabel": { + "description": "The label for the Calendar tab", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "coursesLabel": "पाठ्यक्रम", + "@coursesLabel": { + "description": "The label for the Courses tab", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Students": "कोई छात्र नहीं", + "@No Students": { + "description": "Text for when an observer has no students they are observing", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Tap to show student selector": "छात्र चयनकर्ता दिखाने के लिए टैप करें", + "@Tap to show student selector": { + "description": "Semantics label for the area that will show the student selector when tapped", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Tap to pair with a new student": "किसी नए छात्र के साथ जुड़ने के लिए टैप करें", + "@Tap to pair with a new student": { + "description": "Semantics label for the add student button in the student selector", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Tap to select this student": "इस छात्र को चुनने केलिए टैप करें", + "@Tap to select this student": { + "description": "Semantics label on individual students in the student switcher", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Manage Students": "छात्र प्रबंधित करें", + "@Manage Students": { + "description": "Label text for the Manage Students nav drawer button as well as the title for the Manage Students screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Help": "मदद", + "@Help": { + "description": "Label text for the help nav drawer button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Log Out": "लॉग आउट करें", + "@Log Out": { + "description": "Label text for the Log Out nav drawer button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Switch Users": "उपयोगकर्ता स्विच करें", + "@Switch Users": { + "description": "Label text for the Switch Users nav drawer button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "appVersion": "v. {version}", + "@appVersion": { + "description": "App version shown in the navigation drawer", + "type": "text", + "placeholders_order": [ + "version" + ], + "placeholders": { + "version": {} + } + }, + "Are you sure you want to log out?": "क्या आप सच में लॉग आउट होना चाहते हैं?", + "@Are you sure you want to log out?": { + "description": "Confirmation message displayed when the user tries to log out", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Calendars": "कैलेंडर", + "@Calendars": { + "description": "Label for button that lets users select which calendars to display", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "nextMonth": "अगला महीना: {month}", + "@nextMonth": { + "description": "Label for the button that switches the calendar to the next month", + "type": "text", + "placeholders_order": [ + "month" + ], + "placeholders": { + "month": {} + } + }, + "previousMonth": "पिछला महीना: {month}", + "@previousMonth": { + "description": "Label for the button that switches the calendar to the previous month", + "type": "text", + "placeholders_order": [ + "month" + ], + "placeholders": { + "month": {} + } + }, + "nextWeek": "अगला सप्ताह {date} से शुरू", + "@nextWeek": { + "description": "Label for the button that switches the calendar to the next week", + "type": "text", + "placeholders_order": [ + "date" + ], + "placeholders": { + "date": {} + } + }, + "previousWeek": "पिछला सप्ताह {date} से शुरू", + "@previousWeek": { + "description": "Label for the button that switches the calendar to the previous week", + "type": "text", + "placeholders_order": [ + "date" + ], + "placeholders": { + "date": {} + } + }, + "selectedMonthLabel": "{month} का महीना", + "@selectedMonthLabel": { + "description": "Accessibility label for the button that expands/collapses the month view", + "type": "text", + "placeholders_order": [ + "month" + ], + "placeholders": { + "month": {} + } + }, + "expand": "बढ़ाएं", + "@expand": { + "description": "Accessibility label for the on-tap hint for the button that expands/collapses the month view", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "collapse": "संक्षिप्त करें", + "@collapse": { + "description": "Accessibility label for the on-tap hint for the button that expands/collapses the month view", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "pointsPossible": "{points} पॉइंट्स संभव हैं", + "@pointsPossible": { + "description": "Screen reader label used for the points possible for an assignment, quiz, etc.", + "type": "text", + "placeholders_order": [ + "points" + ], + "placeholders": { + "points": {} + } + }, + "calendarDaySemanticsLabel": "{eventCount,plural, =1{{date}, {eventCount} घटना}other{{date}, {eventCount} घटनाएं}}", + "@calendarDaySemanticsLabel": { + "description": "Screen reader label used for calendar day, reads the date and count of events", + "type": "text", + "placeholders_order": [ + "date", + "eventCount" + ], + "placeholders": { + "date": {}, + "eventCount": {} + } + }, + "No Events Today!": "आज कोई घटना नहीं है!", + "@No Events Today!": { + "description": "Title displayed when there are no calendar events for the current day", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "It looks like a great day to rest, relax, and recharge.": "आज का दिन आराम, विश्राम करने और नई ऊर्जा पाने के लिए अच्छा लग रहा है।", + "@It looks like a great day to rest, relax, and recharge.": { + "description": "Message displayed when there are no calendar events for the current day", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading your student's calendar": "आपके छात्र के कैलेंडर को लोड करने में त्रुटि हुई", + "@There was an error loading your student's calendar": { + "description": "Message displayed when calendar events could not be loaded for the current student", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Tap to favorite the courses you want to see on the Calendar. Select up to 10.": "उन पाठ्यक्रमों को पसंदीदा बनाने के लिए टैप करें जिन्हें आप कैलेंडर पर देखना चाहते हैं। अधिकतम 10 चुनें।", + "@Tap to favorite the courses you want to see on the Calendar. Select up to 10.": { + "description": "Description text on calendar filter screen.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You may only choose 10 calendars to display": "आप प्रदर्शित करने के लिए केवल 10 कैलेंडर चुन सकते हैं", + "@You may only choose 10 calendars to display": { + "description": "Error text when trying to select more than 10 calendars", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You must select at least one calendar to display": "आपको प्रदर्शित करने के लिए कम से कम एक कैलेंडर का चयन करना होगा", + "@You must select at least one calendar to display": { + "description": "Error text when trying to de-select all calendars", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Planner Note": "योजनाकर्ता नोट", + "@Planner Note": { + "description": "Label used for notes in the planner", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Go to today": "आज पर जाएं", + "@Go to today": { + "description": "Accessibility label used for the today button in the planner", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Previous Logins": "पिछले लॉगिन", + "@Previous Logins": { + "description": "Label for the list of previous user logins", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "canvasLogoLabel": "Canvas लोगो", + "@canvasLogoLabel": { + "description": "The semantics label for the Canvas logo", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "findSchool": "स्कूल ढूंढें", + "@findSchool": { + "description": "Text for the find-my-school button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "findAnotherSchool": "कोई अन्य स्कूल ढूंढें", + "@findAnotherSchool": { + "description": "Text for the find-another-school button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "domainSearchInputHint": "स्कूल का नाम या डिस्ट्रिक्ट दर्ज करें…", + "@domainSearchInputHint": { + "description": "Input hint for the text box on the domain search screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "noDomainResults": "\"{query}\" से मेल खाते स्कूल ढूंढने में असमर्थ ", + "@noDomainResults": { + "description": "Message shown to users when the domain search query did not return any results", + "type": "text", + "placeholders_order": [ + "query" + ], + "placeholders": { + "query": {} + } + }, + "domainSearchHelpLabel": "मैं अपना स्कूल या डिस्ट्रिक्ट कैसे ढूंढूं?", + "@domainSearchHelpLabel": { + "description": "Label for the help button on the domain search screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "canvasGuides": "Canvas गाइड", + "@canvasGuides": { + "description": "Proper name for the Canvas Guides. This will be used in the domainSearchHelpBody text and will be highlighted and clickable", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "canvasSupport": "Canvas सपोर्ट", + "@canvasSupport": { + "description": "Proper name for Canvas Support. This will be used in the domainSearchHelpBody text and will be highlighted and clickable", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "domainSearchHelpBody": "आप जिस स्कूल या डिस्ट्रिक्ट को एक्सेस करने का प्रयास कर रहे हैं उसका नाम खोजने का प्रयास करें, जैसे \"Smith प्राइवेट स्कूल\" या \"Smith काउंटी स्कूल\"। आप सीधे Canvas डोमेन भी दर्ज कर सकते हैं, जैसे \"smith.instructure.com\" \n\nअपने संस्थान के Canvas खाते को खोजने के बारे में अधिक जानकारी के लिए, आप {canvasGuides} पर जा सकते हैं, {canvasSupport} से संपर्क कर सकते हैं, या सहायता के लिए अपने स्कूल से संपर्क कर सकते हैं।", + "@domainSearchHelpBody": { + "description": "The body text shown in the help dialog on the domain search screen", + "type": "text", + "placeholders_order": [ + "canvasGuides", + "canvasSupport" + ], + "placeholders": { + "canvasGuides": {}, + "canvasSupport": {} + } + }, + "Uh oh!": "उह ओह!", + "@Uh oh!": { + "description": "Title of the screen that shows when a crash has occurred", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "We’re not sure what happened, but it wasn’t good. Contact us if this keeps happening.": "हमें नहीं पता कि क्या हुआ, पर जो हुआ अच्छा नहीं था। यदि ऐसा बार-बार हो, तो हमसे संपर्क करें।", + "@We’re not sure what happened, but it wasn’t good. Contact us if this keeps happening.": { + "description": "Message shown when a crash has occurred", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Contact Support": "सपोर्ट से संपर्क करें", + "@Contact Support": { + "description": "Label for the button that allows users to contact support after a crash has occurred", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "View error details": "त्रुटि विवरण देखें", + "@View error details": { + "description": "Label for the button that allowed users to view crash details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Restart app": "ऐप फिर से शुरू करें", + "@Restart app": { + "description": "Label for the button that will restart the entire application", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Application version": "ऐप्लिकेशन संस्करण", + "@Application version": { + "description": "Label for the application version displayed in the crash details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Device model": "डिवाइस मॉडल", + "@Device model": { + "description": "Label for the device model displayed in the crash details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Android OS version": "Android OS संस्करण", + "@Android OS version": { + "description": "Label for the Android operating system version displayed in the crash details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Full error message": "पूर्ण त्रुटि संदेश", + "@Full error message": { + "description": "Label for the full error message displayed in the crash details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Inbox": "इनबॉक्स", + "@Inbox": { + "description": "Title for the Inbox screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading your inbox messages.": "आपके इनबॉक्स संदेशों को लोड करने में त्रुटि हुई।", + "@There was an error loading your inbox messages.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Subject": "कोई विषय नहीं", + "@No Subject": { + "description": "Title used for inbox messages that have no subject", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Unable to fetch courses. Please check your connection and try again.": "पाठ्यक्रम प्राप्त करने में असमर्थ। कृपया अपने कनेक्शन की जांच करें और फिर से कोशिश करें।", + "@Unable to fetch courses. Please check your connection and try again.": { + "description": "Message shown when an error occured while loading courses", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Choose a course to message": "संदेश के लिए कोई पाठ्यक्रम चुनें", + "@Choose a course to message": { + "description": "Header in the course list shown when the user is choosing which course to associate with a new message", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Inbox Zero": "इनबॉक्स ज़ीरो", + "@Inbox Zero": { + "description": "Title of the message shown when there are no inbox messages", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You’re all caught up!": "आपका काम पूरा हुआ!", + "@You’re all caught up!": { + "description": "Subtitle of the message shown when there are no inbox messages", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading recipients for this course": "इस पाठ्यक्रम के लिए प्राप्तकर्ता लोड करने में त्रुटि हुई", + "@There was an error loading recipients for this course": { + "description": "Message shown when attempting to create a new message but the recipients list failed to load", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Unable to send message. Check your connection and try again.": "संदेश भेजने में असमर्थ। अपने कनेक्शन की जांच करें और फिर से कोशिश करें।", + "@Unable to send message. Check your connection and try again.": { + "description": "Message show when there was an error creating or sending a new message", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Unsaved changes": "बिना सहेजे गए बदलाव", + "@Unsaved changes": { + "description": "Title of the dialog shown when the user tries to leave with unsaved changes", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Are you sure you wish to close this page? Your unsent message will be lost.": "क्या आप सच में इस पेज को बंद करना चाहते हैं? आपका नहीं भेजा गया संदेश गुम हो जाएगा।", + "@Are you sure you wish to close this page? Your unsent message will be lost.": { + "description": "Body text of the dialog shown when the user tries leave with unsaved changes", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "New message": "नया संदेश", + "@New message": { + "description": "Title of the new-message screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Add attachment": "संलग्नक जोड़ें", + "@Add attachment": { + "description": "Tooltip for the add-attachment button in the new-message screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Send message": "संदेश भेजें", + "@Send message": { + "description": "Tooltip for the send-message button in the new-message screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Select recipients": "प्राप्तकर्ता चुनें", + "@Select recipients": { + "description": "Tooltip for the button that allows users to select message recipients", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No recipients selected": "कोई प्राप्तकर्ता नहीं चुना गया", + "@No recipients selected": { + "description": "Hint displayed when the user has not selected any message recipients", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Message subject": "संदेश विषय", + "@Message subject": { + "description": "Hint text displayed in the input field for the message subject", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Message": "संदेश", + "@Message": { + "description": "Hint text displayed in the input field for the message body", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Recipients": "प्राप्तकर्ता", + "@Recipients": { + "description": "Label for message recipients", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "plusRecipientCount": "+{count}", + "@plusRecipientCount": { + "description": "Shows the number of recipients that are selected but not displayed on screen.", + "type": "text", + "placeholders_order": [ + "count" + ], + "placeholders": { + "count": { + "example": 5 + } + } + }, + "Failed. Tap for options.": "विफल हुआ। विकल्पों के लिए टैप करें।", + "@Failed. Tap for options.": { + "description": "Short message shown on a message attachment when uploading has failed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "courseForWhom": "{studentShortName} के लिए", + "@courseForWhom": { + "description": "Describes for whom a course is for (i.e. for Bill)", + "type": "text", + "placeholders_order": [ + "studentShortName" + ], + "placeholders": { + "studentShortName": {} + } + }, + "messageLinkPostscript": "इसके संबंध में: {studentName}, {linkUrl}", + "@messageLinkPostscript": { + "description": "A postscript appended to new messages that clarifies which student is the subject of the message and also includes a URL for the related Canvas component (course, assignment, event, etc).", + "type": "text", + "placeholders_order": [ + "studentName", + "linkUrl" + ], + "placeholders": { + "studentName": {}, + "linkUrl": {} + } + }, + "There was an error loading this conversation": "इस बातचीत को लोड करने में त्रुटि हुई", + "@There was an error loading this conversation": { + "description": "Message shown when a conversation fails to load", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Reply": "उत्तर दें", + "@Reply": { + "description": "Button label for replying to a conversation", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Reply All": "सभी के लिए उत्तर दें", + "@Reply All": { + "description": "Button label for replying to all conversation participants", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Unknown User": "अज्ञात उपयोगकर्ता", + "@Unknown User": { + "description": "Label used where the user name is not known", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "me": "मैं", + "@me": { + "description": "First-person pronoun (i.e. 'me') that will be used in message author info, e.g. 'Me to 4 others' or 'Jon Snow to me'", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "authorToRecipient": "{authorName} से {recipientName} तक", + "@authorToRecipient": { + "description": "Author info for a single-recipient message; includes both the author name and the recipient name.", + "type": "text", + "placeholders_order": [ + "authorName", + "recipientName" + ], + "placeholders": { + "authorName": {}, + "recipientName": {} + } + }, + "authorToNOthers": "{howMany,plural, =1{{authorName} से 1 अन्य}other{{authorName} से {howMany} अन्य}}", + "@authorToNOthers": { + "description": "Author info for a mutli-recipient message; includes the author name and the number of recipients", + "type": "text", + "placeholders_order": [ + "authorName", + "howMany" + ], + "placeholders": { + "authorName": {}, + "howMany": {} + } + }, + "authorToRecipientAndNOthers": "{howMany,plural, =1{{authorName} से {recipientName} और 1 अन्य}other{{authorName} से {recipientName} और {howMany} अन्य}}", + "@authorToRecipientAndNOthers": { + "description": "Author info for a multi-recipient message; includes the author name, one recipient name, and the number of other recipients", + "type": "text", + "placeholders_order": [ + "authorName", + "recipientName", + "howMany" + ], + "placeholders": { + "authorName": {}, + "recipientName": {}, + "howMany": {} + } + }, + "Download": "डाउनलोड करें", + "@Download": { + "description": "Label for the button that will begin downloading a file", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Open with another app": "किसी अन्य ऐप से खोलें", + "@Open with another app": { + "description": "Label for the button that will allow users to open a file with another app", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There are no installed applications that can open this file": "ऐसे कोई इंस्टॉल किए गए ऐप्लिकेशन नहीं हैं जो इस फ़ाइल को खोल सकें", + "@There are no installed applications that can open this file": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Unsupported File": "असमार्थित फ़ाइल", + "@Unsupported File": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "This file is unsupported and can’t be viewed through the app": "यह फ़ाइल असमर्थित है और इसे ऐप के माध्यम से नहीं देखा जा सकता है", + "@This file is unsupported and can’t be viewed through the app": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Unable to play this media file": "इस मीडिया फ़ाइल को चलाने में असमर्थ", + "@Unable to play this media file": { + "description": "Message shown when audio or video media could not be played", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Unable to load this image": "यह छवि लोड करने में असमर्थ", + "@Unable to load this image": { + "description": "Message shown when an image file could not be loaded or displayed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading this file": "इस फ़ाइल को लोड करने में त्रुटि हुई", + "@There was an error loading this file": { + "description": "Message shown when a file could not be loaded or displayed", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Courses": "कोई पाठ्यक्रम नहीं", + "@No Courses": { + "description": "Title for having no courses", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Your student’s courses might not be published yet.": "हो सकता है कि आपके छात्र के पाठ्यक्रम अभी तक प्रकाशित न हुए हों।", + "@Your student’s courses might not be published yet.": { + "description": "Message for having no courses", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading your student’s courses.": "आपके छात्र के पाठ्यक्रम लोड करने में त्रुटि हुई।", + "@There was an error loading your student’s courses.": { + "description": "Message displayed when the list of student courses could not be loaded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Grade": "कोई ग्रेड नहीं", + "@No Grade": { + "description": "Message shown when there is currently no grade available for a course", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Filter by": "इसके अनुसार फ़िल्टर करें", + "@Filter by": { + "description": "Title for list of terms to filter grades by", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Grades": "ग्रेड", + "@Grades": { + "description": "Label for the \"Grades\" tab in course details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Syllabus": "पाठ्यक्रम", + "@Syllabus": { + "description": "Label for the \"Syllabus\" tab in course details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Front Page": "मुख पृष्ठ", + "@Front Page": { + "description": "Label for the \"Front Page\" tab in course details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Summary": "संक्षिप्त विवरण", + "@Summary": { + "description": "Label for the \"Summary\" tab in course details", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Send a message about this course": "इस पाठ्यक्रम के बारे में संदेश भेजें", + "@Send a message about this course": { + "description": "Accessibility hint for the course messaage floating action button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Total Grade": "कुल ग्रेड", + "@Total Grade": { + "description": "Label for the total grade in the course", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Graded": "ग्रेड किए गए", + "@Graded": { + "description": "Label for assignments that have been graded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Submitted": "सबमिट किया गया", + "@Submitted": { + "description": "Label for assignments that have been submitted", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Not Submitted": "सबमिट नहीं किया गया", + "@Not Submitted": { + "description": "Label for assignments that have not been submitted", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Late": "विलंब", + "@Late": { + "description": "Label for assignments that have been marked late or submitted late", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Missing": "लापता", + "@Missing": { + "description": "Label for assignments that have been marked missing or are not submitted and past the due date", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "-": "-", + "@-": { + "description": "Value representing no score for student submission", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "All Grading Periods": "सभी ग्रेडिंग अवधियां", + "@All Grading Periods": { + "description": "Label for selecting all grading periods", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Assignments": "कोई असाइनमेंट नहीं", + "@No Assignments": { + "description": "Title for the no assignments message", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "It looks like assignments haven't been created in this space yet.": "लगता है इस खाली स्थान में अभी तक कोई असाइनमेंट नहीं बनाई गई है।", + "@It looks like assignments haven't been created in this space yet.": { + "description": "Message for no assignments", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading the summary details for this course.": "इस पाठ्यक्रम के लिए संक्षिप्त विवरण लोड करने में त्रुटि हुई।", + "@There was an error loading the summary details for this course.": { + "description": "Message shown when the course summary could not be loaded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Summary": "कोई संक्षिप्त विवरण नहीं", + "@No Summary": { + "description": "Title displayed when there are no items in the course summary", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "This course does not have any assignments or calendar events yet.": "इस पाठ्यक्रम में अभी तक कोई असाइनमेंट या कैलेंडर घटना नहीं है।", + "@This course does not have any assignments or calendar events yet.": { + "description": "Message displayed when there are no items in the course summary", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "gradeFormatScoreOutOfPointsPossible": "{score} / {pointsPossible}", + "@gradeFormatScoreOutOfPointsPossible": { + "description": "Formatted string for a student score out of the points possible", + "type": "text", + "placeholders_order": [ + "score", + "pointsPossible" + ], + "placeholders": { + "score": {}, + "pointsPossible": {} + } + }, + "contentDescriptionScoreOutOfPointsPossible": "{pointsPossible} में से {score} पॉइंट्स", + "@contentDescriptionScoreOutOfPointsPossible": { + "description": "Formatted string for a student score out of the points possible", + "type": "text", + "placeholders_order": [ + "score", + "pointsPossible" + ], + "placeholders": { + "score": {}, + "pointsPossible": {} + } + }, + "gradesSubjectMessage": "इसके संबंध में: {studentName}, ग्रेड", + "@gradesSubjectMessage": { + "description": "The subject line for a message to a teacher regarding a student's grades", + "type": "text", + "placeholders_order": [ + "studentName" + ], + "placeholders": { + "studentName": {} + } + }, + "syllabusSubjectMessage": "इसके संबंध में: {studentName}, पाठ्यक्रम", + "@syllabusSubjectMessage": { + "description": "The subject line for a message to a teacher regarding a course syllabus", + "type": "text", + "placeholders_order": [ + "studentName" + ], + "placeholders": { + "studentName": {} + } + }, + "frontPageSubjectMessage": "इसके संबंध में: {studentName}, मुख पृष्ठ", + "@frontPageSubjectMessage": { + "description": "The subject line for a message to a teacher regarding a course front page", + "type": "text", + "placeholders_order": [ + "studentName" + ], + "placeholders": { + "studentName": {} + } + }, + "assignmentSubjectMessage": "इसके संबंध में: {studentName}, असाइनमेंट - {assignmentName}", + "@assignmentSubjectMessage": { + "description": "The subject line for a message to a teacher regarding a student's assignment", + "type": "text", + "placeholders_order": [ + "studentName", + "assignmentName" + ], + "placeholders": { + "studentName": {}, + "assignmentName": {} + } + }, + "eventSubjectMessage": "इसके संबंध में: {studentName}, घटना - {eventTitle}", + "@eventSubjectMessage": { + "description": "The subject line for a message to a teacher regarding a calendar event", + "type": "text", + "placeholders_order": [ + "studentName", + "eventTitle" + ], + "placeholders": { + "studentName": {}, + "eventTitle": {} + } + }, + "There is no page information available.": "पेज की कोई जानकारी उपलब्ध नहीं है।", + "@There is no page information available.": { + "description": "Description for when no page information is available", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Assignment Details": "असाइनमेंट विवरण", + "@Assignment Details": { + "description": "Title for the page that shows details for an assignment", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "assignmentTotalPoints": "{points} पॉइंट्स", + "@assignmentTotalPoints": { + "description": "Label used for the total points the assignment is worth", + "type": "text", + "placeholders_order": [ + "points" + ], + "placeholders": { + "points": {} + } + }, + "assignmentTotalPointsAccessible": "{points} पॉइंट्स", + "@assignmentTotalPointsAccessible": { + "description": "Screen reader label used for the total points the assignment is worth", + "type": "text", + "placeholders_order": [ + "points" + ], + "placeholders": { + "points": {} + } + }, + "Due": "नियत", + "@Due": { + "description": "Label for an assignment due date", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Grade": "ग्रेड", + "@Grade": { + "description": "Label for the section that displays an assignment's grade", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Locked": "लॉक किया गया", + "@Locked": { + "description": "Label for when an assignment is locked", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "assignmentLockedModule": "यह असाइनमेंट \"{moduleName}\" मॉड्यूल द्वारा लॉक की गई है।", + "@assignmentLockedModule": { + "description": "The locked description when an assignment is locked by a module", + "type": "text", + "placeholders_order": [ + "moduleName" + ], + "placeholders": { + "moduleName": {} + } + }, + "Remind Me": "मुझे याद दिलाएं", + "@Remind Me": { + "description": "Label for the row to set reminders", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Set a date and time to be notified of this specific assignment.": "इस विशिष्ट असाइनमेंट की सूचना पाने के लिए कोई तिथि और समय निर्धारित करें।", + "@Set a date and time to be notified of this specific assignment.": { + "description": "Description for row to set reminders", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You will be notified about this assignment on…": "आपको इस असाइनमेंट के बारे में सूचित किया जाएगा...", + "@You will be notified about this assignment on…": { + "description": "Description for when a reminder is set", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Instructions": "निर्देश", + "@Instructions": { + "description": "Label for the description of the assignment when it has quiz instructions", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Send a message about this assignment": "इस असाइनमेंट के बारे में संदेश भेजें", + "@Send a message about this assignment": { + "description": "Accessibility hint for the assignment messaage floating action button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "This app is not authorized for use.": "यह ऐप उपयोग के लिए अधिकृत नहीं है।", + "@This app is not authorized for use.": { + "description": "The error shown when the app being used is not verified by Canvas", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "The server you entered is not authorized for this app.": "आपके द्वारा दर्ज किया गया सर्वर इस ऐप के लिए अधिकृत नहीं है।", + "@The server you entered is not authorized for this app.": { + "description": "The error shown when the desired login domain is not verified by Canvas", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "The user agent for this app is not authorized.": "इस ऐप के लिए उपयोगकर्ता एजेंट अधिकृत नहीं है।", + "@The user agent for this app is not authorized.": { + "description": "The error shown when the user agent during verification is not verified by Canvas", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "We were unable to verify the server for use with this app.": "हम इस ऐप के उपयोग के लिए सर्वर को सत्यापित करने में असमर्थ थे।", + "@We were unable to verify the server for use with this app.": { + "description": "The generic error shown when we are unable to verify with Canvas", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Reminders": "अनुस्मारक", + "@Reminders": { + "description": "Name of the system notification channel for assignment and event reminders", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Notifications for reminders about assignments and calendar events": "असाइनमेंट और कैलेंडर घटना के बारे में अनुस्मारक के लिए सूचनाएं", + "@Notifications for reminders about assignments and calendar events": { + "description": "Description of the system notification channel for assignment and event reminders", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Reminders have changed!": "अनुस्मारक बदल गए हैं!", + "@Reminders have changed!": { + "description": "Title of the dialog shown when the user needs to update their reminders", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "In order to provide you with a better experience, we have updated how reminders work. You can add new reminders by viewing an assignment or calendar event and tapping the switch under the \"Remind Me\" section. \n\nBe aware that any reminders created with older versions of this app will not be compatible with the new changes and you will need to create them again.": "आपके अनुभव को बेहतर बनाने के लिए, हमने अनुस्मारक की कार्यक्षमता को उन्नत किया है। आप किसी असाइनमेंट या कैलेंडर घटना को देखकर और \"मुझे याद दिलाएं\" अनुभाग के अंतर्गत स्विच को टैप करके नए अनुस्मारक जोड़ सकते हैं।\n\nध्यान रखें कि इस ऐप के पुराने संस्करणों के साथ बनाया गया कोई भी अनुस्मारक नए परिवर्तनों के साथ संगत नहीं होगा और आपको उन्हें फिर से बनाने की आवश्यकता होगी।", + "@In order to provide you with a better experience, we have updated how reminders work. You can add new reminders by viewing an assignment or calendar event and tapping the switch under the \"Remind Me\" section.\n\nBe aware that any reminders created with older versions of this app will not be compatible with the new changes and you will need to create them again.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Not a parent?": "माता-पिता नहीं है?", + "@Not a parent?": { + "description": "Title for the screen that shows when the user is not observing any students", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "We couldn't find any students associated with this account": "हमें इस खाते से जुड़ा कोई भी छात्र नहीं मिला", + "@We couldn't find any students associated with this account": { + "description": "Subtitle for the screen that shows when the user is not observing any students", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Are you a student or teacher?": "कआप छात्र हैं या शिक्षक हैं?", + "@Are you a student or teacher?": { + "description": "Label for button that will show users the option to view other Canvas apps in the Play Store", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "One of our other apps might be a better fit. Tap one to visit the Play Store.": "हमारा एक अन्य ऐप संभावित रूप से आपकी आवश्यकताओं को बेहतर ढंग से पूरा कर सकता है। प्ले स्टोर पर जाने के लिए टैप करें।", + "@One of our other apps might be a better fit. Tap one to visit the Play Store.": { + "description": "Description of options to view other Canvas apps in the Play Store", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Return to Login": "लॉगिन पर वापस जाएं", + "@Return to Login": { + "description": "Label for the button that returns the user to the login screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "STUDENT": "छात्र", + "@STUDENT": { + "description": "The \"student\" portion of the \"Canvas Student\" app name, in all caps. \"Canvas\" is excluded in this context as it will be displayed to the user as a wordmark image", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "TEACHER": "शिक्षक", + "@TEACHER": { + "description": "The \"teacher\" portion of the \"Canvas Teacher\" app name, in all caps. \"Canvas\" is excluded in this context as it will be displayed to the user as a wordmark image", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Canvas Student": "Canvas छात्र", + "@Canvas Student": { + "description": "The name of the Canvas Student app. Only \"Student\" should be translated as \"Canvas\" is a brand name in this context and should not be translated.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Canvas Teacher": "Canvas शिक्षक", + "@Canvas Teacher": { + "description": "The name of the Canvas Teacher app. Only \"Teacher\" should be translated as \"Canvas\" is a brand name in this context and should not be translated.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Alerts": "कोई चेतावनी नहीं", + "@No Alerts": { + "description": "The title for the empty message to show to users when there are no alerts for the student.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There’s nothing to be notified of yet.": "अभी तक सूचित करने लायक कुछ भी नहीं है।", + "@There’s nothing to be notified of yet.": { + "description": "The empty message to show to users when there are no alerts for the student.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "dismissAlertLabel": "{alertTitle} खारिज करें", + "@dismissAlertLabel": { + "description": "Accessibility label to dismiss an alert", + "type": "text", + "placeholders_order": [ + "alertTitle" + ], + "placeholders": { + "alertTitle": {} + } + }, + "Course Announcement": "पाठ्यक्रम की घोषणा", + "@Course Announcement": { + "description": "Title for alerts when there is a course announcement", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Institution Announcement": "संस्थान की घोषणा", + "@Institution Announcement": { + "description": "Title for alerts when there is an institution announcement", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "assignmentGradeAboveThreshold": "{threshold} से ऊपर का असाइनमेंट ग्रेड", + "@assignmentGradeAboveThreshold": { + "description": "Title for alerts when an assignment grade is above the threshold value", + "type": "text", + "placeholders_order": [ + "threshold" + ], + "placeholders": { + "threshold": {} + } + }, + "assignmentGradeBelowThreshold": "{threshold} से नीचे का असाइनमेंट ग्रेड", + "@assignmentGradeBelowThreshold": { + "description": "Title for alerts when an assignment grade is below the threshold value", + "type": "text", + "placeholders_order": [ + "threshold" + ], + "placeholders": { + "threshold": {} + } + }, + "courseGradeAboveThreshold": "{threshold} से ऊपर का पाठ्यक्रम ग्रेड", + "@courseGradeAboveThreshold": { + "description": "Title for alerts when a course grade is above the threshold value", + "type": "text", + "placeholders_order": [ + "threshold" + ], + "placeholders": { + "threshold": {} + } + }, + "courseGradeBelowThreshold": "{threshold} से नीचे का पाठ्यक्रम ग्रेड", + "@courseGradeBelowThreshold": { + "description": "Title for alerts when a course grade is below the threshold value", + "type": "text", + "placeholders_order": [ + "threshold" + ], + "placeholders": { + "threshold": {} + } + }, + "Settings": "सेटिंग्स", + "@Settings": { + "description": "Title for the settings screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Theme": "थीम", + "@Theme": { + "description": "Label for the light/dark theme section in the settings page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Dark Mode": "डार्क मोड", + "@Dark Mode": { + "description": "Label for the button that enables dark mode", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Light Mode": "लाइट मोड", + "@Light Mode": { + "description": "Label for the button that enables light mode", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "High Contrast Mode": "हाई कंट्रास्ट मोड", + "@High Contrast Mode": { + "description": "Label for the switch that toggles high contrast mode", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Use Dark Theme in Web Content": "वेब सामग्री में डार्क थीम का उपयोग करें", + "@Use Dark Theme in Web Content": { + "description": "Label for the switch that toggles dark mode for webviews", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Appearance": "दिखावट", + "@Appearance": { + "description": "Label for the appearance section in the settings page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Successfully submitted!": "सफलतापूर्वक सबमिट किया गया!", + "@Successfully submitted!": { + "description": "Title displayed in the grade cell for an assignment that has been submitted", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "submissionStatusSuccessSubtitle": "यह असाइनमेंट {date} को {time} बजे सबमिट की गई थी और ग्रेड होने की प्रतीक्षा कर रही है", + "@submissionStatusSuccessSubtitle": { + "description": "Subtitle displayed in the grade cell for an assignment that has been submitted and is awaiting a grade", + "type": "text", + "placeholders_order": [ + "date", + "time" + ], + "placeholders": { + "date": {}, + "time": {} + } + }, + "outOfPoints": "{howMany,plural, =1{1 पॉइंट में से}other{{points} पॉइंट्स में से}}", + "@outOfPoints": { + "description": "Description for an assignment grade that has points without a current scoroe", + "type": "text", + "placeholders_order": [ + "points", + "howMany" + ], + "placeholders": { + "points": {}, + "howMany": {} + } + }, + "Excused": "माफ़ किया", + "@Excused": { + "description": "Grading status for an assignment marked as excused", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Complete": "पूरा हुआ", + "@Complete": { + "description": "Grading status for an assignment marked as complete", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Incomplete": "अधूरा", + "@Incomplete": { + "description": "Grading status for an assignment marked as incomplete", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "minus": "ऋण", + "@minus": { + "description": "Screen reader-friendly replacement for the \"-\" character in letter grades like \"A-\"", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "latePenalty": "विलंब दंड (-{pointsLost})", + "@latePenalty": { + "description": "Text displayed when a late penalty has been applied to the assignment", + "type": "text", + "placeholders_order": [ + "pointsLost" + ], + "placeholders": { + "pointsLost": {} + } + }, + "finalGrade": "अंतिम ग्रेड: {grade}", + "@finalGrade": { + "description": "Text that displays the final grade of an assignment", + "type": "text", + "placeholders_order": [ + "grade" + ], + "placeholders": { + "grade": {} + } + }, + "Alert Settings": "चेतावनी की सेटिंग्स", + "@Alert Settings": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Alert me when…": "मुझे चेतावनी दें जब…", + "@Alert me when…": { + "description": "Header for the screen where the observer chooses the thresholds that will determine when they receive alerts (e.g. when an assignment is graded below 70%)", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Course grade below": "इससे नीचे का पाठ्यक्रम ग्रेड:", + "@Course grade below": { + "description": "Label describing the threshold for when the course grade is below a certain percentage", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Course grade above": "इससे ऊपर का पाठ्यक्रम ग्रेड: ", + "@Course grade above": { + "description": "Label describing the threshold for when the course grade is above a certain percentage", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Assignment missing": "असाइनमेंट लापता है", + "@Assignment missing": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Assignment grade below": "इससे नीचे का असाइनमेंट ग्रेड:", + "@Assignment grade below": { + "description": "Label describing the threshold for when an assignment is graded below a certain percentage", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Assignment grade above": "इससे ऊपर का असाइनमेंट ग्रेड:", + "@Assignment grade above": { + "description": "Label describing the threshold for when an assignment is graded above a certain percentage", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Course Announcements": "पाठ्यक्रम घोषणाएं", + "@Course Announcements": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Institution Announcements": "संस्थान की घोषणाएं", + "@Institution Announcements": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Never": "कभी नहीं", + "@Never": { + "description": "Indication that tells the user they will not receive alert notifications of a specific kind", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Grade percentage": "ग्रेड प्रतिशत", + "@Grade percentage": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading your student's alerts.": "आपके छात्र की चेतावनियां लोड करने में त्रुटि हुई।", + "@There was an error loading your student's alerts.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Must be below 100": "100 से कम होना चाहिए", + "@Must be below 100": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "mustBeBelowN": "{percentage} से कम होना चाहिए", + "@mustBeBelowN": { + "description": "Validation error to the user that they must choose a percentage below 'n'", + "type": "text", + "placeholders_order": [ + "percentage" + ], + "placeholders": { + "percentage": { + "example": 5 + } + } + }, + "mustBeAboveN": "{percentage} से ऊपर होना चाहिए", + "@mustBeAboveN": { + "description": "Validation error to the user that they must choose a percentage above 'n'", + "type": "text", + "placeholders_order": [ + "percentage" + ], + "placeholders": { + "percentage": { + "example": 5 + } + } + }, + "Select Student Color": "छात्र रंग का चयन करें", + "@Select Student Color": { + "description": "Title for screen that allows users to assign a color to a specific student", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Electric, blue": "इलेक्ट्रिक, नीला", + "@Electric, blue": { + "description": "Name of the Electric (blue) color", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Plum, Purple": "प्लम, बैंगनी", + "@Plum, Purple": { + "description": "Name of the Plum (purple) color", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Barney, Fuschia": "बार्नी, रानी गुलाबी", + "@Barney, Fuschia": { + "description": "Name of the Barney (fuschia) color", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Raspberry, Red": "रास्पबेरी, लाल", + "@Raspberry, Red": { + "description": "Name of the Raspberry (red) color", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Fire, Orange": "आग, नारंगी", + "@Fire, Orange": { + "description": "Name of the Fire (orange) color", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Shamrock, Green": "शैमरॉक, हरा", + "@Shamrock, Green": { + "description": "Name of the Shamrock (green) color", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "An error occurred while saving your selection. Please try again.": "आपका चयन सहेजते समय त्रुटि उत्पन्न हुई। कृपया फिर से कोशिश करें।", + "@An error occurred while saving your selection. Please try again.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "changeStudentColorLabel": "{studentName} के लिए रंग बदलें", + "@changeStudentColorLabel": { + "description": "Accessibility label for the button that lets users change the color associated with a specific student", + "type": "text", + "placeholders_order": [ + "studentName" + ], + "placeholders": { + "studentName": {} + } + }, + "Teacher": "शिक्षक", + "@Teacher": { + "description": "Label for the Teacher enrollment type", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Student": "छात्र", + "@Student": { + "description": "Label for the Student enrollment type", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "TA": "TA", + "@TA": { + "description": "Label for the Teaching Assistant enrollment type (also known as Teacher Aid or Education Assistant), reduced to a short acronym/initialism if appropriate.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Observer": "समीक्षक", + "@Observer": { + "description": "Label for the Observer enrollment type", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Use Camera": "कैमरे का उपयोग करें", + "@Use Camera": { + "description": "Label for the action item that lets the user capture a photo using the device camera", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Upload File": "फ़ाइल अपलोड करें", + "@Upload File": { + "description": "Label for the action item that lets the user upload a file from their device", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Choose from Gallery": "गैलरी से चुनें", + "@Choose from Gallery": { + "description": "Label for the action item that lets the user select a photo from their device gallery", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Preparing…": "तैयार किया जा रहा है…", + "@Preparing…": { + "description": "Message shown while a file is being prepared to attach to a message", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Add student with…": "छात्र को इसके साथ जोड़ें...", + "@Add student with…": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Add Student": "छात्र जोड़ें", + "@Add Student": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You are not observing any students.": "आप किसी भी छात्र की समीक्षा नहीं कर रहे हैं।", + "@You are not observing any students.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error loading your students.": "आपके छात्रों को लोड करने में त्रुटि हुई।", + "@There was an error loading your students.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Pairing Code": "पेयरिंग कोड", + "@Pairing Code": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Students can obtain a pairing code through the Canvas website": "छात्र Canvas वेबसाइट के माध्यम से पेयरिंग कोड प्राप्त कर सकते हैं", + "@Students can obtain a pairing code through the Canvas website": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Enter the student pairing code provided to you. If the pairing code doesn't work, it may have expired": "आपको प्रदान किया गया छात्र पेयरिंग कोड दर्ज करें। यदि पेयरिंग कोड काम नहीं करता है, तो हो सकता है वह समाप्त हो गया हो", + "@Enter the student pairing code provided to you. If the pairing code doesn't work, it may have expired": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Your code is incorrect or expired.": "आपका कोड गलत है या समाप्त हो गया है।", + "@Your code is incorrect or expired.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Something went wrong trying to create your account, please reach out to your school for assistance.": "अपना खाता बनाने की कोशिश में कुछ गलत हो गया, कृपया सहायता के लिए अपने स्कूल से संपर्क करें।", + "@Something went wrong trying to create your account, please reach out to your school for assistance.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "QR Code": "QR कोड", + "@QR Code": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Students can create a QR code using the Canvas Student app on their mobile device": "छात्र अपने मोबाइल डिवाइस पर Canvas छात्र ऐप का उपयोग करके QR कोड बना सकते हैं", + "@Students can create a QR code using the Canvas Student app on their mobile device": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Add new student": "नया छात्र जोड़ें", + "@Add new student": { + "description": "Semantics label for the FAB on the Manage Students Screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Select": "चयन करें", + "@Select": { + "description": "Hint text to tell the user to choose one of two options", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "I have a Canvas account": "मेरे पास Canvas खाता है", + "@I have a Canvas account": { + "description": "Option to select for users that have a canvas account", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "I don't have a Canvas account": "मेरे पास Canvas खाता नहीं है", + "@I don't have a Canvas account": { + "description": "Option to select for users that don't have a canvas account", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Create Account": "खाता बनाएं", + "@Create Account": { + "description": "Button text for account creation confirmation", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Full Name": "पूरा नाम", + "@Full Name": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Email Address": "ईमेल पता", + "@Email Address": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Password": "पासवर्ड", + "@Password": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Full Name…": "पूरा नाम…", + "@Full Name…": { + "description": "hint label for inside form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Email…": "ईमेल…", + "@Email…": { + "description": "hint label for inside form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Password…": "पासवर्ड…", + "@Password…": { + "description": "hint label for inside form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Please enter full name": "कृपया पूरा नाम दर्ज करें", + "@Please enter full name": { + "description": "Error message for form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Please enter an email address": "कृपया ईमेल पता दर्ज करें", + "@Please enter an email address": { + "description": "Error message for form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Please enter a valid email address": "कृपया मान्य ईमेल पता दर्ज करें", + "@Please enter a valid email address": { + "description": "Error message for form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Password is required": "पासवर्ड की आवश्यकता है", + "@Password is required": { + "description": "Error message for form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Password must contain at least 8 characters": "पासवर्ड में कम से कम 8 कैरेक्टर होने चाहिए", + "@Password must contain at least 8 characters": { + "description": "Error message for form field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "qrCreateAccountTos": "'खाता बनाएं' पर टैप करके, आप {termsOfService} से सहमत होते हैं और {privacyPolicy} को स्वीकार करते हैं", + "@qrCreateAccountTos": { + "description": "The text show on the account creation screen", + "type": "text", + "placeholders_order": [ + "termsOfService", + "privacyPolicy" + ], + "placeholders": { + "termsOfService": {}, + "privacyPolicy": {} + } + }, + "Terms of Service": "सेवा की शर्तें", + "@Terms of Service": { + "description": "Label for the Canvas Terms of Service agreement. This will be used in the qrCreateAccountTos text and will be highlighted and clickable", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Privacy Policy": "गोपनीयता नीति", + "@Privacy Policy": { + "description": "Label for the Canvas Privacy Policy agreement. This will be used in the qrCreateAccountTos text and will be highlighted and clickable", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "View the Privacy Policy": "गोपनीयता नीति देखें", + "@View the Privacy Policy": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Already have an account? ": "पहले से कोई खाता है? ", + "@Already have an account? ": { + "description": "Part of multiline text span, includes AccountSignIn1-2, in that order", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Sign In": "साइन इन करें", + "@Sign In": { + "description": "Part of multiline text span, includes AccountSignIn1-2, in that order", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Hide Password": "पासवर्ड छिपाएं", + "@Hide Password": { + "description": "content description for password hide button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Show Password": "पासवर्ड दिखाएं", + "@Show Password": { + "description": "content description for password show button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Terms of Service Link": "सेवा की शर्तें लिंक", + "@Terms of Service Link": { + "description": "content description for terms of service link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Privacy Policy Link": "गोपनीयता नीति लिंक", + "@Privacy Policy Link": { + "description": "content description for privacy policy link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Event": "घटना", + "@Event": { + "description": "Title for the event details screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Date": "तिथि", + "@Date": { + "description": "Label for the event date", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Location": "लोकेशन", + "@Location": { + "description": "Label for the location information", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No Location Specified": "कोई लोकेशन निर्दिष्ट नहीं", + "@No Location Specified": { + "description": "Description for events that do not have a location", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "eventTime": "{startAt} - {endAt}", + "@eventTime": { + "description": "The time the event is happening, example: \"2:00 pm - 4:00 pm\"", + "type": "text", + "placeholders_order": [ + "startAt", + "endAt" + ], + "placeholders": { + "startAt": {}, + "endAt": {} + } + }, + "Set a date and time to be notified of this event.": "इस घटना की सूचना पाने के लिए कोई तिथि और समय निर्धारित करें।", + "@Set a date and time to be notified of this event.": { + "description": "Description for row to set event reminders", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You will be notified about this event on…": "आपको इस घटना के बारे में सूचित किया जाएगा...", + "@You will be notified about this event on…": { + "description": "Description for when an event reminder is set", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Share Your Love for the App": "ऐप के लिए अपना प्यार साझा करें", + "@Share Your Love for the App": { + "description": "Label for option to open the app store", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Tell us about your favorite parts of the app": "हमें ऐप के अपने पसंदीदा हिस्सों के बारे में बताएं", + "@Tell us about your favorite parts of the app": { + "description": "Description for option to open the app store", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Legal": "कानूनी", + "@Legal": { + "description": "Label for legal information option", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Privacy policy, terms of use, open source": "गोपनीयता नीति, उपयोग की शर्तें, ओपन सोर्स", + "@Privacy policy, terms of use, open source": { + "description": "Description for legal information option", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Idea for Canvas Parent App [Android]": "Canvas माता-पिता ऐप [Android] के लिए आइडिया", + "@Idea for Canvas Parent App [Android]": { + "description": "The subject for the email to request a feature", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "The following information will help us better understand your idea:": "निम्नलिखित जानकारी हमें आपके आइडिया को बेहतर ढंग से समझने में मदद करेगी:", + "@The following information will help us better understand your idea:": { + "description": "The header for the users information that is attached to a feature request", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Domain:": "डोमेन:", + "@Domain:": { + "description": "The label for the Canvas domain of the logged in user", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "User ID:": "उपयोगकर्ता आईडी:", + "@User ID:": { + "description": "The label for the Canvas user ID of the logged in user", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Email:": "ईमेल:", + "@Email:": { + "description": "The label for the eamil of the logged in user", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Locale:": "स्थान:", + "@Locale:": { + "description": "The label for the locale of the logged in user", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Terms of Use": "उपयोग की शर्तें", + "@Terms of Use": { + "description": "Label for the terms of use", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Canvas on GitHub": "GitHub पर Canvas", + "@Canvas on GitHub": { + "description": "Label for the button that opens the Canvas project on GitHub's website", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was a problem loading the Terms of Use": "उपयोग की शर्तों को लोड करने में समस्या आई", + "@There was a problem loading the Terms of Use": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Device": "डिवाइस", + "@Device": { + "description": "Label used for device manufacturer/model in the error report", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "OS Version": "OS संस्करण", + "@OS Version": { + "description": "Label used for device operating system version in the error report", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Version Number": "संस्करण संख्या", + "@Version Number": { + "description": "Label used for the app version number in the error report", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Report A Problem": "समस्या की रिपोर्ट करें", + "@Report A Problem": { + "description": "Title used for generic dialog to report problems", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Subject": "विषय", + "@Subject": { + "description": "Label used for Subject text field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "A subject is required.": "विषय की आवश्यकता है।", + "@A subject is required.": { + "description": "Error shown when the subject field is empty", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "An email address is required.": "ईमेल पता आवश्यक है।", + "@An email address is required.": { + "description": "Error shown when the email field is empty", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Description": "विवरण", + "@Description": { + "description": "Label used for Description text field", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "A description is required.": "विवरण आवश्यक है।", + "@A description is required.": { + "description": "Error shown when the description field is empty", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "How is this affecting you?": "इसका आप पर क्या प्रभाव पड़ रहा है?", + "@How is this affecting you?": { + "description": "Label used for the dropdown to select how severe the issue is", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "send": "भेजें", + "@send": { + "description": "Label used for send button when reporting a problem", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Just a casual question, comment, idea, suggestion…": "बस एक अनौपचारिक प्रश्न, टिप्पणी, आइडिया, सुझाव...", + "@Just a casual question, comment, idea, suggestion…": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "I need some help but it's not urgent.": "मुझे कुछ मदद की ज़रूरत है, लेकिन यह अत्यावश्यक नहीं है।", + "@I need some help but it's not urgent.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Something's broken but I can work around it to get what I need done.": "एक समस्या है, लेकिन काम को पूरा करने के लिए कोई समाधान निकाल लिया जाएगा।", + "@Something's broken but I can work around it to get what I need done.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "I can't get things done until I hear back from you.": "जब तक आपसे प्रतिक्रिया नहीं मिल जाती, काम पूरा नहीं किया जा सकता।", + "@I can't get things done until I hear back from you.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "EXTREME CRITICAL EMERGENCY!!": "अत्यधिक गंभीर आपातकालीन स्थिति!!", + "@EXTREME CRITICAL EMERGENCY!!": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Not Graded": "ग्रेड नहीं किया गया", + "@Not Graded": { + "description": "Description for an assignment has not been graded.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Login flow: Normal": "लॉगिन प्रवाह: सामान्य", + "@Login flow: Normal": { + "description": "Description for the normal login flow", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Login flow: Canvas": "लॉगिन प्रवाह: Canvas", + "@Login flow: Canvas": { + "description": "Description for the Canvas login flow", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Login flow: Site Admin": "लॉगिन प्रवाह: साइट व्यवस्थापक", + "@Login flow: Site Admin": { + "description": "Description for the Site Admin login flow", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Login flow: Skip mobile verify": "लॉगिन प्रवाह: मोबाइल सत्यापन छोड़ें", + "@Login flow: Skip mobile verify": { + "description": "Description for the login flow that skips domain verification for mobile", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Act As User": "उपयोगकर्ता के रूप में कार्य करें", + "@Act As User": { + "description": "Label for the button that allows the user to act (masquerade) as another user", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Stop Acting as User": "उपयोगकर्ता के रूप में कार्य करना बंद करें", + "@Stop Acting as User": { + "description": "Label for the button that allows the user to stop acting (masquerading) as another user", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "actingAsUser": "आप {userName} के रूप में कार्य कर रहे हैं", + "@actingAsUser": { + "description": "Message shown while acting (masquerading) as another user", + "type": "text", + "placeholders_order": [ + "userName" + ], + "placeholders": { + "userName": {} + } + }, + "\"Act as\" is essentially logging in as this user without a password. You will be able to take any action as if you were this user, and from other users' points of views, it will be as if this user performed them. However, audit logs record that you were the one who performed the actions on behalf of this user.": "\"इस रूप में कार्य करें\" अनिवार्य रूप से बिना पासवर्ड के इस उपयोगकर्ता के रूप में लॉग इन करना है। आपके पास ऐसे कार्य करने की क्षमता होगी जैसे कि आप यह उपयोगकर्ता थे, और अन्य उपयोगकर्ताओं को ऐसा प्रतीत होगा जैसे कि इस उपयोगकर्ता ने कार्य किए हैं। हालांकि, ऑडिट लॉग रिकॉर्ड बताते हैं कि वो आप ही थे जिन्होंने इस उपयोगकर्ता की ओर से कार्रवाई की थी।", + "@\"Act as\" is essentially logging in as this user without a password. You will be able to take any action as if you were this user, and from other users' points of views, it will be as if this user performed them. However, audit logs record that you were the one who performed the actions on behalf of this user.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Domain": "डोमेन", + "@Domain": { + "description": "Text field hint for domain url input", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You must enter a valid domain": "आपको कोई मान्य डोमेन दर्ज करना चाहिए", + "@You must enter a valid domain": { + "description": "Message displayed for domain input error", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "User ID": "उपयोगकर्ता आईडी", + "@User ID": { + "description": "Text field hint for user ID input", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You must enter a user id": "आपको उपयोगकर्ता आईडी दर्ज करनी चाहिए", + "@You must enter a user id": { + "description": "Message displayed for user Id input error", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error trying to act as this user. Please check the Domain and User ID and try again.": "इस उपयोगकर्ता के रूप में कार्य करने का प्रयास करते समय त्रुटि हुई। कृपया डोमेन और उपयोगकर्ता आईडी की जांच करें और फिर से कोशिश करें।", + "@There was an error trying to act as this user. Please check the Domain and User ID and try again.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "endMasqueradeMessage": "आप {userName} के रूप में कार्य करना बंद कर देंगे और अपने मूल खाते में वापस आ जाएंगे।", + "@endMasqueradeMessage": { + "description": "Confirmation message displayed when the user wants to stop acting (masquerading) as another user", + "type": "text", + "placeholders_order": [ + "userName" + ], + "placeholders": { + "userName": {} + } + }, + "endMasqueradeLogoutMessage": "आप {userName} के रूप में कार्य करना बंद कर देंगे और आपको लॉग आउट कर दिया जाएगा।", + "@endMasqueradeLogoutMessage": { + "description": "Confirmation message displayed when the user wants to stop acting (masquerading) as another user and will be logged out.", + "type": "text", + "placeholders_order": [ + "userName" + ], + "placeholders": { + "userName": {} + } + }, + "How are we doing?": "हमारा कार्य कैसा चल रहा है?", + "@How are we doing?": { + "description": "Title for dialog asking user to rate the app out of 5 stars.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Don't show again": "फिर से न दिखाएं", + "@Don't show again": { + "description": "Button to prevent the rating dialog from showing again.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "What can we do better?": "हम क्या बेहतर कर सकते हैं?", + "@What can we do better?": { + "description": "Hint text for providing a comment with the rating.", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Send Feedback": "फ़ीडबैक भेजें", + "@Send Feedback": { + "description": "Button to send rating with feedback", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "ratingDialogEmailSubject": "Android - Canvas Parent {version} के लिए सुझाव", + "@ratingDialogEmailSubject": { + "description": "The subject for an email to provide feedback for CanvasParent.", + "type": "text", + "placeholders_order": [ + "version" + ], + "placeholders": { + "version": {} + } + }, + "starRating": "{position,plural, =1{{position} तारा}other{{position} तारे}}", + "@starRating": { + "description": "Accessibility label for the 1 stars to 5 stars rating", + "type": "text", + "placeholders_order": [ + "position" + ], + "placeholders": { + "position": { + "example": 1 + } + } + }, + "Student Pairing": "छात्र पेयरिंग", + "@Student Pairing": { + "description": "Title for the screen where users can pair to students using a QR code", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Open Canvas Student": "Canvas Student खोलें", + "@Open Canvas Student": { + "description": "Title for QR pairing tutorial screen instructing users to open the Canvas Student app", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You'll need to open your student's Canvas Student app to continue. Go into Main Menu > Settings > Pair with Observer and scan the QR code you see there.": "जारी रखने के लिए आपको अपने छात्र का Canvas Student ऐप खोलना होगा। मुख्य मेनू > सेटिंग्स > समीक्षक के साथ पेयर करें में जाएं और वहां दिखाई देने वाले QR कोड को स्कैन करें।", + "@You'll need to open your student's Canvas Student app to continue. Go into Main Menu > Settings > Pair with Observer and scan the QR code you see there.": { + "description": "Message explaining how QR code pairing works", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Screenshot showing location of pairing QR code generation in the Canvas Student app": "Canvas Student ऐप में QR कोड जेनरेशन को पेयर करने का स्थान दिखाने वाला स्क्रीनशॉट", + "@Screenshot showing location of pairing QR code generation in the Canvas Student app": { + "description": "Content Description for qr pairing tutorial screenshot", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Expired QR Code": "समाप्त हुआ QR कोड", + "@Expired QR Code": { + "description": "Error title shown when the users scans a QR code that has expired", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "The QR code you scanned may have expired. Refresh the code on the student's device and try again.": "हो सकता है कि आपके द्वारा स्कैन किए गए QR कोड का समय समाप्त हो गया हो। छात्र की डिवाइस पर कोड रीफ़्रेश करें और फिर से कोशिश करें।", + "@The QR code you scanned may have expired. Refresh the code on the student's device and try again.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "A network error occurred when adding this student. Check your connection and try again.": "इस छात्र को जोड़ते समय कोई नेटवर्क त्रुटि उत्पन्न हुई। अपने कनेक्शन की जांच करें और फिर से कोशिश करें।", + "@A network error occurred when adding this student. Check your connection and try again.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Invalid QR Code": "अमान्य QR कोड", + "@Invalid QR Code": { + "description": "Error title shown when the user scans an invalid QR code", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Incorrect Domain": "गलत डोमेन", + "@Incorrect Domain": { + "description": "Error title shown when the users scane a QR code for a student that belongs to a different domain", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "The student you are trying to add belongs to a different school. Log in or create an account with that school to scan this code.": "जिस छात्र को आप जोड़ने का प्रयास कर रहे हैं वह एक अलग स्कूल से है। इस कोड को स्कैन करने के लिए लॉग इन करें या उस स्कूल में खाता बनाएं।", + "@The student you are trying to add belongs to a different school. Log in or create an account with that school to scan this code.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Camera Permission": "कैमरा अनुमति", + "@Camera Permission": { + "description": "Error title shown when the user wans to scan a QR code but has denied the camera permission", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "This will unpair and remove all enrollments for this student from your account.": "इस तरह से आपके खाते से इस छात्र के सभी नामांकन अनपेयर हो जाएंगे और निकाल दिए जाएंगे।", + "@This will unpair and remove all enrollments for this student from your account.": { + "description": "Confirmation message shown when the user tries to delete a student from their account", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was a problem removing this student from your account. Please check your connection and try again.": "इस छात्र को आपके खाते से निकालने में समस्या हुई। कृपया अपने कनेक्शन की जांच करें और फिर से कोशिश करें।", + "@There was a problem removing this student from your account. Please check your connection and try again.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Cancel": "रद्द करें", + "@Cancel": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "next": "अगला", + "@next": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "ok": "ठीक है", + "@ok": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Yes": "हां", + "@Yes": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No": "नहीं", + "@No": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Retry": "पुन: प्रयास करें", + "@Retry": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Delete": "मिटाएं", + "@Delete": { + "description": "Label used for general delete/remove actions", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Done": "हो गया", + "@Done": { + "description": "Label for general done/finished actions", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Refresh": "रीफ़्रेश करें", + "@Refresh": { + "description": "Label for button to refresh data from the web", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "View Description": "विवरण देखें", + "@View Description": { + "description": "Button to view the description for an event or assignment", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "expanded": "बढ़ाया गया", + "@expanded": { + "description": "Description for the accessibility reader for list groups that are expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "collapsed": "संक्षिप्त किया गया", + "@collapsed": { + "description": "Description for the accessibility reader for list groups that are expanded", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "An unexpected error occurred": "एक अप्रत्याशित त्रुटि हुई", + "@An unexpected error occurred": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "No description": "कोई विवरण नहीं", + "@No description": { + "description": "Message used when the assignment has no description", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Launch External Tool": "बाहरी उपकरण लॉन्च करें", + "@Launch External Tool": { + "description": "Button text added to webviews to let users open external tools in their browser", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Interactions on this page are limited by your institution.": "इस पेज पर बातचीत आपके संस्थान द्वारा सीमित हैं।", + "@Interactions on this page are limited by your institution.": { + "description": "Message describing how the webview has limited access due to an instution setting", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "dateAtTime": "{time} पर {date}", + "@dateAtTime": { + "description": "The string to format dates", + "type": "text", + "placeholders_order": [ + "date", + "time" + ], + "placeholders": { + "date": {}, + "time": {} + } + }, + "dueDateAtTime": "{date} को {time} बजे नियत", + "@dueDateAtTime": { + "description": "The string to format due dates", + "type": "text", + "placeholders_order": [ + "date", + "time" + ], + "placeholders": { + "date": {}, + "time": {} + } + }, + "No Due Date": "कोई नियत तिथि नहीं", + "@No Due Date": { + "description": "Label for assignments that do not have a due date", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Filter": "फ़िल्टर", + "@Filter": { + "description": "Label for buttons to filter what items are visible", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "unread": "अपठित", + "@unread": { + "description": "Label for things that are marked as unread", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "unreadCount": "{count} अपठित", + "@unreadCount": { + "description": "Formatted string for when there are a number of unread items", + "type": "text", + "placeholders_order": [ + "count" + ], + "placeholders": { + "count": {} + } + }, + "badgeNumberPlus": "{count}+", + "@badgeNumberPlus": { + "description": "Formatted string for when too many items are being notified in a badge, generally something like: 99+", + "type": "text", + "placeholders_order": [ + "count" + ], + "placeholders": { + "count": {} + } + }, + "There was an error loading this announcement": "इस घोषणा को लोड करने में त्रुटि हुई", + "@There was an error loading this announcement": { + "description": "Message shown when an announcement detail screen fails to load", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Network error": "नेटवर्क त्रुटि", + "@Network error": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Under Construction": "निर्माणाधीन", + "@Under Construction": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "We are currently building this feature for your viewing pleasure.": "हम वर्तमान में आपके देखने के अनुभव को बेहतर बनाने के लिए इस फ़ीचर को विकसित कर रहे हैं।", + "@We are currently building this feature for your viewing pleasure.": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Request Login Help Button": "लॉगिन मदद बटन का अनुरोध करें", + "@Request Login Help Button": { + "description": "Accessibility hint for button that opens help dialog for a login help request", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Request Login Help": "लॉगिन मदद का अनुरोध करें", + "@Request Login Help": { + "description": "Title of help dialog for a login help request", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "I'm having trouble logging in": "मुझे लॉग इन करने में समस्या हो रही है", + "@I'm having trouble logging in": { + "description": "Subject of help dialog for a login help request", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "An error occurred when trying to display this link": "इस लिंक को प्रदर्शित करने का प्रयास करते समय त्रुटि उत्पन्न हुई", + "@An error occurred when trying to display this link": { + "description": "Error message shown when a link can't be opened", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "We are unable to display this link, it may belong to an institution you currently aren't logged in to.": "हम इस लिंक को प्रदर्शित करने में असमर्थ हैं, यह किसी ऐसे संस्थान से संबंधित हो सकता है जिसमें आप वर्तमान में लॉग इन नहीं हैं।", + "@We are unable to display this link, it may belong to an institution you currently aren't logged in to.": { + "description": "Description for error page shown when clicking a link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Link Error": "लिंक त्रुटि", + "@Link Error": { + "description": "Title for error page shown when clicking a link", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Open In Browser": "ब्राउज़र में खोलें", + "@Open In Browser": { + "description": "Text for button to open a link in the browswer", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "You'll find the QR code on the web in your account profile. Click 'QR for Mobile Login' in the list.": "आपको वेब पर अपनी खाता प्रोफ़ाइल में QR कोड मिलेगा। सूची में 'मोबाइल लॉगिन के लिए QR' पर क्लिक करें।", + "@You'll find the QR code on the web in your account profile. Click 'QR for Mobile Login' in the list.": { + "description": "Text for qr login tutorial screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Locate QR Code": "QR कोड का पता लगाएं", + "@Locate QR Code": { + "description": "Text for qr login button", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Please scan a QR code generated by Canvas": "कृपया Canvas द्वारा जेनरेट किए गए QR कोड को स्कैन करें", + "@Please scan a QR code generated by Canvas": { + "description": "Text for qr login error with incorrect qr code", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "There was an error logging in. Please generate another QR Code and try again.": "लॉग इन करने में त्रुटि हुई। कृपया कोई अन्य QR कोड जेनरेट करें और फिर से कोशिश करें।", + "@There was an error logging in. Please generate another QR Code and try again.": { + "description": "Text for qr login error", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Screenshot showing location of QR code generation in browser": "ब्राउज़र में QR कोड जेनरेशन का स्थान दिखाने वाला स्क्रीनशॉट", + "@Screenshot showing location of QR code generation in browser": { + "description": "Content Description for qr login tutorial screenshot", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "QR scanning requires camera access": "QR स्कैनिंग के लिए कैमरा एक्सेस की आवश्यकता होती है", + "@QR scanning requires camera access": { + "description": "placeholder for camera error for QR code scan", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "The linked item is no longer available": "लिंक किया गया आइटम अब उपलब्ध नहीं है", + "@The linked item is no longer available": { + "description": "error message when the alert could no be opened", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Message sent": "संदेश भेजा गया", + "@Message sent": { + "description": "confirmation message on the screen when the user succesfully sends a message", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Acceptable Use Policy": "स्वीकार्य उपयोग नीति", + "@Acceptable Use Policy": { + "description": "title for the acceptable use policy screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Submit": "सबमिट करें", + "@Submit": { + "description": "submit button title for acceptable use policy screen", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Either you're a new user or the Acceptable Use Policy has changed since you last agreed to it. Please agree to the Acceptable Use Policy before you continue.": "या तो आप नए उपयोगकर्ता हैं या स्वीकार्य उपयोग नीति तब से बदल गई है जब आप पिछली बार इसके लिए सहमत हुए थे। जारी रखने से पहले कृपया स्वीकार्य उपयोग नीति से सहमत हों।", + "@Either you're a new user or the Acceptable Use Policy has changed since you last agreed to it. Please agree to the Acceptable Use Policy before you continue.": { + "description": "acceptable use policy screen description", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "I agree to the Acceptable Use Policy.": "मैं स्वीकार्य उपयोग नीति से सहमत हूं।", + "@I agree to the Acceptable Use Policy.": { + "description": "acceptable use policy switch title", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "About": "इसके बारे में", + "@About": { + "description": "Title for about menu item in settings", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "App": "ऐप", + "@App": { + "description": "Title for App field on about page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Login ID": "लॉगिन आईडी", + "@Login ID": { + "description": "Title for Login ID field on about page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Email": "ईमेल", + "@Email": { + "description": "Title for Email field on about page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Version": "संस्करण", + "@Version": { + "description": "Title for Version field on about page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "Instructure logo": "Instructure लोगो", + "@Instructure logo": { + "description": "Semantics label for the Instructure logo on the about page", + "type": "text", + "placeholders_order": [], + "placeholders": {} + } +} \ No newline at end of file diff --git a/apps/parent/build.gradle b/apps/parent/build.gradle index 29702ae11f..715191d39e 100644 --- a/apps/parent/build.gradle +++ b/apps/parent/build.gradle @@ -42,9 +42,12 @@ android { versionCode 50 versionName "3.10.0" + buildConfigField "boolean", "IS_TESTING", "false" + testInstrumentationRunner 'com.instructure.parentapp.ui.espresso.ParentHiltTestRunner' + testInstrumentationRunnerArguments disableAnalytics: 'true' + /* Add private data */ PrivateData.merge(project, "parent") - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } packagingOptions { @@ -140,11 +143,15 @@ android { buildFeatures { viewBinding true dataBinding true + compose true } hilt { enableAggregatingTask = false enableExperimentalClasspathAggregation = true } + composeOptions { + kotlinCompilerExtensionVersion = Versions.KOTLIN_COMPOSE_COMPILER_VERSION + } } dependencies { @@ -186,6 +193,7 @@ dependencies { kapt Libs.HILT_COMPILER implementation Libs.HILT_ANDROIDX_WORK kapt Libs.HILT_ANDROIDX_COMPILER + androidTestImplementation Libs.HILT_TESTING /* ROOM */ implementation Libs.ROOM @@ -206,4 +214,6 @@ dependencies { implementation Libs.ANDROIDX_RECYCLERVIEW implementation Libs.ANDROIDX_PALETTE implementation Libs.PLAY_IN_APP_UPDATES + + androidTestImplementation Libs.COMPOSE_UI_TEST } \ No newline at end of file diff --git a/apps/parent/proguard-rules.pro b/apps/parent/proguard-rules.txt similarity index 100% rename from apps/parent/proguard-rules.pro rename to apps/parent/proguard-rules.txt diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/compose/CoursesScreenTest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/compose/CoursesScreenTest.kt new file mode 100644 index 0000000000..07772ed7b0 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/compose/CoursesScreenTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.instructure.parentapp.ui.compose + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.espresso.assertTextColor +import com.instructure.pandares.R +import com.instructure.parentapp.features.courses.list.CourseListItemUiState +import com.instructure.parentapp.features.courses.list.CoursesScreen +import com.instructure.parentapp.features.courses.list.CoursesUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + + +@RunWith(AndroidJUnit4::class) +class CoursesScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun assertEmptyContent() { + composeTestRule.setContent { + CoursesScreen( + uiState = CoursesUiState( + isLoading = false, + courseListItems = emptyList() + ), + actionHandler = {} + ) + } + + composeTestRule.onNodeWithText("No Courses") + .assertIsDisplayed() + composeTestRule.onNodeWithText("Your student’s courses might not be published yet.") + .assertIsDisplayed() + composeTestRule.onNodeWithTag(R.drawable.ic_panda_book.toString()) + .assertIsDisplayed() + } + + @Test + fun assertErrorContent() { + composeTestRule.setContent { + CoursesScreen( + uiState = CoursesUiState( + isLoading = false, + isError = true + ), + actionHandler = {} + ) + } + + composeTestRule.onNodeWithText("There was an error loading your student’s courses.") + .assertIsDisplayed() + composeTestRule.onNodeWithText("Retry") + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun assertCourseContent() { + composeTestRule.setContent { + CoursesScreen( + uiState = CoursesUiState( + isLoading = false, + studentColor = android.graphics.Color.RED, + courseListItems = listOf( + CourseListItemUiState( + courseId = 1, + courseName = "Course 1", + courseCode = "C1", + grade = "A+" + ) + ) + ), + actionHandler = {} + ) + } + + composeTestRule.onNodeWithText("Course 1") + .assertIsDisplayed() + composeTestRule.onNodeWithText("C1") + .assertIsDisplayed() + composeTestRule.onNodeWithText("A+", useUnmergedTree = true) + .assertIsDisplayed() + .assertTextColor(Color(android.graphics.Color.RED)) + composeTestRule.onNodeWithTag("courseListItem") + .assertIsDisplayed() + .assertHasClickAction() + } + + @Test + fun assertLoadingContent() { + composeTestRule.setContent { + CoursesScreen( + uiState = CoursesUiState( + isLoading = true + ), + actionHandler = {} + ) + } + + composeTestRule.onNodeWithTag("pullRefreshIndicator") + .assertIsDisplayed() + } +} diff --git a/apps/student/src/main/java/com/instructure/student/db/sqlColAdapters/ErrorColAdapter.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/ParentHiltTestApplication.kt similarity index 64% rename from apps/student/src/main/java/com/instructure/student/db/sqlColAdapters/ErrorColAdapter.kt rename to apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/ParentHiltTestApplication.kt index 075a490ca4..133f99ea69 100644 --- a/apps/student/src/main/java/com/instructure/student/db/sqlColAdapters/ErrorColAdapter.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/ParentHiltTestApplication.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * Copyright (C) 2024 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -12,12 +12,12 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * */ -package com.instructure.student.db.sqlColAdapters -import com.squareup.sqldelight.ColumnAdapter +package com.instructure.parentapp.ui.espresso + +import dagger.hilt.android.testing.CustomTestApplication -class ErrorColAdapter : ColumnAdapter { - override fun decode(databaseValue: Int) = databaseValue == 1 - override fun encode(value: Boolean) = if (value) 1 else 0 -} \ No newline at end of file +@CustomTestApplication(TestAppManager::class) +interface ParentHiltTestApplication \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/db/Db.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/ParentHiltTestRunner.kt similarity index 51% rename from apps/student/src/main/java/com/instructure/student/db/Db.kt rename to apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/ParentHiltTestRunner.kt index 6881833765..f1d1d18169 100644 --- a/apps/student/src/main/java/com/instructure/student/db/Db.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/ParentHiltTestRunner.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * Copyright (C) 2024 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,29 +14,16 @@ * limitations under the License. * */ -package com.instructure.student.db -import com.squareup.sqldelight.db.SqlDriver +package com.instructure.parentapp.ui.espresso -object Db { - private var driverRef: SqlDriver? = null - private var dbRef: StudentDb? = null +import android.app.Application +import android.content.Context +import com.instructure.canvas.espresso.CanvasRunner - val ready: Boolean - get() = driverRef != null +class ParentHiltTestRunner : CanvasRunner() { - fun dbSetup(driver: SqlDriver) { - val db = createQueryWrapper(driver) - driverRef = driver - dbRef = db + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { + return super.newApplication(cl, ParentHiltTestApplication_Application::class.java.name, context) } - - internal fun dbClear() { - driverRef!!.close() - dbRef = null - driverRef = null - } - - val instance: StudentDb - get() = dbRef!! -} \ No newline at end of file +} diff --git a/apps/student/src/main/java/com/instructure/student/db/sqlColAdapters/CanvasContextColAdapter.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/TestAppManager.kt similarity index 53% rename from apps/student/src/main/java/com/instructure/student/db/sqlColAdapters/CanvasContextColAdapter.kt rename to apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/TestAppManager.kt index 7b16bc9590..76c05c44d5 100644 --- a/apps/student/src/main/java/com/instructure/student/db/sqlColAdapters/CanvasContextColAdapter.kt +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/espresso/TestAppManager.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 - present Instructure, Inc. + * Copyright (C) 2024 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,17 +14,17 @@ * limitations under the License. * */ -package com.instructure.student.db.sqlColAdapters -import com.squareup.sqldelight.ColumnAdapter -import com.instructure.canvasapi2.models.CanvasContext +package com.instructure.parentapp.ui.espresso -class CanvasContextColAdapter : ColumnAdapter { - override fun decode(databaseValue: String): CanvasContext { - return CanvasContext.fromContextCode(databaseValue) ?: CanvasContext.defaultCanvasContext() - } +import androidx.work.WorkerFactory +import com.instructure.parentapp.util.BaseAppManager + +open class TestAppManager : BaseAppManager() { + + var workerFactory: WorkerFactory? = null - override fun encode(value: CanvasContext): String { - return value.contextId + override fun getWorkManagerFactory(): WorkerFactory { + return workerFactory ?: WorkerFactory.getDefaultWorkerFactory() } } \ No newline at end of file diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/interaction/CoursesInteractionTest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/interaction/CoursesInteractionTest.kt new file mode 100644 index 0000000000..b03bded687 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/interaction/CoursesInteractionTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.ui.interaction + +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addCourseWithEnrollment +import com.instructure.canvas.espresso.mockCanvas.addEnrollment +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Enrollment +import com.instructure.parentapp.ui.pages.CoursesPage +import com.instructure.parentapp.utils.ParentComposeTest +import com.instructure.parentapp.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + + +@HiltAndroidTest +class CoursesInteractionTest : ParentComposeTest() { + + private val coursesPage = CoursesPage(composeTestRule) + + @Test + fun testNoCourseDisplayed() { + val data = initData() + data.courses.clear() + + goToCourses(data) + + composeTestRule.waitForIdle() + coursesPage.assertEmptyContentDisplayed() + } + + @Test + fun testCourseDisplayed() { + val data = initData() + + goToCourses(data) + + composeTestRule.waitForIdle() + coursesPage.assertCourseItemDisplayed(data.courses.values.first()) + } + + @Test + fun testShowGradeIfThereIsACurrentGrade() { + val data = initData() + val course = data.courses.values.find { + val enrollment = it.enrollments!!.first() + !enrollment.currentGrade.isNullOrEmpty() && enrollment.currentScore != null + } + val enrollment = course!!.enrollments!!.first() + + goToCourses(data) + + composeTestRule.waitForIdle() + coursesPage.assertGradeTextDisplayed(course.name, "${enrollment.currentGrade} ${enrollment.currentScore}%") + } + + @Test + fun testShowNoGradeIfThereIsNoCurrentGrade() { + val data = initData() + val firstStudent = data.students.first() + val courseWithoutGrade = data.addCourseWithEnrollment(firstStudent, Enrollment.EnrollmentType.Student, score = null, grade = null) + data.addEnrollment(data.parents.first(), courseWithoutGrade, Enrollment.EnrollmentType.Observer, firstStudent) + + goToCourses(data) + + composeTestRule.waitForIdle() + coursesPage.assertGradeTextDisplayed(courseWithoutGrade.name, "No Grade") + } + + @Test + fun testShowGradeOnlyIfQuantitativeDataIsRestricted() { + val data = initData() + val firstStudent = data.students.first() + val course = data.addCourseWithEnrollment(firstStudent, Enrollment.EnrollmentType.Student, restrictQuantitativeData = true) + data.addEnrollment(data.parents.first(), course, Enrollment.EnrollmentType.Observer, firstStudent) + + goToCourses(data) + + composeTestRule.waitForIdle() + coursesPage.assertGradeTextIsNotDisplayed(course.name) + } + + @Test + fun testCourseTapped() { + val data = initData() + + goToCourses(data) + + composeTestRule.waitForIdle() + coursesPage.tapCurseItem(data.courses.values.first().name) + // TODO Assert course details when implemented + } + + private fun initData(): MockCanvas { + return MockCanvas.init( + parentCount = 1, + studentCount = 1, + courseCount = 3 + ) + } + + private fun goToCourses(data: MockCanvas) { + val parent = data.parents.first() + val token = data.tokenFor(parent)!! + tokenLogin(data.domain, token, parent) + } +} diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/CoursesPage.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/CoursesPage.kt new file mode 100644 index 0000000000..83c9844e4f --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/ui/pages/CoursesPage.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.instructure.parentapp.ui.pages + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.instructure.canvasapi2.models.Course +import com.instructure.composeTest.hasSiblingWithText +import com.instructure.pandares.R + + +class CoursesPage(private val composeTestRule: ComposeTestRule) { + + fun assertCourseItemDisplayed(course: Course) { + composeTestRule.onNodeWithText(course.name) + .performScrollTo() + .assertIsDisplayed() + course.courseCode?.let { + composeTestRule.onNode(hasSiblingWithText(course.name).and(hasText(it)), true) + .performScrollTo() + .assertIsDisplayed() + } + } + + fun assertEmptyContentDisplayed() { + composeTestRule.onNodeWithText("No Courses") + .assertIsDisplayed() + composeTestRule.onNodeWithText("Your student’s courses might not be published yet.") + .assertIsDisplayed() + composeTestRule.onNodeWithTag(R.drawable.ic_panda_book.toString()) + .assertIsDisplayed() + } + + fun assertGradeTextDisplayed(courseName: String, gradeText: String) { + composeTestRule.onNode(hasSiblingWithText(courseName).and(hasText(gradeText)), true) + .performScrollTo() + .assertIsDisplayed() + } + + fun assertGradeTextIsNotDisplayed(courseName: String) { + composeTestRule.onNode(hasSiblingWithText(courseName).and(hasTestTag("gradeText")), true) + .assertIsNotDisplayed() + } + + fun tapCurseItem(courseName: String) { + composeTestRule.onNodeWithText(courseName) + .performScrollTo() + .assertIsDisplayed() + .performClick() + } +} diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentActivityTestRule.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentActivityTestRule.kt new file mode 100644 index 0000000000..39aa54cdd1 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentActivityTestRule.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.instructure.parentapp.utils + +import android.app.Activity +import android.content.Context +import com.instructure.espresso.InstructureActivityTestRule +import com.instructure.loginapi.login.util.LoginPrefs +import com.instructure.loginapi.login.util.PreviousUsersUtils +import com.instructure.pandautils.utils.PandaAppResetter +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.parentapp.util.ParentPrefs + + +class ParentActivityTestRule(activityClass: Class) : InstructureActivityTestRule(activityClass) { + + override fun performReset(context: Context) { + PandaAppResetter.reset(context) + ParentPrefs.clearPrefs() + PreviousUsersUtils.clear(context) + LoginPrefs.clearPrefs() + + // We need to set this true so the theme selector won't stop our tests. + ThemePrefs.themeSelectionShown = true + } +} diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentComposeTest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentComposeTest.kt new file mode 100644 index 0000000000..d65bcdc917 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentComposeTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.instructure.parentapp.utils + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.instructure.parentapp.features.login.LoginActivity +import org.junit.Rule + + +abstract class ParentComposeTest : ParentTest() { + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + override fun displaysPageObjects() = Unit +} diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentTest.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentTest.kt new file mode 100644 index 0000000000..852cf6a357 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentTest.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.instructure.parentapp.utils + +import com.instructure.canvas.espresso.CanvasTest +import com.instructure.parentapp.BuildConfig +import com.instructure.parentapp.features.login.LoginActivity + + +abstract class ParentTest : CanvasTest() { + + override val isTesting = BuildConfig.IS_TESTING + + override val activityRule = ParentActivityTestRule(LoginActivity::class.java) +} \ No newline at end of file diff --git a/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentTestExtensions.kt b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentTestExtensions.kt new file mode 100644 index 0000000000..ca51ca25b8 --- /dev/null +++ b/apps/parent/src/androidTest/java/com/instructure/parentapp/utils/ParentTestExtensions.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.utils + +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import com.instructure.canvas.espresso.waitForMatcherWithSleeps +import com.instructure.canvasapi2.models.User +import com.instructure.parentapp.R +import com.instructure.parentapp.features.login.LoginActivity + + +fun ParentTest.tokenLogin(domain: String, token: String, user: User) { + activityRule.runOnUiThread { + (originalActivity as LoginActivity).loginWithToken( + token, + domain, + user + ) + } + + waitForMatcherWithSleeps(ViewMatchers.withId(R.id.toolbar), 20000).check(ViewAssertions.matches(ViewMatchers.isDisplayed())) +} diff --git a/apps/parent/src/main/AndroidManifest.xml b/apps/parent/src/main/AndroidManifest.xml index 4eaaa8fad1..2596ab4229 100644 --- a/apps/parent/src/main/AndroidManifest.xml +++ b/apps/parent/src/main/AndroidManifest.xml @@ -18,6 +18,10 @@ package="com.instructure.parentapp"> + + android:exported="false" + android:label="@string/canvas" + android:launchMode="singleTask" + android:theme="@style/CanvasMaterialTheme_Default"> diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/ApplicationModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/ApplicationModule.kt index 150d3b3015..cf556d6c79 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/ApplicationModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/ApplicationModule.kt @@ -18,9 +18,11 @@ package com.instructure.parentapp.di import com.instructure.canvasapi2.utils.Analytics +import com.instructure.loginapi.login.util.PreviousUsersUtils import com.instructure.loginapi.login.util.QRLogin import com.instructure.pandautils.utils.LogoutHelper import com.instructure.parentapp.util.ParentLogoutHelper +import com.instructure.parentapp.util.ParentPrefs import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -44,4 +46,14 @@ class ApplicationModule { fun provideAnalytics(): Analytics { return Analytics } + + @Provides + fun providePreviousUsersUtils(): PreviousUsersUtils { + return PreviousUsersUtils + } + + @Provides + fun provideParentPrefs(): ParentPrefs { + return ParentPrefs + } } \ No newline at end of file diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CalendarModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/CalendarModule.kt similarity index 97% rename from apps/parent/src/main/java/com/instructure/parentapp/di/feature/CalendarModule.kt rename to apps/parent/src/main/java/com/instructure/parentapp/di/CalendarModule.kt index 685b044879..42209d6694 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CalendarModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/CalendarModule.kt @@ -14,7 +14,7 @@ * along with this program. If not, see . * */ -package com.instructure.parentapp.di.feature +package com.instructure.parentapp.di import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.apis.CourseAPI diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/CoursesModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/CoursesModule.kt new file mode 100644 index 0000000000..5c946fb125 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/CoursesModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.di + +import android.content.Context +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.parentapp.features.courses.list.CourseGradeFormatter +import com.instructure.parentapp.features.courses.list.CoursesRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.qualifiers.ApplicationContext + + +@Module +@InstallIn(ViewModelComponent::class) +class CoursesModule { + + @Provides + fun provideCoursesRepository(courseApi: CourseAPI.CoursesInterface): CoursesRepository { + return CoursesRepository(courseApi) + } + + @Provides + fun provideCourseGradeFormatter(@ApplicationContext context: Context): CourseGradeFormatter { + return CourseGradeFormatter(context) + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CreateUpdateEventModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/CreateUpdateEventModule.kt similarity index 97% rename from apps/parent/src/main/java/com/instructure/parentapp/di/feature/CreateUpdateEventModule.kt rename to apps/parent/src/main/java/com/instructure/parentapp/di/CreateUpdateEventModule.kt index d053c4b9a6..819296f20e 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/CreateUpdateEventModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/CreateUpdateEventModule.kt @@ -15,7 +15,7 @@ * */ -package com.instructure.parentapp.di.feature +package com.instructure.parentapp.di import com.instructure.canvasapi2.apis.CalendarEventAPI import com.instructure.canvasapi2.apis.CourseAPI diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/EventModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/EventModule.kt similarity index 96% rename from apps/parent/src/main/java/com/instructure/parentapp/di/feature/EventModule.kt rename to apps/parent/src/main/java/com/instructure/parentapp/di/EventModule.kt index e10661947f..776a7c9e8f 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/EventModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/EventModule.kt @@ -15,7 +15,7 @@ * */ -package com.instructure.parentapp.di.feature +package com.instructure.parentapp.di import androidx.fragment.app.FragmentActivity import com.instructure.pandautils.features.calendarevent.details.EventRouter diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/InboxModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/InboxModule.kt index c94bbe48ee..c7a0f29456 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/InboxModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/InboxModule.kt @@ -38,7 +38,7 @@ import dagger.hilt.android.components.ViewModelComponent class InboxFragmentModule { @Provides - fun providesInboxRouter(activity: FragmentActivity, fragment: Fragment): InboxRouter { + fun provideInboxRouter(activity: FragmentActivity, fragment: Fragment): InboxRouter { return ParentInboxRouter(activity, fragment) } } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/MainModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/MainModule.kt new file mode 100644 index 0000000000..1a922e07a5 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/MainModule.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.di + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.apis.ThemeAPI +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.parentapp.features.main.MainRepository +import com.instructure.parentapp.features.main.SelectedStudentHolder +import com.instructure.parentapp.features.main.SelectedStudentHolderImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(ViewModelComponent::class) +class MainModule { + + @Provides + fun provideMainRepository( + enrollmentApi: EnrollmentAPI.EnrollmentInterface, + userApi: UserAPI.UsersInterface, + themeApi: ThemeAPI.ThemeInterface + ): MainRepository { + return MainRepository(enrollmentApi, userApi, themeApi) + } +} + +@Module +@InstallIn(SingletonComponent::class) +class SelectedStudentHolderModule { + + @Provides + @Singleton + fun provideSelectedStudentHolder(): SelectedStudentHolder { + return SelectedStudentHolderImpl() + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt b/apps/parent/src/main/java/com/instructure/parentapp/di/ToDoModule.kt similarity index 96% rename from apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt rename to apps/parent/src/main/java/com/instructure/parentapp/di/ToDoModule.kt index 5850cc76db..dccd3a77c3 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/di/feature/ToDoModule.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/di/ToDoModule.kt @@ -15,7 +15,7 @@ * */ -package com.instructure.parentapp.di.feature +package com.instructure.parentapp.di import androidx.fragment.app.FragmentActivity import com.instructure.pandautils.features.calendartodo.details.ToDoRouter diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CourseGradeFormatter.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CourseGradeFormatter.kt new file mode 100644 index 0000000000..1096da8778 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CourseGradeFormatter.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.courses.list + +import android.content.Context +import com.instructure.canvasapi2.models.Course +import com.instructure.pandautils.utils.orDefault +import com.instructure.parentapp.R +import dagger.hilt.android.qualifiers.ApplicationContext +import java.text.NumberFormat + + +class CourseGradeFormatter(@ApplicationContext private val context: Context) { + + private val percentageFormat = NumberFormat.getPercentInstance().apply { + maximumFractionDigits = 2 + } + + fun getGradeText(course: Course, selectedStudentId: Long): String? { + val enrollment = course.enrollments?.find { it.userId == selectedStudentId } ?: return null + val grade = course.parentGetCourseGradeFromEnrollment( + enrollment, + course.enrollments.orEmpty().any { + !it.hasActiveGradingPeriod() + } + ) + + val restrictQuantitativeData = course.settings?.restrictQuantitativeData.orDefault() + if (grade.isLocked || (restrictQuantitativeData && !grade.hasCurrentGradeString())) return null + + val formattedScore = grade.currentScore?.takeIf { + !restrictQuantitativeData + }?.let { + percentageFormat.format(it / 100) + }.orEmpty() + + return when { + grade.noCurrentGrade -> context.getString(R.string.noGrade) + !grade.currentGrade.isNullOrEmpty() -> "${grade.currentGrade} $formattedScore" + else -> formattedScore + } + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesFragment.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesFragment.kt index e455586e8b..ac3b46d61f 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesFragment.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesFragment.kt @@ -22,26 +22,42 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import com.instructure.pandautils.binding.viewBinding -import com.instructure.parentapp.R -import com.instructure.parentapp.databinding.FragmentCoursesBinding +import com.instructure.pandautils.utils.collectOneOffEvents +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class CoursesFragment : Fragment() { - private val binding by viewBinding(FragmentCoursesBinding::bind) + private val viewModel: CoursesViewModel by viewModels() - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return layoutInflater.inflate(R.layout.fragment_courses, container, false) + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + lifecycleScope.collectOneOffEvents(viewModel.events, ::handleAction) + + return ComposeView(requireActivity()).apply { + setContent { + val uiState by viewModel.uiState.collectAsState() + CoursesScreen(uiState, viewModel::handleAction) + } + } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - // TODO this is just to showcase the navigation - binding.button.setOnClickListener { - findNavController().navigate(Uri.parse("https://anything.instructure.com/courses/1")) + private fun handleAction(action: CoursesViewModelAction) { + when (action) { + is CoursesViewModelAction.NavigateToCourseDetails -> { + findNavController().navigate(Uri.parse(action.navigationUrl)) + } } } } \ No newline at end of file diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesRepository.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesRepository.kt new file mode 100644 index 0000000000..10550dd3b1 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesRepository.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.courses.list + +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.utils.depaginate +import com.instructure.pandautils.utils.orDefault + + +class CoursesRepository( + private val courseApi: CourseAPI.CoursesInterface +) { + + suspend fun getCourses(studentId: Long, forceRefresh: Boolean): List { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return courseApi.firstPageObserveeCourses(params).depaginate { nextUrl -> + courseApi.next(nextUrl, params) + }.dataOrThrow.filter { + it.enrollments?.any { enrollment -> + enrollment.userId == studentId + }.orDefault() + } + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesScreen.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesScreen.kt new file mode 100644 index 0000000000..cdc695edec --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesScreen.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.courses.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.instructure.pandautils.R +import com.instructure.pandautils.compose.CanvasTheme +import com.instructure.pandautils.compose.composables.EmptyContent +import com.instructure.pandautils.compose.composables.ErrorContent + + +@Composable +internal fun CoursesScreen( + uiState: CoursesUiState, + actionHandler: (CoursesAction) -> Unit, + modifier: Modifier = Modifier +) { + CanvasTheme { + Scaffold( + backgroundColor = colorResource(id = R.color.backgroundLightest), + content = { padding -> + if (uiState.isError) { + ErrorContent( + errorMessage = stringResource(id = R.string.errorLoadingCourses), + retryClick = { + actionHandler(CoursesAction.Refresh) + }, modifier = Modifier.fillMaxSize() + ) + } else if (uiState.isEmpty) { + EmptyContent( + emptyTitle = stringResource(id = R.string.parentNoCourses), + emptyMessage = stringResource(id = R.string.parentNoCoursesMessage), + imageRes = R.drawable.ic_panda_book, + modifier = Modifier.fillMaxSize() + ) + } else { + CourseListContent( + uiState = uiState, + actionHandler = actionHandler, + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) + } + }, + modifier = modifier + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CourseListContent( + uiState: CoursesUiState, + actionHandler: (CoursesAction) -> Unit, + modifier: Modifier = Modifier +) { + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isLoading, + onRefresh = { + actionHandler(CoursesAction.Refresh) + } + ) + + Box( + modifier = modifier.pullRefresh(pullRefreshState) + ) { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize() + ) { + items(uiState.courseListItems) { + CourseListItem(it, uiState.studentColor, actionHandler) + } + } + + PullRefreshIndicator( + refreshing = uiState.isLoading, + state = pullRefreshState, + modifier = Modifier + .align(Alignment.TopCenter) + .testTag("pullRefreshIndicator"), + contentColor = Color(uiState.studentColor) + ) + } +} + +@Composable +private fun CourseListItem( + uiState: CourseListItemUiState, + studentColor: Int, + actionHandler: (CoursesAction) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .testTag("courseListItem") + .clickable { + actionHandler(CoursesAction.CourseTapped(uiState.courseId)) + } + ) { + Text( + text = uiState.courseName, + color = colorResource(id = R.color.textDarkest), + fontSize = 16.sp + ) + uiState.courseCode?.let { + Text( + text = uiState.courseCode, + color = colorResource(id = R.color.textDark), + fontSize = 14.sp + ) + } + uiState.grade?.let { + Text( + text = uiState.grade, + color = Color(studentColor), + fontSize = 16.sp, + modifier = Modifier.testTag("gradeText") + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CourseListPreview() { + CoursesScreen( + uiState = CoursesUiState( + studentColor = android.graphics.Color.RED, + courseListItems = listOf( + CourseListItemUiState(1L, "Course 1", "course-1", "A"), + CourseListItemUiState(2L, "Course 2", "course-2", ""), + CourseListItemUiState(3L, "Course 3", "", "C") + ) + ), + actionHandler = {} + ) +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesUiState.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesUiState.kt new file mode 100644 index 0000000000..491e0e59bf --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesUiState.kt @@ -0,0 +1,30 @@ +package com.instructure.parentapp.features.courses.list + +import android.graphics.Color +import androidx.annotation.ColorInt + + +data class CoursesUiState( + val isLoading: Boolean = true, + val isError: Boolean = false, + val courseListItems: List = emptyList(), + @ColorInt val studentColor: Int = Color.BLACK +) { + val isEmpty = courseListItems.isEmpty() && !isLoading +} + +data class CourseListItemUiState( + val courseId: Long, + val courseName: String = "", + val courseCode: String? = null, + val grade: String? = null +) + +sealed class CoursesAction { + data class CourseTapped(val courseId: Long) : CoursesAction() + data object Refresh : CoursesAction() +} + +sealed class CoursesViewModelAction { + data class NavigateToCourseDetails(val navigationUrl: String) : CoursesViewModelAction() +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesViewModel.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesViewModel.kt new file mode 100644 index 0000000000..8dce7ec86b --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/courses/list/CoursesViewModel.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.courses.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.weave.catch +import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.pandautils.utils.ColorKeeper +import com.instructure.parentapp.features.main.SelectedStudentHolder +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class CoursesViewModel @Inject constructor( + private val repository: CoursesRepository, + private val colorKeeper: ColorKeeper, + private val apiPrefs: ApiPrefs, + private val selectedStudentHolder: SelectedStudentHolder, + private val courseGradeFormatter: CourseGradeFormatter +) : ViewModel() { + + private val _uiState = MutableStateFlow(CoursesUiState()) + val uiState = _uiState.asStateFlow() + + private val _events = Channel() + val events = _events.receiveAsFlow() + + private var selectedStudent: User? = null + + init { + viewModelScope.launch { + selectedStudentHolder.selectedStudentFlow.collect { + studentChanged(it) + } + } + } + + private fun loadCourses(forceRefresh: Boolean = false) { + viewModelScope.tryLaunch { + val color = colorKeeper.getOrGenerateUserColor(selectedStudent).textAndIconColor() + + _uiState.update { + it.copy( + isLoading = true, + isError = false, + studentColor = color + ) + } + + selectedStudent?.id?.let { + val courses = repository.getCourses(it, forceRefresh) + _uiState.update { state -> + state.copy( + isLoading = false, + courseListItems = courses.map { course -> + CourseListItemUiState( + course.id, + course.name, + course.courseCode, + courseGradeFormatter.getGradeText(course, it) + ) + } + ) + } + } ?: run { + setErrorState() + } + } catch { + setErrorState() + } + } + + private fun setErrorState() { + _uiState.update { + it.copy( + isLoading = false, + isError = true + ) + } + } + + private fun studentChanged(student: User?) { + if (selectedStudent != student) { + selectedStudent = student + loadCourses() + } + } + + fun handleAction(action: CoursesAction) { + when (action) { + is CoursesAction.CourseTapped -> { + viewModelScope.launch { + _events.send( + CoursesViewModelAction.NavigateToCourseDetails( + "${apiPrefs.fullDomain}/courses/${action.courseId}" + ) + ) + } + } + + is CoursesAction.Refresh -> { + loadCourses(true) + } + } + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/LoginActivity.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/LoginActivity.kt index 07e0f2a314..95dfe40745 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/LoginActivity.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/LoginActivity.kt @@ -22,12 +22,16 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.core.content.ContextCompat +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.loginapi.login.activities.BaseLoginInitActivity import com.instructure.loginapi.login.util.QRLogin import com.instructure.pandautils.utils.AppType import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.Utils import com.instructure.parentapp.R import com.instructure.parentapp.features.login.routevalidator.RouteValidatorActivity +import com.instructure.parentapp.features.main.MainActivity import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -52,6 +56,20 @@ class LoginActivity : BaseLoginInitActivity() { override fun userAgent() = Const.PARENT_USER_AGENT + /** + * ONLY USE FOR UI TESTING + * Skips the traditional login process by directly setting the domain, token, and user info. + */ + fun loginWithToken(token: String, domain: String, user: User) { + ApiPrefs.accessToken = token + ApiPrefs.domain = domain + ApiPrefs.user = user + ApiPrefs.userAgent = Utils.generateUserAgent(this, userAgent()) + val intent = MainActivity.createIntent(this, Uri.EMPTY) + finish() + startActivity(intent) + } + companion object { fun createIntent(context: Context): Intent { val intent = Intent(context, LoginActivity::class.java) diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt index 21cc5b8810..8fe7a1587b 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/login/routevalidator/RouteValidatorActivity.kt @@ -27,14 +27,12 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.instructure.canvasapi2.models.AccountDomain import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.utils.collectOneOffEvents import com.instructure.parentapp.databinding.ActivityRouteValidatorBinding import com.instructure.parentapp.features.login.LoginActivity import com.instructure.parentapp.features.login.SignInActivity import com.instructure.parentapp.features.main.MainActivity import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @AndroidEntryPoint @@ -48,13 +46,7 @@ class RouteValidatorActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(binding.root) - lifecycleScope.launch { - withContext(Dispatchers.Main.immediate) { - viewModel.events.collect { - handleAction(it) - } - } - } + lifecycleScope.collectOneOffEvents(viewModel.events, ::handleAction) viewModel.loadRoute(intent.dataString) } diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt index 5bfbb22169..2187a8eff1 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainActivity.kt @@ -21,21 +21,31 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle +import android.widget.Toast +import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.navigateUp import androidx.navigation.ui.setupWithNavController -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.Pronouns +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.LocaleUtils import com.instructure.loginapi.login.tasks.LogoutTask import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.interfaces.NavigationCallbacks +import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const -import com.instructure.pandautils.utils.ProfileUtils +import com.instructure.pandautils.utils.ViewStyler +import com.instructure.pandautils.utils.animateCircularBackgroundColorChange +import com.instructure.pandautils.utils.applyTheme +import com.instructure.pandautils.utils.collapse +import com.instructure.pandautils.utils.collectOneOffEvents +import com.instructure.pandautils.utils.expand import com.instructure.pandautils.utils.hide import com.instructure.pandautils.utils.show import com.instructure.parentapp.R @@ -43,27 +53,48 @@ import com.instructure.parentapp.databinding.ActivityMainBinding import com.instructure.parentapp.databinding.NavigationDrawerHeaderLayoutBinding import com.instructure.parentapp.util.ParentLogoutTask import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : AppCompatActivity() { private val binding by viewBinding(ActivityMainBinding::inflate) + private val viewModel by viewModels() + private lateinit var navController: NavController private lateinit var appBarConfiguration: AppBarConfiguration + private lateinit var headerLayoutBinding: NavigationDrawerHeaderLayoutBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + binding.lifecycleOwner = this + binding.viewModel = viewModel setContentView(binding.root) setupNavigation() handleDeeplink() + + lifecycleScope.collectOneOffEvents(viewModel.events, ::handleAction) + + lifecycleScope.launch { + viewModel.data.collectLatest { + setupNavigationDrawerHeader(it.userViewData) + setupAppColors(it.selectedStudent) + } + } } override fun onSupportNavigateUp(): Boolean { return navController.navigateUp(appBarConfiguration) } + private fun handleAction(action: MainAction) = when (action) { + is MainAction.ShowToast -> Toast.makeText(this, action.message, Toast.LENGTH_LONG).show() + is MainAction.LocaleChanged -> LocaleUtils.restartApp(this) + } + private fun setupNavigation() { val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment navController = navHostFragment.navController @@ -71,6 +102,13 @@ class MainActivity : AppCompatActivity() { val drawerLayout = binding.drawerLayout appBarConfiguration = AppBarConfiguration(setOf(R.id.courses, R.id.calendar, R.id.alerts), drawerLayout) + val toolbar = binding.toolbar + toolbar.setNavigationIcon(R.drawable.ic_hamburger) + toolbar.navigationContentDescription = getString(R.string.navigation_drawer_open) + toolbar.setNavigationOnClickListener { + openNavigationDrawer() + } + val navView = binding.navView navView.setNavigationItemSelectedListener { closeNavigationDrawer() @@ -91,8 +129,7 @@ class MainActivity : AppCompatActivity() { } } } - val header = NavigationDrawerHeaderLayoutBinding.bind(navView.getHeaderView(0)) - setupNavigationDrawerHeader(header) + headerLayoutBinding = NavigationDrawerHeaderLayoutBinding.bind(navView.getHeaderView(0)) val bottomNavigationView = binding.bottomNav bottomNavigationView.setupWithNavController(navController) @@ -100,12 +137,36 @@ class MainActivity : AppCompatActivity() { // Hide bottom nav on screens which don't require it navController.addOnDestinationChangedListener { _, destination, _ -> when (destination.id) { - R.id.coursesFragment, R.id.calendarFragment, R.id.alertsFragment, R.id.help -> binding.bottomNav.show() - else -> binding.bottomNav.hide() + R.id.coursesFragment, R.id.calendarFragment, R.id.alertsFragment, R.id.help -> showMainNavigation() + else -> hideMainNavigation() } } } + private fun showMainNavigation() { + binding.bottomNav.show() + binding.toolbar.expand(200L, 100L, true) + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + } + + private fun hideMainNavigation() { + binding.bottomNav.hide() + binding.toolbar.collapse(200L, 100L, true) + viewModel.closeStudentSelector() + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + } + + private fun setupAppColors(student: User?) { + val color = ColorKeeper.getOrGenerateUserColor(student).backgroundColor() + if (binding.toolbar.background == null) { + binding.toolbar.setBackgroundColor(color) + } else { + binding.toolbar.animateCircularBackgroundColorChange(color, binding.toolbarImage) + } + binding.bottomNav.applyTheme(color, getColor(R.color.textDarkest)) + ViewStyler.setStatusBarDark(this, color) + } + private fun openNavigationDrawer() { binding.drawerLayout.openDrawer(GravityCompat.START) } @@ -137,12 +198,8 @@ class MainActivity : AppCompatActivity() { ParentLogoutTask(LogoutTask.Type.SWITCH_USERS).execute() } - private fun setupNavigationDrawerHeader(header: NavigationDrawerHeaderLayoutBinding) { - ApiPrefs.user?.let { - header.navHeaderName.text = Pronouns.span(it.shortName, it.pronouns) - header.navHeaderEmail.text = it.primaryEmail - ProfileUtils.loadAvatarForUser(header.navHeaderImage, it.shortName, it.avatarUrl) - } + private fun setupNavigationDrawerHeader(userViewData: UserViewData?) { + headerLayoutBinding.userViewData = userViewData } override fun onBackPressed() { diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainRepository.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainRepository.kt new file mode 100644 index 0000000000..45916470d2 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainRepository.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.main + +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.apis.ThemeAPI +import com.instructure.canvasapi2.apis.UserAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasColor +import com.instructure.canvasapi2.models.CanvasTheme +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.depaginate + + +class MainRepository( + private val enrollmentApi: EnrollmentAPI.EnrollmentInterface, + private val userApi: UserAPI.UsersInterface, + private val themeApi: ThemeAPI.ThemeInterface +) { + + suspend fun getStudents(): List { + val params = RestParams(usePerPageQueryParam = true) + return enrollmentApi.firstPageObserveeEnrollmentsParent(params).depaginate { + enrollmentApi.getNextPage(it, params) + }.dataOrNull + .orEmpty() + .mapNotNull { it.observedUser } + .distinct() + .sortedBy { it.sortableName } + } + + suspend fun getSelf(): User? { + val params = RestParams(isForceReadFromNetwork = true) + return userApi.getSelf(params).dataOrNull + } + + suspend fun getColors(): CanvasColor? { + val params = RestParams(isForceReadFromNetwork = true) + return userApi.getColors(params).dataOrNull + } + + suspend fun getTheme(): CanvasTheme? { + val params = RestParams(isForceReadFromNetwork = true) + return themeApi.getTheme(params).dataOrNull + } +} \ No newline at end of file diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainViewData.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainViewData.kt new file mode 100644 index 0000000000..3b66e40718 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainViewData.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.main + +import com.instructure.canvasapi2.models.User + + +data class MainViewData( + val userViewData: UserViewData? = null, + val studentSelectorExpanded: Boolean = false, + val studentItems: List = emptyList(), + val selectedStudent: User? = null +) + +sealed class MainAction { + data class ShowToast(val message: String) : MainAction() + data object LocaleChanged : MainAction() +} + +data class StudentItemViewData( + val studentId: Long, + val studentName: String, + val avatarUrl: String +) + +data class UserViewData( + val name: String?, + val pronouns: String?, + val shortName: String?, + val avatarUrl: String?, + val email: String? +) diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainViewModel.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainViewModel.kt new file mode 100644 index 0000000000..7dc2550f9f --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/main/MainViewModel.kt @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.main + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.weave.catch +import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.loginapi.login.util.PreviousUsersUtils +import com.instructure.pandautils.mvvm.ViewState +import com.instructure.pandautils.utils.ColorKeeper +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.orDefault +import com.instructure.parentapp.R +import com.instructure.parentapp.util.ParentPrefs +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class MainViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: MainRepository, + private val previousUsersUtils: PreviousUsersUtils, + private val apiPrefs: ApiPrefs, + private val parentPrefs: ParentPrefs, + private val colorKeeper: ColorKeeper, + private val themePrefs: ThemePrefs, + private val selectedStudentHolder: SelectedStudentHolder +) : ViewModel() { + + private val _events = Channel() + val events = _events.receiveAsFlow() + + private val _data = MutableStateFlow(MainViewData()) + val data = _data.asStateFlow() + + private val _state = MutableStateFlow(ViewState.Loading) + val state = _state.asStateFlow() + + private val currentUser = previousUsersUtils.getSignedInUser(context, apiPrefs.domain, apiPrefs.user?.id.orDefault()) + + init { + loadInitialData() + } + + private fun loadInitialData() { + viewModelScope.tryLaunch { + _state.value = ViewState.Loading + + val user = repository.getSelf() + user?.let { saveUserInfo(it) } + + val colors = repository.getColors() + colors?.let { colorKeeper.addToCache(it) } + + val theme = repository.getTheme() + theme?.let { themePrefs.applyCanvasTheme(it, context) } + + loadUserInfo() + + loadStudents() + + if (_data.value.studentItems.isEmpty()) { + _state.value = ViewState.Empty( + R.string.noStudentsError, + R.string.noStudentsErrorDescription, + R.drawable.panda_manage_students + ) + } else { + _state.value = ViewState.Success + } + } catch { + viewModelScope.launch { + _state.value = ViewState.Error(context.getString(R.string.errorOccurred)) + } + } + } + + private suspend fun saveUserInfo(user: User) { + val oldLocale = apiPrefs.effectiveLocale + apiPrefs.user = user + if (apiPrefs.effectiveLocale != oldLocale) { + _events.send(MainAction.LocaleChanged) + } + } + + private fun loadUserInfo() { + apiPrefs.user?.let { user -> + _data.update { + it.copy( + userViewData = UserViewData( + user.name, + user.pronouns, + user.shortName, + user.avatarUrl, + user.primaryEmail + ) + ) + } + } + } + + private suspend fun loadStudents() { + val students = repository.getStudents() + val selectedStudent = students.find { it.id == currentUser?.selectedStudentId } ?: students.firstOrNull() + parentPrefs.currentStudent = selectedStudent + selectedStudent?.let { + selectedStudentHolder.updateSelectedStudent(it) + } + + _data.update { data -> + data.copy( + studentItems = students.map { user -> + StudentItemViewModel( + StudentItemViewData( + user.id, + user.shortName.orEmpty(), + user.avatarUrl.orEmpty() + ) + ) { userId -> + onStudentSelected(students.first { it.id == userId }) + } + }, + selectedStudent = selectedStudent + ) + } + } + + private fun onStudentSelected(student: User) { + parentPrefs.currentStudent = student + currentUser?.let { + previousUsersUtils.add(context, it.copy(selectedStudentId = student.id)) + } + viewModelScope.launch { + selectedStudentHolder.updateSelectedStudent(student) + } + _data.update { + it.copy( + studentSelectorExpanded = false, + selectedStudent = student + ) + } + } + + fun toggleStudentSelector() { + _data.update { + it.copy(studentSelectorExpanded = !it.studentSelectorExpanded) + } + } + + fun closeStudentSelector() { + _data.update { + it.copy(studentSelectorExpanded = false) + } + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/main/SelectedStudentHolder.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/main/SelectedStudentHolder.kt new file mode 100644 index 0000000000..3c36032a07 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/main/SelectedStudentHolder.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.main + + +import com.instructure.canvasapi2.models.User +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +interface SelectedStudentHolder { + val selectedStudentFlow: SharedFlow + suspend fun updateSelectedStudent(user: User) +} + +class SelectedStudentHolderImpl : SelectedStudentHolder { + private val _selectedStudentFlow = MutableSharedFlow() + override val selectedStudentFlow = _selectedStudentFlow.asSharedFlow() + + override suspend fun updateSelectedStudent(user: User) { + _selectedStudentFlow.emit(user) + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/features/main/StudentItemViewModel.kt b/apps/parent/src/main/java/com/instructure/parentapp/features/main/StudentItemViewModel.kt new file mode 100644 index 0000000000..844e5a5b67 --- /dev/null +++ b/apps/parent/src/main/java/com/instructure/parentapp/features/main/StudentItemViewModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.instructure.parentapp.features.main + +import com.instructure.pandautils.mvvm.ItemViewModel +import com.instructure.parentapp.R + + +data class StudentItemViewModel( + val studentItemViewData: StudentItemViewData, + private val onStudentSelected: (Long) -> Unit +) : ItemViewModel { + + override val layoutId = R.layout.item_student + + fun onStudentClick() { + onStudentSelected(studentItemViewData.studentId) + } +} diff --git a/apps/parent/src/main/java/com/instructure/parentapp/util/BaseAppManager.kt b/apps/parent/src/main/java/com/instructure/parentapp/util/BaseAppManager.kt index cd54238566..58d284cfe6 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/util/BaseAppManager.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/util/BaseAppManager.kt @@ -13,7 +13,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . * - */package com.instructure.parentapp.util + */ + +package com.instructure.parentapp.util import android.os.Build import android.webkit.WebView @@ -38,7 +40,7 @@ abstract class BaseAppManager : AppManager() { val appTheme = AppTheme.fromIndex(ThemePrefs.appTheme) AppCompatDelegate.setDefaultNightMode(appTheme.nightModeType) - Analytics.setUserProperty(AnalyticsEventConstants.USER_PROPERTY_BUILD_TYPE, if(BuildConfig.DEBUG) "debug" else "release") + Analytics.setUserProperty(AnalyticsEventConstants.USER_PROPERTY_BUILD_TYPE, if (BuildConfig.DEBUG) "debug" else "release") Analytics.setUserProperty(AnalyticsEventConstants.USER_PROPERTY_OS_VERSION, Build.VERSION.SDK_INT.toString()) RemoteConfigUtils.initialize() @@ -56,6 +58,7 @@ abstract class BaseAppManager : AppManager() { } catch (e: Exception) { FirebaseCrashlytics.getInstance().log("Exception trying to setWebContentsDebuggingEnabled") } - } + + override fun performLogoutOnAuthError() = Unit } \ No newline at end of file diff --git a/apps/parent/src/main/java/com/instructure/parentapp/util/ParentPrefs.kt b/apps/parent/src/main/java/com/instructure/parentapp/util/ParentPrefs.kt index 3efb01da52..11e847620a 100644 --- a/apps/parent/src/main/java/com/instructure/parentapp/util/ParentPrefs.kt +++ b/apps/parent/src/main/java/com/instructure/parentapp/util/ParentPrefs.kt @@ -17,9 +17,13 @@ package com.instructure.parentapp.util +import com.instructure.canvasapi2.models.User +import com.instructure.canvasapi2.utils.GsonPref import com.instructure.canvasapi2.utils.PrefManager object ParentPrefs : PrefManager("parentSP") { + var currentStudent: User? by GsonPref(User::class.java, null, "current_student", false) + } \ No newline at end of file diff --git a/apps/parent/src/main/res/layout/activity_main.xml b/apps/parent/src/main/res/layout/activity_main.xml index dfc60ea0c0..5ead5ccf49 100644 --- a/apps/parent/src/main/res/layout/activity_main.xml +++ b/apps/parent/src/main/res/layout/activity_main.xml @@ -13,45 +13,164 @@ ~ You should have received a copy of the GNU General Public License ~ along with this program. If not, see . --> - + xmlns:tools="http://schemas.android.com/tools"> - + + + + + + + + android:fitsSystemWindows="true"> - + android:layout_height="match_parent" + android:orientation="vertical" + tools:context=".MainActivity"> - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + diff --git a/apps/parent/src/main/res/layout/fragment_courses.xml b/apps/parent/src/main/res/layout/fragment_courses.xml deleted file mode 100644 index 659a01ab22..0000000000 --- a/apps/parent/src/main/res/layout/fragment_courses.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - -