diff --git a/app/schemas/org.openedx.app.room.AppDatabase/3.json b/app/schemas/org.openedx.app.room.AppDatabase/3.json new file mode 100644 index 000000000..0b47d8504 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/3.json @@ -0,0 +1,1198 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "bcf7a22441e12e4c8b6fb332754827bf", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcf7a22441e12e4c8b6fb332754827bf')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 464007259..7f016ace9 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -23,6 +23,7 @@ import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel import org.openedx.course.presentation.offline.CourseOfflineViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel +import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel import org.openedx.course.presentation.unit.html.HtmlUnitViewModel @@ -495,6 +496,13 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String) -> + CourseProgressViewModel( + courseId, + get(), + get() + ) + } single { DownloadRepository( diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index bfdcee43f..b5dfde4da 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -7,6 +7,7 @@ import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress @@ -21,7 +22,7 @@ import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 2 +const val DATABASE_VERSION = 3 const val DATABASE_NAME = "OpenEdX_db" @Database( @@ -34,10 +35,12 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarEventEntity::class, CourseCalendarStateEntity::class, DownloadCoursePreview::class, - CourseEnrollmentDetailsEntity::class + CourseEnrollmentDetailsEntity::class, + CourseProgressEntity::class, ], autoMigrations = [ - AutoMigration(1, DATABASE_VERSION) + AutoMigration(1, 2), + AutoMigration(2, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index d24eb54f9..0dd6ce937 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -17,8 +17,7 @@ class DatabaseManager( ) : DatabaseManager { override fun clearTables() { CoroutineScope(Dispatchers.IO).launch { - courseDao.clearCachedData() - courseDao.clearEnrollmentCachedData() + courseDao.clearCourseData() dashboardDao.clearCachedData() downloadDao.clearOfflineProgress() discoveryDao.clearCachedData() diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt index 88e8ad94b..1b9dcafab 100644 --- a/core/src/main/java/org/openedx/core/NoContentScreenType.kt +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -27,5 +27,9 @@ enum class NoContentScreenType( COURSE_ANNOUNCEMENTS( iconResId = R.drawable.core_ic_no_announcements, messageResId = R.string.core_no_announcements - ) + ), + COURSE_PROGRESS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_progress + ), } diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 50cd81d6b..d4fade6e2 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -8,6 +8,7 @@ import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseProgressResponse import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus @@ -106,4 +107,9 @@ interface CourseApi { suspend fun getDownloadCoursesPreview( @Path("username") username: String ): List + + @GET("/api/course_home/progress/{course_id}") + suspend fun getCourseProgress( + @Path("course_id") courseId: String, + ): CourseProgressResponse } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt new file mode 100644 index 000000000..bf31419e6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -0,0 +1,283 @@ +package org.openedx.core.data.model + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.CertificateDataDb +import org.openedx.core.data.model.room.CompletionSummaryDb +import org.openedx.core.data.model.room.CourseGradeDb +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb +import org.openedx.core.data.model.room.VerificationDataDb +import org.openedx.core.domain.model.CourseProgress + +data class CourseProgressResponse( + @SerializedName("verified_mode") val verifiedMode: String?, + @SerializedName("access_expiration") val accessExpiration: String?, + @SerializedName("certificate_data") val certificateData: CertificateData?, + @SerializedName("completion_summary") val completionSummary: CompletionSummary?, + @SerializedName("course_grade") val courseGrade: CourseGrade?, + @SerializedName("credit_course_requirements") val creditCourseRequirements: String?, + @SerializedName("end") val end: String?, + @SerializedName("enrollment_mode") val enrollmentMode: String?, + @SerializedName("grading_policy") val gradingPolicy: GradingPolicy?, + @SerializedName("has_scheduled_content") val hasScheduledContent: Boolean?, + @SerializedName("section_scores") val sectionScores: List?, + @SerializedName("studio_url") val studioUrl: String?, + @SerializedName("username") val username: String?, + @SerializedName("user_has_passing_grade") val userHasPassingGrade: Boolean?, + @SerializedName("verification_data") val verificationData: VerificationData?, + @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean?, +) { + data class CertificateData( + @SerializedName("cert_status") val certStatus: String?, + @SerializedName("cert_web_view_url") val certWebViewUrl: String?, + @SerializedName("download_url") val downloadUrl: String?, + @SerializedName("certificate_available_date") val certificateAvailableDate: String? + ) { + fun mapToRoomEntity() = CertificateDataDb( + certStatus = certStatus.orEmpty(), + certWebViewUrl = certWebViewUrl.orEmpty(), + downloadUrl = downloadUrl.orEmpty(), + certificateAvailableDate = certificateAvailableDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus ?: "", + certWebViewUrl = certWebViewUrl ?: "", + downloadUrl = downloadUrl ?: "", + certificateAvailableDate = certificateAvailableDate ?: "" + ) + } + + data class CompletionSummary( + @SerializedName("complete_count") val completeCount: Int?, + @SerializedName("incomplete_count") val incompleteCount: Int?, + @SerializedName("locked_count") val lockedCount: Int? + ) { + fun mapToRoomEntity() = CompletionSummaryDb( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + } + + data class CourseGrade( + @SerializedName("letter_grade") val letterGrade: String?, + @SerializedName("percent") val percent: Double?, + @SerializedName("is_passing") val isPassing: Boolean? + ) { + fun mapToRoomEntity() = CourseGradeDb( + letterGrade = letterGrade.orEmpty(), + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade ?: "", + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + } + + data class GradingPolicy( + @SerializedName("assignment_policies") val assignmentPolicies: List?, + @SerializedName("grade_range") val gradeRange: Map?, + @SerializedName("assignment_colors") val assignmentColors: List? + ) { + // TODO Temporary solution. Backend will returns color list later + val defaultColors = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) + + fun mapToRoomEntity() = GradingPolicyDb( + assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors ?: defaultColors + ) + + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies?.map { it.mapToDomain() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors?.map { colorString -> + Color(colorString.toColorInt()) + } ?: defaultColors.map { Color(it.toColorInt()) } + ) + + data class AssignmentPolicy( + @SerializedName("num_droppable") val numDroppable: Int?, + @SerializedName("num_total") val numTotal: Int?, + @SerializedName("short_label") val shortLabel: String?, + @SerializedName("type") val type: String?, + @SerializedName("weight") val weight: Double? + ) { + fun mapToRoomEntity() = GradingPolicyDb.AssignmentPolicyDb( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel.orEmpty(), + type = type.orEmpty(), + weight = weight ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel ?: "", + type = type ?: "", + weight = weight ?: 0.0 + ) + } + } + + data class SectionScore( + @SerializedName("display_name") val displayName: String?, + @SerializedName("subsections") val subsections: List? + ) { + fun mapToRoomEntity() = SectionScoreDb( + displayName = displayName.orEmpty(), + subsections = subsections?.map { it.mapToRoomEntity() } ?: emptyList() + ) + + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName ?: "", + subsections = subsections?.map { it.mapToDomain() } ?: emptyList() + ) + data class Subsection( + @SerializedName("assignment_type") val assignmentType: String?, + @SerializedName("block_key") val blockKey: String?, + @SerializedName("display_name") val displayName: String?, + @SerializedName("has_graded_assignment") val hasGradedAssignment: Boolean?, + @SerializedName("override") val override: String?, + @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, + @SerializedName("num_points_earned") val numPointsEarned: Float?, + @SerializedName("num_points_possible") val numPointsPossible: Float?, + @SerializedName("percent_graded") val percentGraded: Double?, + @SerializedName("problem_scores") val problemScores: List?, + @SerializedName("show_correctness") val showCorrectness: String?, + @SerializedName("show_grades") val showGrades: Boolean?, + @SerializedName("url") val url: String? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb( + assignmentType = assignmentType.orEmpty(), + blockKey = blockKey.orEmpty(), + displayName = displayName.orEmpty(), + hasGradedAssignment = hasGradedAssignment ?: false, + override = override.orEmpty(), + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToRoomEntity() } ?: emptyList(), + showCorrectness = showCorrectness.orEmpty(), + showGrades = showGrades ?: false, + url = url.orEmpty() + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType ?: "", + blockKey = blockKey ?: "", + displayName = displayName ?: "", + hasGradedAssignment = hasGradedAssignment ?: false, + override = override ?: "", + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToDomain() } ?: emptyList(), + showCorrectness = showCorrectness ?: "", + showGrades = showGrades ?: false, + url = url ?: "" + ) + data class ProblemScore( + @SerializedName("earned") val earned: Double?, + @SerializedName("possible") val possible: Double? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb.ProblemScoreDb( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + } + } + } + + data class VerificationData( + @SerializedName("link") val link: String?, + @SerializedName("status") val status: String?, + @SerializedName("status_date") val statusDate: String? + ) { + fun mapToRoomEntity() = VerificationDataDb( + link = link.orEmpty(), + status = status.orEmpty(), + statusDate = statusDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.VerificationData( + link = link ?: "", + status = status ?: "", + statusDate = statusDate ?: "" + ) + } + + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode ?: "", + accessExpiration = accessExpiration ?: "", + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements ?: "", + end = end ?: "", + enrollmentMode = enrollmentMode ?: "", + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToDomain() } ?: emptyList(), + studioUrl = studioUrl ?: "", + username = username ?: "", + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } + + fun mapToRoomEntity(courseId: String): CourseProgressEntity { + return CourseProgressEntity( + courseId = courseId, + verifiedMode = verifiedMode.orEmpty(), + accessExpiration = accessExpiration.orEmpty(), + certificateData = certificateData?.mapToRoomEntity(), + completionSummary = completionSummary?.mapToRoomEntity(), + courseGrade = courseGrade?.mapToRoomEntity(), + creditCourseRequirements = creditCourseRequirements.orEmpty(), + end = end.orEmpty(), + enrollmentMode = enrollmentMode.orEmpty(), + gradingPolicy = gradingPolicy?.mapToRoomEntity(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToRoomEntity() } ?: emptyList(), + studioUrl = studioUrl.orEmpty(), + username = username.orEmpty(), + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToRoomEntity(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt new file mode 100644 index 000000000..6c98cbed2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -0,0 +1,236 @@ +package org.openedx.core.data.model.room + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseProgress + +@Entity(tableName = "course_progress_table") +data class CourseProgressEntity( + @PrimaryKey + @ColumnInfo("courseId") + val courseId: String, + @ColumnInfo("verifiedMode") + val verifiedMode: String, + @ColumnInfo("accessExpiration") + val accessExpiration: String, + @Embedded(prefix = "certificate_") + val certificateData: CertificateDataDb?, + @Embedded(prefix = "completion_") + val completionSummary: CompletionSummaryDb?, + @Embedded(prefix = "grade_") + val courseGrade: CourseGradeDb?, + @ColumnInfo("creditCourseRequirements") + val creditCourseRequirements: String, + @ColumnInfo("end") + val end: String, + @ColumnInfo("enrollmentMode") + val enrollmentMode: String, + @Embedded(prefix = "grading_") + val gradingPolicy: GradingPolicyDb?, + @ColumnInfo("hasScheduledContent") + val hasScheduledContent: Boolean, + @ColumnInfo("sectionScores") + val sectionScores: List, + @ColumnInfo("studioUrl") + val studioUrl: String, + @ColumnInfo("username") + val username: String, + @ColumnInfo("userHasPassingGrade") + val userHasPassingGrade: Boolean, + @Embedded(prefix = "verification_") + val verificationData: VerificationDataDb?, + @ColumnInfo("disableProgressGraph") + val disableProgressGraph: Boolean, +) { + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode, + accessExpiration = accessExpiration, + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements, + end = end, + enrollmentMode = enrollmentMode, + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent, + sectionScores = sectionScores.map { it.mapToDomain() }, + studioUrl = studioUrl, + username = username, + userHasPassingGrade = userHasPassingGrade, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph, + ) + } +} + +data class CertificateDataDb( + @ColumnInfo("certStatus") + val certStatus: String, + @ColumnInfo("certWebViewUrl") + val certWebViewUrl: String, + @ColumnInfo("downloadUrl") + val downloadUrl: String, + @ColumnInfo("certificateAvailableDate") + val certificateAvailableDate: String +) { + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus, + certWebViewUrl = certWebViewUrl, + downloadUrl = downloadUrl, + certificateAvailableDate = certificateAvailableDate + ) +} + +data class CompletionSummaryDb( + @ColumnInfo("completeCount") + val completeCount: Int, + @ColumnInfo("incompleteCount") + val incompleteCount: Int, + @ColumnInfo("lockedCount") + val lockedCount: Int +) { + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount, + incompleteCount = incompleteCount, + lockedCount = lockedCount + ) +} + +data class CourseGradeDb( + @ColumnInfo("letterGrade") + val letterGrade: String, + @ColumnInfo("percent") + val percent: Double, + @ColumnInfo("isPassing") + val isPassing: Boolean +) { + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade, + percent = percent, + isPassing = isPassing + ) +} + +data class GradingPolicyDb( + @ColumnInfo("assignmentPolicies") + val assignmentPolicies: List, + @ColumnInfo("gradeRange") + val gradeRange: Map, + @ColumnInfo("assignmentColors") + val assignmentColors: List +) { + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies.map { it.mapToDomain() }, + gradeRange = gradeRange, + assignmentColors = assignmentColors.map { colorString -> + Color(colorString.toColorInt()) + } + ) + data class AssignmentPolicyDb( + @ColumnInfo("numDroppable") + val numDroppable: Int, + @ColumnInfo("numTotal") + val numTotal: Int, + @ColumnInfo("shortLabel") + val shortLabel: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("weight") + val weight: Double + ) { + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable, + numTotal = numTotal, + shortLabel = shortLabel, + type = type, + weight = weight + ) + } +} + +data class SectionScoreDb( + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("subsections") + val subsections: List +) { + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName, + subsections = subsections.map { it.mapToDomain() } + ) + data class SubsectionDb( + @ColumnInfo("assignmentType") + val assignmentType: String, + @ColumnInfo("blockKey") + val blockKey: String, + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("hasGradedAssignment") + val hasGradedAssignment: Boolean, + @ColumnInfo("override") + val override: String, + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean, + @ColumnInfo("numPointsEarned") + val numPointsEarned: Float, + @ColumnInfo("numPointsPossible") + val numPointsPossible: Float, + @ColumnInfo("percentGraded") + val percentGraded: Double, + @ColumnInfo("problemScores") + val problemScores: List, + @ColumnInfo("showCorrectness") + val showCorrectness: String, + @ColumnInfo("showGrades") + val showGrades: Boolean, + @ColumnInfo("url") + val url: String + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType, + blockKey = blockKey, + displayName = displayName, + hasGradedAssignment = hasGradedAssignment, + override = override, + learnerHasAccess = learnerHasAccess, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible, + percentGraded = percentGraded, + problemScores = problemScores.map { it.mapToDomain() }, + showCorrectness = showCorrectness, + showGrades = showGrades, + url = url + ) + data class ProblemScoreDb( + @ColumnInfo("earned") + val earned: Double, + @ColumnInfo("possible") + val possible: Double + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned, + possible = possible + ) + } + } +} + +data class VerificationDataDb( + @ColumnInfo("link") + val link: String, + @ColumnInfo("status") + val status: String, + @ColumnInfo("statusDate") + val statusDate: String +) { + fun mapToDomain() = CourseProgress.VerificationData( + link = link, + status = status, + statusDate = statusDate + ) +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt index 1ce813242..14ac6713a 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -4,7 +4,9 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity @Dao @@ -16,8 +18,21 @@ interface CourseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) + @Transaction + suspend fun clearCourseData() { + clearCourseStructureData() + clearCourseProgressData() + clearEnrollmentCachedData() + } + @Query("DELETE FROM course_structure_table") - suspend fun clearCachedData() + suspend fun clearCourseStructureData() + + @Query("DELETE FROM course_progress_table") + suspend fun clearCourseProgressData() + + @Query("DELETE FROM course_enrollment_details_table") + suspend fun clearEnrollmentCachedData() @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) @@ -25,6 +40,9 @@ interface CourseDao { @Query("SELECT * FROM course_enrollment_details_table WHERE id=:id") suspend fun getCourseEnrollmentDetailsById(id: String): CourseEnrollmentDetailsEntity? - @Query("DELETE FROM course_enrollment_details_table") - suspend fun clearEnrollmentCachedData() + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) + + @Query("SELECT * FROM course_progress_table WHERE courseId=:id") + suspend fun getCourseProgressById(id: String): CourseProgressEntity? } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt new file mode 100644 index 000000000..537959ece --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -0,0 +1,135 @@ +package org.openedx.core.domain.model + +import androidx.compose.ui.graphics.Color + +data class CourseProgress( + val verifiedMode: String, + val accessExpiration: String, + val certificateData: CertificateData?, + val completionSummary: CompletionSummary?, + val courseGrade: CourseGrade?, + val creditCourseRequirements: String, + val end: String, + val enrollmentMode: String, + val gradingPolicy: GradingPolicy?, + val hasScheduledContent: Boolean, + val sectionScores: List, + val studioUrl: String, + val username: String, + val userHasPassingGrade: Boolean, + val verificationData: VerificationData?, + val disableProgressGraph: Boolean, +) { + val completion = with(completionSummary) { + val total = (this?.completeCount ?: 0) + (this?.incompleteCount ?: 0) + if (total > 0f) (this?.completeCount ?: 0).toFloat() / total else 0f + } + val completionPercent = (completion * 100f).toInt() + val requiredGrade = gradingPolicy?.gradeRange?.values?.firstOrNull() ?: 0f + val requiredGradePercent = (requiredGrade * 100f).toInt() + + fun getEarnedAssignmentProblems( + policy: GradingPolicy.AssignmentPolicy + ) = sectionScores + .flatMap { section -> + section.subsections.filter { it.assignmentType == policy.type } + }.sumOf { subsection -> + subsection.problemScores.sumOf { it.earned } + } + + fun getPossibleAssignmentProblems( + policy: GradingPolicy.AssignmentPolicy + ) = sectionScores + .flatMap { section -> + section.subsections.filter { it.assignmentType == policy.type } + }.sumOf { subsection -> + subsection.problemScores.sumOf { it.possible } + } + + fun getAssignmentGradedPercent(type: String): Float { + val assignmentSections = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + if (assignmentSections.isEmpty()) return 0f + return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size + } + + fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { + return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100f).toFloat() + } + + fun getTotalWeightPercent() = + gradingPolicy?.assignmentPolicies?.sumOf { getAssignmentWeightedGradedPercent(it).toDouble() } + ?.toFloat() ?: 0f + + fun getNotCompletedWeightedGradePercent(): Float { + val totalWeightedPercent = getTotalWeightPercent() + val notCompletedPercent = 100.0 - totalWeightedPercent + return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() + } + + data class CertificateData( + val certStatus: String, + val certWebViewUrl: String, + val downloadUrl: String, + val certificateAvailableDate: String + ) + + data class CompletionSummary( + val completeCount: Int, + val incompleteCount: Int, + val lockedCount: Int + ) + + data class CourseGrade( + val letterGrade: String, + val percent: Double, + val isPassing: Boolean + ) + + data class GradingPolicy( + val assignmentPolicies: List, + val gradeRange: Map, + val assignmentColors: List, + ) { + data class AssignmentPolicy( + val numDroppable: Int, + val numTotal: Int, + val shortLabel: String, + val type: String, + val weight: Double + ) + } + + data class SectionScore( + val displayName: String, + val subsections: List + ) { + data class Subsection( + val assignmentType: String, + val blockKey: String, + val displayName: String, + val hasGradedAssignment: Boolean, + val override: String, + val learnerHasAccess: Boolean, + val numPointsEarned: Float, + val numPointsPossible: Float, + val percentGraded: Double, + val problemScores: List, + val showCorrectness: String, + val showGrades: Boolean, + val url: String + ) { + data class ProblemScore( + val earned: Double, + val possible: Double + ) + } + } + + data class VerificationData( + val link: String, + val status: String, + val statusDate: String + ) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index be653a3ed..d3dac7d42 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -22,4 +22,5 @@ class CourseNotifier { suspend fun send(event: CourseOpenBlock) = channel.emit(event) suspend fun send(event: RefreshDates) = channel.emit(event) suspend fun send(event: RefreshDiscussions) = channel.emit(event) + suspend fun send(event: RefreshProgress) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt new file mode 100644 index 000000000..c0835f787 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshProgress : CourseEvent diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 12da2cfce..143bfabf7 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -78,7 +78,8 @@ data class AppColors( val settingsTitleContent: Color, val progressBarColor: Color, - val progressBarBackgroundColor: Color + val progressBarBackgroundColor: Color, + val gradeProgressBarBorder: Color, ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 2ad2a4eae..c4f54ac17 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -96,7 +96,8 @@ private val DarkColorPalette = AppColors( settingsTitleContent = dark_settings_title_content, progressBarColor = dark_progress_bar_color, - progressBarBackgroundColor = dark_progress_bar_background_color + progressBarBackgroundColor = dark_progress_bar_background_color, + gradeProgressBarBorder = dark_grade_progress_bar_color ) private val LightColorPalette = AppColors( @@ -185,7 +186,8 @@ private val LightColorPalette = AppColors( settingsTitleContent = light_settings_title_content, progressBarColor = light_progress_bar_color, - progressBarBackgroundColor = light_progress_bar_background_color + progressBarBackgroundColor = light_progress_bar_background_color, + gradeProgressBarBorder = light_grade_progress_bar_color ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 99df5b3d4..ae731550f 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -172,6 +172,7 @@ No course content is currently available. There are currently no videos for this course. Course dates are currently not available. + This course does not contain exams or graded assignments. Unable to load discussions.\n Please try again later. There are currently no handouts for this course. There are currently no announcements for this course. diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index d2618e6b0..65c082f70 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -74,6 +74,7 @@ val light_course_home_back_btn_background = Color.White val light_settings_title_content = Color.White val light_progress_bar_color = light_primary val light_progress_bar_background_color = Color(0xFFCCD4E0) +val light_grade_progress_bar_color = Color.Black val dark_primary = Color(0xFF3F68F8) val dark_primary_variant = Color(0xFF3700B3) @@ -147,3 +148,4 @@ val dark_course_home_back_btn_background = Color.Black val dark_settings_title_content = Color.White val dark_progress_bar_color = light_primary val dark_progress_bar_background_color = Color(0xFF8E9BAE) +val dark_grade_progress_bar_color = Color.Transparent diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index bf39cc80c..914ce7191 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -14,6 +14,7 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.channelFlowWithAwait @@ -45,7 +46,10 @@ class CourseRepository( suspend fun getAllDownloadModels() = downloadDao.readAllData().map { it.mapToDomain() } - suspend fun getCourseStructureFlow(courseId: String, forceRefresh: Boolean = true): Flow = + suspend fun getCourseStructureFlow( + courseId: String, + forceRefresh: Boolean = true + ): Flow = channelFlowWithAwait { var hasCourseStructure = false val cachedCourseStructure = courseStructure[courseId] ?: ( @@ -235,4 +239,17 @@ class CourseRepository( downloadDao.removeOfflineXBlockProgress(listOf(blockId)) } } + + fun getCourseProgress(courseId: String, isRefresh: Boolean): Flow = + channelFlowWithAwait { + if (!isRefresh) { + val cached = courseDao.getCourseProgressById(courseId) + if (cached != null) { + trySend(cached.mapToDomain()) + } + } + val response = api.getCourseProgress(courseId) + courseDao.insertCourseProgressEntity(response.mapToRoomEntity(courseId)) + trySend(response.mapToDomain()) + } } diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 8daa7fb13..c59b69638 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -1,27 +1,16 @@ package org.openedx.course.data.storage import androidx.room.TypeConverter +import com.google.common.reflect.TypeToken import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb -import org.openedx.core.data.model.room.VideoInfoDb +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb import org.openedx.foundation.extension.genericType class CourseConverter { - @TypeConverter - fun fromVideoDb(value: VideoInfoDb?): String { - if (value == null) return "" - val json = Gson().toJson(value) - return json.toString() - } - - @TypeConverter - fun toVideoDb(value: String): VideoInfoDb? { - if (value.isEmpty()) return null - return Gson().fromJson(value, VideoInfoDb::class.java) - } - @TypeConverter fun fromListOfString(value: List): String { val json = Gson().toJson(value) @@ -46,18 +35,6 @@ class CourseConverter { return Gson().fromJson(value, type) } - @TypeConverter - fun fromStringToMap(value: String?): Map { - val mapType = genericType>() - return Gson().fromJson(value, mapType) - } - - @TypeConverter - fun fromMapToString(map: Map): String { - val gson = Gson() - return gson.toJson(map) - } - @TypeConverter fun fromListOfCourseDateBlockDb(value: List): String { val json = Gson().toJson(value) @@ -69,4 +46,31 @@ class CourseConverter { val type = genericType>() return Gson().fromJson(value, type) } + + @TypeConverter + fun fromSectionScoreDbList(value: List?): String = + Gson().toJson(value) + + @TypeConverter + fun toSectionScoreDbList(value: String): List = + Gson().fromJson(value, object : TypeToken>() {}.type) + + @TypeConverter + fun fromAssignmentPolicyDbList(value: List?): String = + Gson().toJson(value) + + @TypeConverter + fun toAssignmentPolicyDbList(value: String): List = + Gson().fromJson( + value, + object : TypeToken>() {}.type + ) + + @TypeConverter + fun fromGradeRangeMap(value: Map?): String = + Gson().toJson(value) + + @TypeConverter + fun toGradeRangeMap(value: String): Map = + Gson().fromJson(value, object : TypeToken>() {}.type) } diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 8fab7bba7..49fdf0d42 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -65,14 +65,19 @@ class CourseInteractor( return blocks.firstOrNull { it.descendants.contains(childId) } } - private fun addToResultBlocks(videoBlock: Block, verticalBlock: Block, resultBlocks: MutableList) { + private fun addToResultBlocks( + videoBlock: Block, + verticalBlock: Block, + resultBlocks: MutableList + ) { resultBlocks.add(videoBlock) val verticalIndex = resultBlocks.indexOfFirst { it.id == verticalBlock.id } if (verticalIndex == -1) { resultBlocks.add(verticalBlock.copy(descendants = listOf(videoBlock.id))) } else { val block = resultBlocks[verticalIndex] - resultBlocks[verticalIndex] = block.copy(descendants = block.descendants + videoBlock.id) + resultBlocks[verticalIndex] = + block.copy(descendants = block.descendants + videoBlock.id) } } @@ -114,4 +119,7 @@ class CourseInteractor( suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String) = repository.submitOfflineXBlockProgress(blockId, courseId) + + fun getCourseProgress(courseId: String, isRefresh: Boolean) = + repository.getCourseProgress(courseId, isRefresh) } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 0dbe660e5..0eff40583 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -66,6 +66,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Handouts Tab", "edx.bi.app.course.handouts_tab" ), + PROGRESS_TAB( + "Course:Progress Tab", + "edx.bi.app.course.progress_tab" + ), ANNOUNCEMENTS( "Course:Announcements", "edx.bi.app.course.announcements" diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 1abd8cbb2..a71d954df 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -88,6 +88,7 @@ import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.offline.CourseOfflineScreen import org.openedx.course.presentation.outline.CourseOutlineScreen +import org.openedx.course.presentation.progress.CourseProgressScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen @@ -267,6 +268,7 @@ fun CourseDashboard( CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS CourseContainerTab.DATES.name -> CourseContainerTab.DATES CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.PROGRESS.name -> CourseContainerTab.PROGRESS CourseContainerTab.MORE.name -> CourseContainerTab.MORE else -> CourseContainerTab.HOME } @@ -344,8 +346,7 @@ fun CourseDashboard( when (accessStatus.value) { CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, CourseAccessError.NOT_YET_STARTED, - CourseAccessError.UNKNOWN, - -> { + CourseAccessError.UNKNOWN -> { CourseAccessErrorView( viewModel = viewModel, accessError = accessStatus.value, @@ -492,6 +493,13 @@ private fun DashboardPager( ) } + CourseContainerTab.PROGRESS -> { + CourseProgressScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { parametersOf(viewModel.courseId) }), + ) + } + CourseContainerTab.MORE -> { HandoutsScreen( windowSize = windowSize, @@ -608,8 +616,7 @@ private fun SetupCourseAccessErrorButtons( ) { when (accessError) { CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, - CourseAccessError.NOT_YET_STARTED, - -> { + CourseAccessError.NOT_YET_STARTED -> { OpenEdXButton( text = stringResource(R.string.course_label_back), onClick = { fragmentManager.popBackStack() }, diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index b591c7ecf..236c548f6 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Moving import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector @@ -19,8 +20,9 @@ enum class CourseContainerTab( ) : TabItem { HOME(R.string.course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + PROGRESS(R.string.course_container_nav_progress, Icons.Default.Moving), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), OFFLINE(R.string.course_container_nav_downloads, Icons.Filled.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), - MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) + MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet), } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index f3d2bd2c7..18f5f9b3c 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -40,6 +40,7 @@ import org.openedx.core.system.notifier.CourseStructureGot import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshDates import org.openedx.core.system.notifier.RefreshDiscussions +import org.openedx.core.system.notifier.RefreshProgress import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor @@ -303,6 +304,12 @@ class CourseContainerViewModel( } } + CourseContainerTab.PROGRESS -> { + viewModelScope.launch { + courseNotifier.send(RefreshProgress) + } + } + else -> { _refreshing.value = false } @@ -313,7 +320,7 @@ class CourseContainerViewModel( viewModelScope.launch { try { interactor.getCourseStructure(courseId, isNeedRefresh = true) - } catch (ignore: Exception) { + } catch (_: Exception) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_unknown_error) } @@ -328,6 +335,7 @@ class CourseContainerViewModel( CourseContainerTab.VIDEOS -> videoTabClickedEvent() CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() + CourseContainerTab.PROGRESS -> progressTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() CourseContainerTab.OFFLINE -> {} } @@ -381,6 +389,10 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.MORE_TAB) } + private fun progressTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.PROGRESS_TAB) + } + private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { courseAnalytics.logScreenEvent( screenName = event.eventName, diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt new file mode 100644 index 000000000..57b13d80b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -0,0 +1,547 @@ +package org.openedx.course.presentation.progress + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.NoContentScreenType +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.NoContentScreen +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@Composable +fun CourseProgressScreen( + windowSize: WindowSize, + viewModel: CourseProgressViewModel, +) { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + + when (val state = uiState) { + is CourseProgressUIState.Loading -> CircularProgress() + is CourseProgressUIState.Error -> NoContentScreen(NoContentScreenType.COURSE_PROGRESS) + is CourseProgressUIState.Data -> CourseProgressContent( + uiState = state, + uiMessage = uiMessage, + windowSize = windowSize, + ) + } +} + +@Composable +private fun CourseProgressContent( + uiState: CourseProgressUIState.Data, + uiMessage: UIMessage?, + windowSize: WindowSize +) { + val scaffoldState = rememberScaffoldState() + val gradingPolicy = uiState.progress.gradingPolicy + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background, + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + item { + CourseCompletionView( + progress = uiState.progress + ) + } + if (gradingPolicy == null) return@LazyColumn + if (gradingPolicy.assignmentPolicies.isNotEmpty()) { + item { + OverallGradeView( + progress = uiState.progress, + ) + } + item { + GradeDetailsHeaderView() + } + itemsIndexed(gradingPolicy.assignmentPolicies) { index, policy -> + AssignmentTypeRow( + progress = uiState.progress, + policy = policy, + color = if (gradingPolicy.assignmentColors.isNotEmpty()) { + gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] + } else { + MaterialTheme.appColors.primary + } + ) + Divider( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + item { + GradeDetailsFooterView( + progress = uiState.progress + ) + } + } else { + item { + Box( + modifier = Modifier + .fillMaxSize() + .padding(top = 60.dp), + contentAlignment = Alignment.Center + ) { + NoGradesView() + } + } + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + } + } +} + +@Composable +private fun NoGradesView() { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(60.dp), + imageVector = Icons.AutoMirrored.Outlined.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.course_progress_no_assignments), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun GradeDetailsHeaderView() { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_progress_grade_details), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_assignment_type), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + Text( + text = stringResource(R.string.course_progress_current_max), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } +} + +@Composable +private fun GradeDetailsFooterView( + progress: CourseProgress, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.course_progress_current_overall), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = "${progress.getTotalWeightPercent().toInt()}%", + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun OverallGradeView( + progress: CourseProgress, +) { + val gradingPolicy = progress.gradingPolicy + if (gradingPolicy == null) return + val notCompletedWeightedGradePercent = progress.getNotCompletedWeightedGradePercent() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.course_progress_overall_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_overall_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = buildAnnotatedString { + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.textDark, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = MaterialTheme.appTypography.labelMedium.fontWeight + ) + ) { + append(stringResource(R.string.course_progress_current_overall) + " ") + } + withStyle( + style = SpanStyle( + color = MaterialTheme.appColors.primary, + fontSize = MaterialTheme.appTypography.labelMedium.fontSize, + fontFamily = MaterialTheme.appTypography.labelMedium.fontFamily, + fontWeight = FontWeight.SemiBold + ) + ) { + append("${progress.getTotalWeightPercent().toInt()}%") + } + }, + style = MaterialTheme.appTypography.labelMedium, + ) + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.appColors.gradeProgressBarBorder, + shape = CircleShape + ) + ) { + gradingPolicy.assignmentPolicies.forEachIndexed { index, assignmentPolicy -> + val assignmentColors = gradingPolicy.assignmentColors + val color = if (assignmentColors.isNotEmpty()) { + assignmentColors[ + gradingPolicy.assignmentPolicies.indexOf( + assignmentPolicy + ) % assignmentColors.size + ] + } else { + MaterialTheme.appColors.primary + } + val weightedPercent = + progress.getAssignmentWeightedGradedPercent(assignmentPolicy) + if (weightedPercent > 0f) { + Box( + modifier = Modifier + .weight(weightedPercent) + .background(color) + .fillMaxHeight() + ) + + // Add black separator between assignment policies (except after the last one) + if (index < gradingPolicy.assignmentPolicies.size - 1) { + Box( + modifier = Modifier + .width(1.dp) + .background(Color.Black) + .fillMaxHeight() + ) + } + } + } + if (notCompletedWeightedGradePercent > 0f) { + Box( + modifier = Modifier + .weight(notCompletedWeightedGradePercent) + .fillMaxHeight() + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth(progress.requiredGrade), + contentAlignment = Alignment.CenterEnd + ) { + Box( + modifier = Modifier.offset(x = 20.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_course_marker), + tint = MaterialTheme.appColors.warning, + contentDescription = null + ) + Text( + modifier = Modifier + .offset(y = 2.dp) + .clearAndSetSemantics { }, + text = "${progress.requiredGradePercent}%", + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + } + } + + Surface( + color = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.cardShape, + border = BorderStroke( + width = 1.dp, + color = MaterialTheme.appColors.warning + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = android.R.drawable.ic_dialog_alert), + contentDescription = null, + tint = MaterialTheme.appColors.warning, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource( + R.string.course_progress_required_grade_percent, + progress.requiredGradePercent.toString() + ), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) + } + } + } +} + +@Composable +private fun CourseCompletionView( + progress: CourseProgress +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.course_progress_completion_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = stringResource(R.string.course_progress_completion_description), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + ) + } + Box( + modifier = Modifier + .align(Alignment.CenterVertically) + .semantics(mergeDescendants = true) {} + ) { + CircularProgressIndicator( + modifier = Modifier + .size(100.dp) + .border( + width = 1.dp, + color = MaterialTheme.appColors.progressBarBackgroundColor, + shape = CircleShape + ) + .padding(3.dp), + progress = progress.completion, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, + strokeWidth = 10.dp, + strokeCap = StrokeCap.Round + ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "${progress.completionPercent}%", + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.primary, + ) + Text( + text = stringResource(R.string.course_completed), + style = MaterialTheme.appTypography.labelSmall, + color = MaterialTheme.appColors.textPrimaryVariant, + ) + } + } + } +} + +@Composable +private fun AssignmentTypeRow( + progress: CourseProgress, + policy: CourseProgress.GradingPolicy.AssignmentPolicy, + color: Color +) { + val earned = progress.getEarnedAssignmentProblems(policy) + val possible = progress.getPossibleAssignmentProblems(policy) + Column( + modifier = Modifier + .semantics(mergeDescendants = true) {} + ) { + Text( + text = policy.type, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textPrimary, + ) + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .width(7.dp) + .background( + color = color, + shape = CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource( + R.string.progress_earned_possible_assignment_problems, + earned.toInt(), + possible.toInt() + ), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("${(policy.weight * 100).toInt()}%") + } + append(" ") + append(stringResource(R.string.progress_of_grade)) + }, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + ) + } + Text( + stringResource( + R.string.progress_current_and_max_weighted_graded_percent, + progress.getAssignmentWeightedGradedPercent(policy).toInt(), + (policy.weight * 100).toInt() + ), + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.W700, + color = MaterialTheme.appColors.textDark, + ) + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt new file mode 100644 index 000000000..25771f631 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressUIState.kt @@ -0,0 +1,9 @@ +package org.openedx.course.presentation.progress + +import org.openedx.core.domain.model.CourseProgress + +sealed class CourseProgressUIState { + data object Error : CourseProgressUIState() + data object Loading : CourseProgressUIState() + data class Data(val progress: CourseProgress) : CourseProgressUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt new file mode 100644 index 000000000..c5c4b1f06 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -0,0 +1,72 @@ +package org.openedx.course.presentation.progress + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.openedx.core.system.notifier.CourseLoading +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.RefreshProgress +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage + +class CourseProgressViewModel( + val courseId: String, + private val interactor: CourseInteractor, + private val courseNotifier: CourseNotifier, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(CourseProgressUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private var progressJob: Job? = null + + init { + loadCourseProgress(false) + collectCourseNotifier() + } + + fun loadCourseProgress(isRefresh: Boolean) { + progressJob?.cancel() + progressJob = viewModelScope.launch { + if (!isRefresh) { + _uiState.value = CourseProgressUIState.Loading + } + interactor.getCourseProgress(courseId, isRefresh) + .catch { e -> + if (_uiState.value !is CourseProgressUIState.Data) { + _uiState.value = CourseProgressUIState.Error + } + courseNotifier.send(CourseLoading(false)) + } + .collectLatest { progress -> + _uiState.value = CourseProgressUIState.Data(progress) + courseNotifier.send(CourseLoading(false)) + } + } + } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is RefreshProgress, is CourseStructureUpdated -> loadCourseProgress(true) + } + } + } + } +} diff --git a/course/src/main/res/drawable/ic_course_marker.xml b/course/src/main/res/drawable/ic_course_marker.xml new file mode 100644 index 000000000..007f3425b --- /dev/null +++ b/course/src/main/res/drawable/ic_course_marker.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index e4ae9e39d..45a56ead3 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -36,6 +36,15 @@ Some content in this part of the course is locked for upgraded users only. You cannot change the download video quality when all videos are downloading Dates Shifted + Course Completion + This represents how much of the course content you have completed. Note that some content may not yet be released. + Overall Grade + This represents your weighted grade against the grade needed to pass this course. + Current Overall Weighted Grade: + A weighted grade of %1$s%% is required to pass this course + Grade Details + Assignment Type + Current / Max % Course dates are not currently available. @@ -46,6 +55,7 @@ More Dates Downloads + Progress Video player @@ -55,7 +65,6 @@ Section completed Section uncompleted - %1$s of %2$s assignment complete %1$s of %2$s assignments complete @@ -66,4 +75,9 @@ Find a new course This course will begin on %s. Come back then to start learning! An error occurred while loading your course + Completed + %1$s / %2$s Complete + of Grade + %1$s / %2$s%% + This course does not contain graded assignments.