diff --git a/app/schemas/org.openedx.app.room.AppDatabase/4.json b/app/schemas/org.openedx.app.room.AppDatabase/4.json new file mode 100644 index 000000000..0f1e1c17b --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/4.json @@ -0,0 +1,1236 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "488bd2b78e977fef626afb28014c80f2", + "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": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER NOT NULL, `duration` INTEGER NOT NULL, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_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, '488bd2b78e977fef626afb28014c80f2')" + ] + } +} \ 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 25cf3fed4..5d8f1eb5a 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -18,11 +18,13 @@ import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.core.repository.CalendarRepository import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.assignments.CourseAssignmentViewModel import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.contenttab.ContentTabViewModel 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.outline.CourseContentAllViewModel import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel @@ -281,7 +283,7 @@ val screenModule = module { ) } viewModel { (courseId: String, courseTitle: String) -> - CourseOutlineViewModel( + CourseContentAllViewModel( courseId, courseTitle, get(), @@ -300,6 +302,13 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String, courseTitle: String) -> + ContentTabViewModel( + courseId, + courseTitle, + get(), + ) + } viewModel { (courseId: String) -> CourseSectionViewModel( courseId, @@ -320,10 +329,9 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseTitle: String) -> + viewModel { (courseId: String) -> CourseVideoViewModel( courseId, - courseTitle, get(), get(), get(), @@ -343,9 +351,11 @@ val screenModule = module { } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> VideoUnitViewModel( courseId, + videoUrl, + blockId, get(), get(), get(), @@ -353,9 +363,10 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, blockId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> EncodedVideoUnitViewModel( courseId, + videoUrl, blockId, get(), get(), @@ -538,4 +549,13 @@ val screenModule = module { router = get() ) } + viewModel { (courseId: String) -> + CourseAssignmentViewModel( + courseId = courseId, + interactor = get(), + courseRouter = get(), + courseNotifier = get(), + analytics = get() + ) + } } 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 b5dfde4da..fd0b0069f 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -11,6 +11,7 @@ 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 +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.CalendarDao @@ -22,9 +23,10 @@ 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 = 3 +const val DATABASE_VERSION = 4 const val DATABASE_NAME = "OpenEdX_db" +@Suppress("MagicNumber") @Database( entities = [ CourseEntity::class, @@ -36,11 +38,13 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarStateEntity::class, DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class, + VideoProgressEntity::class, CourseProgressEntity::class, ], autoMigrations = [ AutoMigration(1, 2), - AutoMigration(2, DATABASE_VERSION), + AutoMigration(2, 3), + AutoMigration(3, 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 0dd6ce937..0c3087abf 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -17,7 +17,7 @@ class DatabaseManager( ) : DatabaseManager { override fun clearTables() { CoroutineScope(Dispatchers.IO).launch { - courseDao.clearCourseData() + courseDao.clearCachedData() 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 1b9dcafab..559cf05d1 100644 --- a/core/src/main/java/org/openedx/core/NoContentScreenType.kt +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -16,6 +16,10 @@ enum class NoContentScreenType( iconResId = R.drawable.core_ic_no_content, messageResId = R.string.core_no_dates ), + COURSE_ASSIGNMENT( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_assignments + ), COURSE_DISCUSSIONS( iconResId = R.drawable.core_ic_no_content, messageResId = R.string.core_no_discussion diff --git a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt index 2ac10cb18..8c4d20e35 100644 --- a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -4,6 +4,8 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.AssignmentProgressDb import org.openedx.core.domain.model.AssignmentProgress +private const val DEFAULT_LABEL_LENGTH = 5 + data class AssignmentProgress( @SerializedName("assignment_type") val assignmentType: String?, @@ -11,16 +13,20 @@ data class AssignmentProgress( val numPointsEarned: Float?, @SerializedName("num_points_possible") val numPointsPossible: Float?, + @SerializedName("short_label") + val shortLabel: String? ) { - fun mapToDomain() = AssignmentProgress( - assignmentType = assignmentType ?: "", + fun mapToDomain(displayName: String) = AssignmentProgress( + assignmentType = assignmentType, numPointsEarned = numPointsEarned ?: 0f, - numPointsPossible = numPointsPossible ?: 0f + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: displayName.take(DEFAULT_LABEL_LENGTH) ) fun mapToRoomEntity() = AssignmentProgressDb( assignmentType = assignmentType, numPointsEarned = numPointsEarned, - numPointsPossible = numPointsPossible + numPointsPossible = numPointsPossible, + shortLabel = shortLabel ) } diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 8ac8a8378..c85a4c1b5 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -65,7 +65,7 @@ data class Block( blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, containsGatedContent = containsGatedContent ?: false, - assignmentProgress = assignmentProgress?.mapToDomain(), + assignmentProgress = assignmentProgress?.mapToDomain(displayName.orEmpty()), due = TimeUtils.iso8601ToDate(due.orEmpty()), offlineDownload = offlineDownload?.mapToDomain() ) @@ -136,7 +136,9 @@ data class VideoInfo( var fileSize: Long? ) { fun mapToDomain() = DomainVideoInfo( - url = url.orEmpty(), + url = url + .orEmpty() + .trim(), fileSize = fileSize ?: 0 ) } 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 index bf31419e6..00d55a9b5 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -157,6 +157,7 @@ data class CourseProgressResponse( displayName = displayName ?: "", subsections = subsections?.map { it.mapToDomain() } ?: emptyList() ) + data class Subsection( @SerializedName("assignment_type") val assignmentType: String?, @SerializedName("block_key") val blockKey: String?, @@ -203,6 +204,7 @@ data class CourseProgressResponse( showGrades = showGrades ?: false, url = url ?: "" ) + data class ProblemScore( @SerializedName("earned") val earned: Double?, @SerializedName("possible") val possible: Double? diff --git a/core/src/main/java/org/openedx/core/data/model/Progress.kt b/core/src/main/java/org/openedx/core/data/model/Progress.kt index d4813c14c..469be14b9 100644 --- a/core/src/main/java/org/openedx/core/data/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -11,8 +11,8 @@ data class Progress( val totalAssignmentsCount: Int?, ) { fun mapToDomain() = Progress( - assignmentsCompleted = assignmentsCompleted ?: 0, - totalAssignmentsCount = totalAssignmentsCount ?: 0 + completed = assignmentsCompleted ?: 0, + total = totalAssignmentsCount ?: 0 ) fun mapToRoomEntity() = ProgressDb( diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index a60d9e68c..4ec631f30 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -203,7 +203,9 @@ data class VideoInfoDb( fun createFrom(videoInfo: VideoInfo?): VideoInfoDb? { if (videoInfo == null) return null return VideoInfoDb( - videoInfo.url ?: "", + videoInfo.url + .orEmpty() + .trim(), videoInfo.fileSize ?: 0, ) } @@ -230,11 +232,13 @@ data class AssignmentProgressDb( val numPointsEarned: Float?, @ColumnInfo("num_points_possible") val numPointsPossible: Float?, + val shortLabel: String? ) { fun mapToDomain() = DomainAssignmentProgress( - assignmentType = assignmentType ?: "", + assignmentType = assignmentType, numPointsEarned = numPointsEarned ?: 0f, - numPointsPossible = numPointsPossible ?: 0f + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: "" ) } 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 index 6c98cbed2..19ad78590 100644 --- 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 @@ -131,6 +131,7 @@ data class GradingPolicyDb( Color(colorString.toColorInt()) } ) + data class AssignmentPolicyDb( @ColumnInfo("numDroppable") val numDroppable: Int, @@ -163,6 +164,7 @@ data class SectionScoreDb( displayName = displayName, subsections = subsections.map { it.mapToDomain() } ) + data class SubsectionDb( @ColumnInfo("assignmentType") val assignmentType: String, @@ -206,6 +208,7 @@ data class SectionScoreDb( showGrades = showGrades, url = url ) + data class ProblemScoreDb( @ColumnInfo("earned") val earned: Double, diff --git a/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt new file mode 100644 index 000000000..fbe2866e7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt @@ -0,0 +1,18 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "video_progress_table") +data class VideoProgressEntity( + @PrimaryKey + @ColumnInfo("block_id") + val blockId: String, + @ColumnInfo("video_url") + val videoUrl: String, + @ColumnInfo("video_time") + val videoTime: Long, + @ColumnInfo("duration") + val duration: Long, +) 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 14ac6713a..4ca7db3a6 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 @@ -8,6 +8,7 @@ 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 +import org.openedx.core.data.model.room.VideoProgressEntity @Dao interface CourseDao { @@ -19,27 +20,37 @@ interface CourseDao { suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) @Transaction - suspend fun clearCourseData() { - clearCourseStructureData() - clearCourseProgressData() + suspend fun clearCachedData() { + clearCourseStructure() + clearVideoProgress() clearEnrollmentCachedData() + clearCourseProgressData() } @Query("DELETE FROM course_structure_table") - suspend fun clearCourseStructureData() + suspend fun clearCourseStructure() - @Query("DELETE FROM course_progress_table") - suspend fun clearCourseProgressData() + @Query("DELETE FROM video_progress_table") + suspend fun clearVideoProgress() @Query("DELETE FROM course_enrollment_details_table") suspend fun clearEnrollmentCachedData() + @Query("DELETE FROM course_progress_table") + suspend fun clearCourseProgressData() + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) @Query("SELECT * FROM course_enrollment_details_table WHERE id=:id") suspend fun getCourseEnrollmentDetailsById(id: String): CourseEnrollmentDetailsEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVideoProgressEntity(vararg videoProgressEntity: VideoProgressEntity) + + @Query("SELECT * FROM video_progress_table WHERE block_id=:blockId") + suspend fun getVideoProgressByBlockId(blockId: String): VideoProgressEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt index 730bfbfba..6c51810fb 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -1,11 +1,27 @@ package org.openedx.core.domain.model import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.safeDivBy @Parcelize data class AssignmentProgress( - val assignmentType: String, + val assignmentType: String?, val numPointsEarned: Float, - val numPointsPossible: Float -) : Parcelable + val numPointsPossible: Float, + val shortLabel: String +) : Parcelable { + + @IgnoredOnParcel + val value: Float = numPointsEarned.safeDivBy(numPointsPossible) + + fun toPointString(separator: String = ""): String { + return "${numPointsEarned.toInt()}$separator/$separator${numPointsPossible.toInt()}" + } + + @IgnoredOnParcel + val label = shortLabel + .replace(" ", "") + .replaceFirst(Regex("^(\\D+)(0*)(\\d+)$"), "$1$3") +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index d2c36a0f3..4b27c87fd 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -1,5 +1,6 @@ package org.openedx.core.domain.model +import android.content.Context import android.os.Parcelable import android.webkit.URLUtil import kotlinx.parcelize.Parcelize @@ -7,8 +8,9 @@ import kotlinx.parcelize.RawValue import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.module.db.DownloadModel -import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.utils.PreviewHelper +import org.openedx.core.utils.VideoPreview import org.openedx.core.utils.VideoUtil import java.util.Date @@ -51,13 +53,6 @@ data class Block( null } - fun isDownloading(): Boolean { - return downloadModel?.downloadedState == DownloadedState.DOWNLOADING || - downloadModel?.downloadedState == DownloadedState.WAITING - } - - fun isDownloaded() = downloadModel?.downloadedState == DownloadedState.DOWNLOADED - fun isGated() = containsGatedContent fun isCompleted() = completion == 1.0 @@ -89,6 +84,36 @@ data class Block( } } + fun getVideoPreview(context: Context, isOnline: Boolean, offlineUrl: String?): VideoPreview? { + return if (studentViewData?.encodedVideos?.hasYoutubeUrl == true) { + val youtubeUrl = studentViewData.encodedVideos.youtube?.url ?: "" + VideoPreview.createYoutubePreview( + PreviewHelper.getYouTubeThumbnailUrl(youtubeUrl) + ) + } else if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + val videoUrl = if (studentViewData.encodedVideos.videoUrl.isNotEmpty() && isOnline) { + studentViewData.encodedVideos.videoUrl + } else { + offlineUrl ?: "" + } + val bitmap = PreviewHelper.getVideoFrameBitmap( + context = context, + isOnline = isOnline, + videoUrl = videoUrl + ) + bitmap?.let { VideoPreview.createEncodedVideoPreview(it) } + } else { + null + } + } + + val videoUrl: String? + get() = if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + studentViewData.encodedVideos.videoUrl + } else { + studentViewData?.encodedVideos?.youtube?.url + } + val isVideoBlock get() = type == BlockType.VIDEO val isDiscussionBlock get() = type == BlockType.DISCUSSION val isHTMLBlock get() = type == BlockType.HTML @@ -169,7 +194,10 @@ data class EncodedVideos( isPreferredVideoInfo(mobileHigh) -> mobileHigh isPreferredVideoInfo(desktopMp4) -> desktopMp4 fallback != null && isPreferredVideoInfo(fallback) && - !VideoUtil.videoHasFormat(fallback!!.url, AppDataConstants.VIDEO_FORMAT_M3U8) -> fallback + !VideoUtil.videoHasFormat( + fallback!!.url, + AppDataConstants.VIDEO_FORMAT_M3U8 + ) -> fallback hls != null && isPreferredVideoInfo(hls) -> hls else -> null diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt index edbcf0f90..fbe82d5cc 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -7,12 +7,12 @@ import org.openedx.core.extension.safeDivBy @Parcelize data class Progress( - val assignmentsCompleted: Int, - val totalAssignmentsCount: Int, + val completed: Int, + val total: Int, ) : Parcelable { @IgnoredOnParcel - val value: Float = assignmentsCompleted.toFloat().safeDivBy(totalAssignmentsCount.toFloat()) + val value: Float = completed.toFloat().safeDivBy(total.toFloat()) companion object { val DEFAULT_PROGRESS = Progress(0, 0) diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 6d97816ae..6a802755f 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -10,3 +10,7 @@ fun List.getVerticalBlocks(): List { fun List.getSequentialBlocks(): List { return this.filter { it.type == BlockType.SEQUENTIAL } } + +fun List.getChapterBlocks(): List { + return this.filter { it.type == BlockType.CHAPTER } +} diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 1f4de150a..ba87e6ab0 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -34,7 +34,6 @@ abstract class BaseDownloadViewModel( private val _downloadModelsStatusFlow = MutableSharedFlow>() protected val downloadModelsStatusFlow = _downloadModelsStatusFlow.asSharedFlow() - private var downloadingModelsList = listOf() private val _downloadingModelsFlow = MutableSharedFlow>() protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() @@ -53,7 +52,7 @@ abstract class BaseDownloadViewModel( _downloadModelsStatusFlow.emit(downloadModelsStatus) } - private suspend fun getDownloadModelList(): List { + suspend fun getDownloadModelList(): List { return downloadDao.getAllDataFlow().first().map { it.mapToDomain() } } @@ -198,8 +197,6 @@ abstract class BaseDownloadViewModel( ) } - fun hasDownloadModelsInQueue() = downloadingModelsList.isNotEmpty() - fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] open fun removeDownloadModels(blockId: String, courseId: String) { @@ -210,13 +207,6 @@ abstract class BaseDownloadViewModel( } } - fun removeAllDownloadModels() { - viewModelScope.launch { - val downloadableChildren = downloadableChildrenMap.values.flatten() - workerController.removeModels(downloadableChildren) - } - } - fun removeBlockDownloadModel(blockId: String) { viewModelScope.launch { workerController.removeModel(blockId) @@ -244,16 +234,6 @@ abstract class BaseDownloadViewModel( downloadableChildrenMap[parentId] = children + childId } - fun logBulkDownloadToggleEvent(toggle: Boolean, courseId: String) { - logEvent( - CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, - buildMap { - put(CoreAnalyticsKey.ACTION.key, toggle) - }, - courseId - ) - } - private fun logSubsectionDownloadEvent( subsectionId: String, numberOfVideos: Int, diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt index bdeba1114..a289abe91 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt @@ -3,5 +3,6 @@ package org.openedx.core.system.notifier data class CourseVideoPositionChanged( val videoUrl: String, val videoTime: Long, + val duration: Long, val isPlaying: Boolean ) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 3cf6eb1fc..eed214567 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -983,7 +984,9 @@ fun OfflineModeDialog( @Composable fun OpenEdXButton( - modifier: Modifier = Modifier.fillMaxWidth(), + modifier: Modifier = Modifier + .fillMaxWidth() + .height(42.dp), text: String = "", onClick: () -> Unit, enabled: Boolean = true, @@ -994,8 +997,7 @@ fun OpenEdXButton( Button( modifier = Modifier .testTag("btn_${text.tagId()}") - .then(modifier) - .height(42.dp), + .then(modifier), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( backgroundColor = backgroundColor @@ -1141,7 +1143,11 @@ fun NoContentScreen(message: String, icon: Painter) { horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - modifier = Modifier.size(80.dp), + modifier = Modifier + .sizeIn( + maxWidth = 80.dp, + maxHeight = 80.dp + ), painter = icon, contentDescription = null, tint = MaterialTheme.appColors.progressBarBackgroundColor, diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt index eed4d481d..1a45681f9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt @@ -13,9 +13,11 @@ data class AppShapes( val textFieldShape: CornerBasedShape, val screenBackgroundShape: CornerBasedShape, val cardShape: CornerBasedShape, + val sectionCardShape: CornerBasedShape, val screenBackgroundShapeFull: CornerBasedShape, val courseImageShape: CornerBasedShape, val dialogShape: CornerBasedShape, + val videoPreviewShape: CornerBasedShape, ) val MaterialTheme.appShapes: AppShapes diff --git a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt new file mode 100644 index 000000000..03227050b --- /dev/null +++ b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt @@ -0,0 +1,147 @@ +package org.openedx.core.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest + +data class VideoPreview( + val link: String? = null, + val bitmap: Bitmap? = null +) { + companion object { + fun createYoutubePreview(link: String): VideoPreview { + return VideoPreview(link = link) + } + + fun createEncodedVideoPreview(bitmap: Bitmap): VideoPreview { + return VideoPreview(bitmap = bitmap) + } + } +} + +object PreviewHelper { + + fun getYouTubeThumbnailUrl(url: String): String { + val videoId = extractYouTubeVideoId(url) + return "https://img.youtube.com/vi/$videoId/0.jpg" + } + + private fun extractYouTubeVideoId(url: String): String { + val regex = Regex( + "^(?:https?://)?(?:www\\.)?(?:youtube\\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)|.*[?&]v=)|youtu\\.be/)" + + "([^\"&?/\\s]{11})", + RegexOption.IGNORE_CASE + ) + val matchResult = regex.find(url) + return matchResult?.groups?.get(1)?.value ?: "" + } + + fun getVideoFrameBitmap(context: Context, isOnline: Boolean, videoUrl: String): Bitmap? { + var result: Bitmap? = null + if (isOnline || isLocalFile(videoUrl)) { + // Check cache first + val cacheFile = getCacheFile(context, videoUrl) + result = if (cacheFile.exists()) { + try { + BitmapFactory.decodeFile(cacheFile.absolutePath) + } catch (_: Exception) { + extractBitmapFromVideo(videoUrl, context) + } + } else { + extractBitmapFromVideo(videoUrl, context) + } + } + return result + } + + private fun extractBitmapFromVideo(videoUrl: String, context: Context): Bitmap? { + val retriever = MediaMetadataRetriever() + try { + if (isLocalFile(videoUrl)) { + retriever.setDataSource(videoUrl) + } else { + retriever.setDataSource(videoUrl, HashMap()) + } + val bitmap = retriever.getFrameAtTime(0) + + // Save bitmap to cache if it was successfully retrieved + bitmap?.let { + saveBitmapToCache(context, videoUrl, it) + } + + return bitmap + } catch (e: Exception) { + // Log the exception for debugging but don't crash + e.printStackTrace() + return null + } finally { + try { + retriever.release() + } catch (e: Exception) { + // Ignore release exceptions + e.printStackTrace() + } + } + } + + private fun isLocalFile(url: String): Boolean { + return url.startsWith("/") || url.startsWith("file://") + } + + private fun getCacheFile(context: Context, videoUrl: String): File { + val cacheDir = context.cacheDir + val fileName = generateFileName(videoUrl) + return File(cacheDir, "video_thumbnails/$fileName") + } + + private fun generateFileName(videoUrl: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(videoUrl.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + ".jpg" + } + + private fun saveBitmapToCache(context: Context, videoUrl: String, bitmap: Bitmap) { + try { + val cacheFile = getCacheFile(context, videoUrl) + cacheFile.parentFile?.mkdirs() // Create directories if they don't exist + + FileOutputStream(cacheFile).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Clear the bitmap cache to free storage + */ + fun clearCache(context: Context) { + try { + val cacheDir = File(context.cacheDir, "video_thumbnails") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Remove a specific bitmap from cache + */ + fun removeFromCache(context: Context, videoUrl: String) { + try { + val cacheFile = getCacheFile(context, videoUrl) + if (cacheFile.exists()) { + cacheFile.delete() + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index b401d0eb4..572d4bc5c 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -77,6 +77,25 @@ object TimeUtils { } } } + fun formatToDueInString(context: Context, date: Date): String { + val now = Calendar.getInstance() + val dueDate = Calendar.getInstance().apply { time = date } + now.set(Calendar.HOUR_OF_DAY, 0) + now.set(Calendar.MINUTE, 0) + now.set(Calendar.SECOND, 0) + now.set(Calendar.MILLISECOND, 0) + dueDate.set(Calendar.HOUR_OF_DAY, 0) + dueDate.set(Calendar.MINUTE, 0) + dueDate.set(Calendar.SECOND, 0) + dueDate.set(Calendar.MILLISECOND, 0) + val daysDifference = + ((dueDate.timeInMillis - now.timeInMillis) / (24 * 60 * 60 * 1000)).toInt() + return when { + daysDifference < 0 -> context.getString(R.string.core_date_type_past_due) + daysDifference == 0 -> context.getString(R.string.core_date_type_today) + else -> context.getString(R.string.core_date_format_due_in_days, daysDifference) + } + } fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis diff --git a/core/src/main/res/drawable/core_ic_mountains.xml b/core/src/main/res/drawable/core_ic_mountains.xml new file mode 100644 index 000000000..eea9a0e6b --- /dev/null +++ b/core/src/main/res/drawable/core_ic_mountains.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/core/src/main/res/drawable/ic_core_check.xml b/core/src/main/res/drawable/ic_core_check.xml new file mode 100644 index 000000000..e636ca1d8 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_check.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/src/main/res/drawable/ic_core_pointer.xml b/core/src/main/res/drawable/ic_core_pointer.xml new file mode 100644 index 000000000..cc777cf3e --- /dev/null +++ b/core/src/main/res/drawable/ic_core_pointer.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/ic_core_watch_later.xml b/core/src/main/res/drawable/ic_core_watch_later.xml new file mode 100644 index 000000000..4dd7cedf0 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_watch_later.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index e28580acc..405751cf8 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -87,11 +87,13 @@ Completed Past Due Today + Due Tomorrow This Week Next Week Upcoming None Due %1$s + Due in %1$d days %d Item Hidden %d Items Hidden @@ -152,13 +154,13 @@ Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. Update Now Remove Course Calendar - No course content is currently available. - There are currently no videos for this course. + No videos available for this course. Course dates are currently not available. This course does not contain exams or graded assignments. + No assignments available for this course. 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. @@ -182,7 +184,6 @@ Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? Are you sure you want to delete all video(s) for \"%s\"? Are you sure you want to delete video(s) for \"%s\"? - %1$s - %2$s - %3$d / %4$d Downloading this content requires an active internet connection. Please connect to the internet and try again. Wi-Fi Required Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. @@ -213,7 +214,6 @@ Explore other parts of this course or view this when you reconnect. This component is not downloaded Explore other parts of this course or download this when you reconnect. - Authorization Please enter the system to continue with course enrollment. 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 65c082f70..df4f6c357 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -72,7 +72,7 @@ val light_tab_selected_btn_content = Color.White val light_course_home_header_shade = Color(0xFFBABABA) 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_color = light_success_green val light_progress_bar_background_color = Color(0xFFCCD4E0) val light_grade_progress_bar_color = Color.Black @@ -146,6 +146,6 @@ val dark_tab_selected_btn_content = Color.White val dark_course_home_header_shade = Color(0xFF999999) 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_color = dark_success_green val dark_progress_bar_background_color = Color(0xFF8E9BAE) val dark_grade_progress_bar_color = Color.Transparent diff --git a/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt b/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt index b5415bc5e..f126b44e3 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/LocalShapes.kt @@ -20,6 +20,8 @@ internal val LocalShapes = staticCompositionLocalOf { cardShape = RoundedCornerShape(12.dp), screenBackgroundShapeFull = RoundedCornerShape(24.dp), courseImageShape = RoundedCornerShape(8.dp), - dialogShape = RoundedCornerShape(24.dp) + dialogShape = RoundedCornerShape(24.dp), + sectionCardShape = RoundedCornerShape(6.dp), + videoPreviewShape = RoundedCornerShape(8.dp), ) } 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 914ce7191..2e460bfa6 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 @@ -7,6 +7,7 @@ import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.CourseDao @@ -240,6 +241,21 @@ class CourseRepository( } } + suspend fun saveVideoProgress( + blockId: String, + videoUrl: String, + videoTime: Long, + duration: Long + ) { + val videoProgressEntity = VideoProgressEntity(blockId, videoUrl, videoTime, duration) + courseDao.insertVideoProgressEntity(videoProgressEntity) + } + + suspend fun getVideoProgress(blockId: String): VideoProgressEntity { + return courseDao.getVideoProgressByBlockId(blockId) + ?: VideoProgressEntity(blockId, "", 0L, 0L) + } + fun getCourseProgress(courseId: String, isRefresh: Boolean): Flow = channelFlowWithAwait { if (!isRefresh) { 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 49fdf0d42..7da1623d7 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 @@ -122,4 +122,6 @@ class CourseInteractor( fun getCourseProgress(courseId: String, isRefresh: Boolean) = repository.getCourseProgress(courseId, isRefresh) + + suspend fun getVideoProgress(blockId: String) = repository.getVideoProgress(blockId) } 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 0eff40583..99ff6d2e1 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -70,6 +70,14 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Progress Tab", "edx.bi.app.course.progress_tab" ), + OFFLINE_TAB( + "Course:Offline Tab", + "edx.bi.app.course.offline_tab" + ), + CONTENT_TAB( + "Course:Content Tab", + "edx.bi.app.course.content_tab" + ), ANNOUNCEMENTS( "Course:Announcements", "edx.bi.app.course.announcements" @@ -82,6 +90,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Unit Detail", "edx.bi.app.course.unit_detail" ), + COURSE_CONTENT_TAB_CLICK( + "Content Page:Section Click", + "edx.bi.app.course.content.section.clicked" + ), VIEW_CERTIFICATE( "Course:View Certificate Clicked", "edx.bi.app.course.view_certificate.clicked" @@ -114,6 +126,18 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Video:Completed", "edx.bi.app.videos.completed" ), + VIDEO_SHOW_COMPLETED( + "Content Page:Show Completed Subsection Click", + "edx.bi.app.course.content.show_completed_subsection.clicked" + ), + COURSE_CONTENT_VIDEO_CLICK( + "Course:Video Clicked", + "edx.bi.app.course.content.video.clicked" + ), + COURSE_CONTENT_ASSIGNMENT_CLICK( + "Course:Assignment click", + "edx.bi.app.course.content.assignment.clicked" + ), CAST_CONNECTED( "Cast:Connected", "edx.bi.app.cast.connected" @@ -150,6 +174,10 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Dates:CalendarSync Snackbar", "edx.bi.app.dates.calendar_sync.snackbar" ), + ASSIGNMENT_CLICKED( + "Course:Assignment Tab.Assignment Clicked", + "edx.bi.app.course.assignment_tab.assignment.clicked" + ), } enum class CourseAnalyticsKey(val key: String) { @@ -168,6 +196,7 @@ enum class CourseAnalyticsKey(val key: String) { LINK("link"), SUPPORTED("supported"), BLOCK_ID("block_id"), + TAB_NAME("tab_name"), BLOCK_NAME("block_name"), BLOCK_TYPE("block_type"), PLAY_MEDIUM("play_medium"), diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 1f874e055..d600b0897 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -2,7 +2,6 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { @@ -63,7 +62,5 @@ interface CourseRouter { fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) - fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) - fun navigateToDiscover(fm: FragmentManager) } diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt new file mode 100644 index 000000000..28da59f6d --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentUIState.kt @@ -0,0 +1,16 @@ +package org.openedx.course.presentation.assignments + +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.Progress + +sealed class CourseAssignmentUIState { + data class CourseData( + val groupedAssignments: Map>, + val courseProgress: CourseProgress, + val progress: Progress, + val sectionNames: Map + ) : CourseAssignmentUIState() + data object Empty : CourseAssignmentUIState() + data object Loading : CourseAssignmentUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt new file mode 100644 index 000000000..1e480e538 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseAssignmentViewModel.kt @@ -0,0 +1,138 @@ +package org.openedx.course.presentation.assignments + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.Progress +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter + +class CourseAssignmentViewModel( + val courseId: String, + val courseRouter: CourseRouter, + private val interactor: CourseInteractor, + private val courseNotifier: CourseNotifier, + private val analytics: CourseAnalytics, +) : ViewModel() { + private val _uiState = + MutableStateFlow(CourseAssignmentUIState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + collectCourseNotifier() + collectData() + } + + private fun collectData() { + viewModelScope.launch { + val courseProgressFlow = interactor.getCourseProgress(courseId, false) + val courseStructureFlow = interactor.getCourseStructureFlow(courseId) + + combine( + courseProgressFlow, + courseStructureFlow + ) { courseProgress, courseStructure -> + courseProgress to courseStructure + }.catch { + if (_uiState.value !is CourseAssignmentUIState.CourseData) { + _uiState.value = CourseAssignmentUIState.Empty + } + }.collect { (courseProgress, courseStructure) -> + if (courseStructure != null) { + updateAssignments(courseStructure, courseProgress) + } else { + _uiState.value = CourseAssignmentUIState.Empty + } + } + } + } + + private fun updateAssignments( + courseStructure: CourseStructure, + courseProgress: CourseProgress + ) { + val assignments = courseStructure.blockData + .filter { !it.assignmentProgress?.assignmentType.isNullOrEmpty() } + if (assignments.isEmpty()) { + _uiState.value = CourseAssignmentUIState.Empty + } else { + val assignmentTypeOrder = + courseProgress.gradingPolicy?.assignmentPolicies?.map { it.type } ?: emptyList() + val filteredAssignments = assignments + .filter { assignment -> + assignmentTypeOrder.contains(assignment.assignmentProgress?.assignmentType) + } + .filter { it.graded } + val grouped = filteredAssignments + .groupBy { it.assignmentProgress?.assignmentType ?: "" } + .toSortedMap(compareBy { assignmentTypeOrder.indexOf(it) }) + val completed = assignments.count { it.isCompleted() } + val total = assignments.size + val progress = Progress(completed, total) + val sectionName = + createAssignmentToChapterMapping(courseStructure.blockData, assignments) + _uiState.value = CourseAssignmentUIState.CourseData( + groupedAssignments = grouped, + courseProgress = courseProgress, + progress = progress, + sectionNames = sectionName + ) + } + } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseStructureUpdated -> collectData() + } + } + } + } + + fun logAssignmentClick(blockId: String) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_ASSIGNMENT_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + + private fun createAssignmentToChapterMapping( + allBlocks: List, + assignments: List + ): Map { + val assignmentToChapterMap = mutableMapOf() + assignments.forEach { assignment -> + val chapterBlock = findChapterForAssignment(assignment.id, allBlocks) + if (chapterBlock != null) { + assignmentToChapterMap[assignment.id] = chapterBlock.displayName + } + } + + return assignmentToChapterMap + } + + private fun findChapterForAssignment(assignmentId: String, blocks: List): Block? { + return blocks.firstOrNull { it.descendants.contains(assignmentId) } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt new file mode 100644 index 000000000..57d2d5766 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -0,0 +1,706 @@ +package org.openedx.course.presentation.assignments + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +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.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.openedx.core.BlockType +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.Progress +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState +import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date +import org.openedx.core.R as coreR + +private const val ICON_SIZE_DP = 20 +private const val POINTER_ICON_SIZE_DP = 10 +private const val POINTER_ICON_PADDING_TOP_DP = 4 +private const val PROGRESS_HEIGHT_DP = 6 +private const val ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA = 0.5f +private const val COMPLETED_ASSIGNMENTS_COUNT = 1 +private const val COMPLETED_ASSIGNMENTS_COUNT_TABLET = 2 +private const val TOTAL_ASSIGNMENTS_COUNT = 3 + +@Composable +fun CourseContentAssignmentScreen( + windowSize: WindowSize, + viewModel: CourseAssignmentViewModel, + fragmentManager: FragmentManager, + onNavigateToHome: () -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsState() + CourseContentAssignmentScreen( + uiState = uiState, + windowSize = windowSize, + onNavigateToHome = onNavigateToHome, + onAssignmentClick = { subSectionBlock -> + viewModel.courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = viewModel.courseId, + subSectionId = subSectionBlock.id, + mode = CourseViewMode.FULL + ) + viewModel.logAssignmentClick(subSectionBlock.id) + }, + ) +} + +@Composable +private fun CourseContentAssignmentScreen( + uiState: CourseAssignmentUIState, + windowSize: WindowSize, + onNavigateToHome: () -> Unit, + onAssignmentClick: (Block) -> Unit, +) { + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + when (uiState) { + is CourseAssignmentUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is CourseAssignmentUIState.Empty -> { + CourseContentAssignmentEmptyState( + onReturnToCourseClick = onNavigateToHome + ) + } + + is CourseAssignmentUIState.CourseData -> { + val gradingPolicy = uiState.courseProgress.gradingPolicy + val defaultGradeColor = MaterialTheme.appColors.primary + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + val progress = uiState.progress + val description = stringResource( + id = R.string.course_completed, + progress.completed, + progress.total + ) + LazyColumn( + modifier = screenWidth, + contentPadding = PaddingValues(bottom = 16.dp) + ) { + item { + Column { + CourseProgress( + modifier = Modifier.padding(horizontal = 24.dp), + progress = progress, + description = description + ) + Spacer(modifier = Modifier.padding(vertical = 6.dp)) + Divider( + color = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.padding(vertical = 4.dp)) + } + } + uiState.groupedAssignments.onEachIndexed { index, (type, blocks) -> + val percentOfGrade = gradingPolicy?.assignmentPolicies + ?.find { it.type == type } + ?.weight?.times(100) + ?.toInt() ?: 0 + val gradeColor = + if (gradingPolicy?.assignmentColors?.isNotEmpty() == true) { + gradingPolicy.assignmentColors[index % gradingPolicy.assignmentColors.size] + } else { + defaultGradeColor + } + item { + AssignmentGroupSection( + label = type, + percentOfGrade = percentOfGrade, + gradeColor = gradeColor, + assignments = blocks, + sectionNames = uiState.sectionNames, + onAssignmentClick = onAssignmentClick, + ) + } + } + } + } + } + } +} + +@Composable +private fun AssignmentGroupSection( + label: String, + assignments: List, + sectionNames: Map, + percentOfGrade: Int, + gradeColor: Color, + onAssignmentClick: (Block) -> Unit, +) { + val progress = Progress( + total = assignments.size, + completed = assignments.filter { it.isCompleted() }.size + ) + val description = stringResource( + id = R.string.course_completed, + progress.completed, + progress.total + ) + val firstUncompletedId = assignments.firstOrNull { !it.isCompleted() }?.id + var selectedId by rememberSaveable(label) { mutableStateOf(firstUncompletedId) } + var isCompletedShown by rememberSaveable { mutableStateOf(false) } + + Column( + modifier = Modifier + .animateContentSize() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier.weight(1f), + text = label, + style = MaterialTheme.appTypography.headlineSmall, + color = MaterialTheme.appColors.textDark, + ) + Surface( + modifier = Modifier.padding(start = 8.dp), + color = gradeColor.copy(alpha = 0.1f), + border = BorderStroke(1.dp, gradeColor), + shape = MaterialTheme.appShapes.material.small + ) { + Text( + modifier = Modifier.padding(4.dp), + text = stringResource(R.string.course_of_grade, percentOfGrade), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall, + maxLines = 1 + ) + } + } + Spacer(modifier = Modifier.padding(vertical = 12.dp)) + CourseProgress( + modifier = Modifier + .padding(horizontal = 24.dp), + progress = progress, + description = description, + isCompletedShown = isCompletedShown, + onVisibilityChanged = if (progress.value == 1f) { + { isCompletedShown = !isCompletedShown } + } else { + null + }, + ) + if (isCompletedShown || progress.value != 1f) { + if (assignments.size > 1) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 24.dp) + ) { + items(assignments) { assignment -> + AssignmentButton( + assignment = assignment, + isSelected = assignment.id == selectedId, + onClick = { + selectedId = if (selectedId == assignment.id) { + null + } else { + assignment.id + } + } + ) + } + } + } + if (assignments.size > 1) { + // Show details for selected assignment in this group + assignments.find { it.id == selectedId }?.let { assignment -> + AssignmentDetails( + modifier = Modifier + .padding(horizontal = 24.dp), + assignment = assignment, + sectionName = sectionNames[assignment.id] ?: "", + onAssignmentClick = onAssignmentClick + ) + } + } else { + val assignment = assignments.firstOrNull() ?: return@Column + AssignmentDetails( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 8.dp), + assignment = assignment, + sectionName = sectionNames[assignment.id] ?: "", + onAssignmentClick = onAssignmentClick + ) + } + } + Divider( + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.appColors.divider + ) + } +} + +@Composable +private fun AssignmentButton(assignment: Block, isSelected: Boolean, onClick: () -> Unit) { + val isDuePast = assignment.due != null && assignment.due!! < Date() + val cardBorderColor = when { + isSelected -> MaterialTheme.appColors.primary + assignment.isCompleted() -> MaterialTheme.appColors.successGreen + isDuePast -> MaterialTheme.appColors.warning + else -> MaterialTheme.appColors.textDark + } + val icon = when { + assignment.isCompleted() -> painterResource(id = coreR.drawable.ic_core_check) + isDuePast -> painterResource(id = coreR.drawable.ic_core_watch_later) + else -> null + } + val iconDescription = when { + assignment.isCompleted() -> stringResource(R.string.course_accessibility_assignment_completed) + isDuePast -> stringResource(R.string.course_accessibility_assignment_completed) + else -> null + } + val borderWidth = when { + isSelected -> 2.dp + else -> 1.dp + } + val cardBackground = when { + assignment.isCompleted() -> MaterialTheme.appColors.successGreen.copy( + ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA + ) + + isDuePast -> MaterialTheme.appColors.warning.copy(ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA) + else -> MaterialTheme.appColors.cardViewBackground + } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + contentAlignment = Alignment.TopCenter, + ) { + Card( + modifier = Modifier + .width(60.dp) + .height(42.dp) + .clickable { + onClick() + }, + backgroundColor = cardBackground, + shape = MaterialTheme.appShapes.material.small, + border = BorderStroke( + width = borderWidth, + color = cardBorderColor + ), + elevation = 0.dp, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(2.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = assignment.assignmentProgress?.label ?: "", + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + if (icon != null) { + Image( + modifier = Modifier + .size(16.dp) + .offset(y = (-6).dp), + painter = icon, + contentDescription = iconDescription, + ) + } + } + if (isSelected) { + Icon( + modifier = Modifier + .size(POINTER_ICON_SIZE_DP.dp) + .padding(top = POINTER_ICON_PADDING_TOP_DP.dp), + painter = painterResource(id = coreR.drawable.ic_core_pointer), + tint = MaterialTheme.appColors.primary, + contentDescription = null + ) + } else { + Box( + modifier = Modifier + .size(POINTER_ICON_SIZE_DP.dp) + .padding(top = POINTER_ICON_PADDING_TOP_DP.dp) + ) + } + } +} + +@Composable +private fun AssignmentDetails( + modifier: Modifier = Modifier, + assignment: Block, + sectionName: String, + onAssignmentClick: (Block) -> Unit, +) { + val dueDate = + assignment.due?.let { + TimeUtils.formatToDueInString(LocalContext.current, it) + } ?: "" + val isDuePast = assignment.due != null && assignment.due!! < Date() + val progress = assignment.completion.toFloat() + val color = when { + assignment.isCompleted() -> MaterialTheme.appColors.successGreen + isDuePast -> MaterialTheme.appColors.warning + else -> MaterialTheme.appColors.cardViewBorder + } + val label = assignment.assignmentProgress?.label + val description = when { + assignment.isCompleted() -> { + "$label " + stringResource( + R.string.course_complete_points, + assignment.assignmentProgress?.toPointString() ?: "" + ) + } + + isDuePast -> { + "$label " + stringResource( + R.string.course_past_due, + assignment.assignmentProgress?.toPointString() ?: "" + ) + } + + progress < 1f && assignment.due == null -> { + "$label " + stringResource( + R.string.course_in_progress, + assignment.assignmentProgress?.toPointString() ?: "" + ) + } + + else -> { + "$label $dueDate" + } + } + Card( + modifier = modifier + .fillMaxWidth() + .clickable { + onAssignmentClick(assignment) + }, + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.material.small, + border = BorderStroke( + width = 1.dp, + color = color + ), + elevation = 0.dp, + ) { + Column { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(PROGRESS_HEIGHT_DP.dp), + progress = progress, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = color + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = sectionName, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark + ) + Text( + text = assignment.displayName, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textDark + ) + if (description.isNotEmpty()) { + Text( + text = description, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark + ) + } + } + Icon( + modifier = Modifier + .size(ICON_SIZE_DP.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.appColors.primary + ) + } + } + } +} + +@Preview +@Composable +private fun CourseContentAssignmentScreenPreview() { + OpenEdXTheme { + CourseContentAssignmentScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseAssignmentUIState.CourseData( + progress = Progress(COMPLETED_ASSIGNMENTS_COUNT, TOTAL_ASSIGNMENTS_COUNT), + groupedAssignments = mapOf( + "Homework" to listOf(mockChapterBlock, mockSequentialBlock) + ), + courseProgress = mockCourseProgress, + sectionNames = mapOf() + ), + onAssignmentClick = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview +@Composable +private fun CourseContentAssignmentScreenEmptyPreview() { + OpenEdXTheme { + CourseContentAssignmentScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = CourseAssignmentUIState.Empty, + onAssignmentClick = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(device = Devices.NEXUS_9) +@Composable +private fun CourseContentAssignmentScreenTabletPreview() { + OpenEdXTheme { + CourseContentAssignmentScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = CourseAssignmentUIState.CourseData( + progress = Progress(COMPLETED_ASSIGNMENTS_COUNT_TABLET, TOTAL_ASSIGNMENTS_COUNT), + groupedAssignments = mapOf( + "Homework" to listOf(mockChapterBlock), + "Quiz" to listOf(mockSequentialBlock) + ), + courseProgress = mockCourseProgress, + sectionNames = mapOf() + ), + onAssignmentClick = {}, + onNavigateToHome = {}, + ) + } +} + +private val mockCourseProgress = CourseProgress( + verifiedMode = "verified", + accessExpiration = "2024-12-31", + certificateData = CourseProgress.CertificateData( + certStatus = "downloadable", + certWebViewUrl = "https://example.com/cert", + downloadUrl = "https://example.com/cert.pdf", + certificateAvailableDate = "2024-06-01" + ), + completionSummary = CourseProgress.CompletionSummary( + completeCount = 5, + incompleteCount = 3, + lockedCount = 1 + ), + courseGrade = CourseProgress.CourseGrade( + letterGrade = "B+", + percent = 85.5, + isPassing = true + ), + creditCourseRequirements = "Complete all assignments", + end = "2024-12-31", + enrollmentMode = "verified", + gradingPolicy = CourseProgress.GradingPolicy( + assignmentPolicies = listOf( + CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = 1, + numTotal = 5, + shortLabel = "HW", + type = "Homework", + weight = 0.4 + ), + CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = 0, + numTotal = 3, + shortLabel = "Quiz", + type = "Quiz", + weight = 0.6 + ) + ), + gradeRange = mapOf( + "A" to 0.9f, + "B" to 0.8f, + "C" to 0.7f, + "D" to 0.6f + ), + assignmentColors = listOf(Color(0xFF2196F3), Color(0xFF4CAF50)) + ), + hasScheduledContent = false, + sectionScores = listOf( + CourseProgress.SectionScore( + displayName = "Week 1", + subsections = listOf( + CourseProgress.SectionScore.Subsection( + assignmentType = "Homework", + blockKey = "block1", + displayName = "Homework 1", + hasGradedAssignment = true, + override = "", + learnerHasAccess = true, + numPointsEarned = 8f, + numPointsPossible = 10f, + percentGraded = 80.0, + problemScores = listOf( + CourseProgress.SectionScore.Subsection.ProblemScore( + earned = 8.0, + possible = 10.0 + ) + ), + showCorrectness = "always", + showGrades = true, + url = "https://example.com/hw1" + ) + ) + ) + ), + studioUrl = "https://studio.example.com", + username = "testuser", + userHasPassingGrade = true, + verificationData = CourseProgress.VerificationData( + link = "https://example.com/verify", + status = "approved", + statusDate = "2024-01-15" + ), + disableProgressGraph = false +) + +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" +) + +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) 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 a71d954df..6280cd2fb 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -32,6 +33,7 @@ import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -72,6 +74,7 @@ import org.openedx.core.domain.model.CourseAccessError import org.openedx.core.extension.isFalse import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.RoundTabsBar @@ -83,19 +86,19 @@ import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding +import org.openedx.course.presentation.contenttab.ContentTabScreen import org.openedx.course.presentation.dates.CourseDatesScreen 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 import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import java.util.Date +import org.openedx.core.R as coreR class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -249,6 +252,35 @@ fun CourseDashboard( fragmentManager: FragmentManager, onRefresh: (page: Int) -> Unit, ) { + val refreshing by viewModel.refreshing.collectAsState(true) + val courseImage by viewModel.courseImage.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val requiredTab = when (openTab.uppercase()) { + CourseContainerTab.HOME.name -> CourseContainerTab.HOME + CourseContainerTab.DATES.name -> CourseContainerTab.DATES + CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.PROGRESS.name -> CourseContainerTab.PROGRESS + CourseContainerTab.MORE.name -> CourseContainerTab.MORE + else -> CourseContainerTab.HOME + } + + val pagerState = rememberPagerState( + initialPage = CourseContainerTab.entries.indexOf(requiredTab), + pageCount = { CourseContainerTab.entries.size } + ) + val contentTabPagerState = rememberPagerState( + initialPage = 0, + pageCount = { CourseContentTab.entries.size } + ) + val accessStatus = viewModel.courseAccessStatus.observeAsState() + val tabState = rememberLazyListState() + val snackState = remember { SnackbarHostState() } + var selectedContentTab by remember { mutableStateOf(CourseContentTab.ALL) } + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onRefresh(pagerState.currentPage) } + ) + OpenEdXTheme { val windowSize = rememberWindowSize() val scope = rememberCoroutineScope() @@ -258,32 +290,51 @@ fun CourseDashboard( .fillMaxSize() .navigationBarsPadding(), scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background - ) { paddingValues -> - val refreshing by viewModel.refreshing.collectAsState(true) - val courseImage by viewModel.courseImage.collectAsState() - val uiMessage by viewModel.uiMessage.collectAsState(null) - val requiredTab = when (openTab.uppercase()) { - CourseContainerTab.HOME.name -> CourseContainerTab.HOME - 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 + backgroundColor = MaterialTheme.appColors.background, + bottomBar = { + Box { + if (CourseContainerTab.entries[pagerState.currentPage] == CourseContainerTab.CONTENT && + selectedContentTab == CourseContentTab.ASSIGNMENTS + ) { + Column( + modifier = Modifier.background(MaterialTheme.appColors.background), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Divider(modifier = Modifier.fillMaxWidth()) + TextButton( + onClick = { + scrollToProgress(scope, pagerState) + } + ) { + IconText( + text = stringResource(R.string.course_review_grading_policy), + painter = painterResource(id = coreR.drawable.core_ic_mountains), + color = MaterialTheme.appColors.primary, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } + } + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = viewModel.hasInternetConnection + onRefresh(pagerState.currentPage) + } + ) + } + } } - - val pagerState = rememberPagerState( - initialPage = CourseContainerTab.entries.indexOf(requiredTab), - pageCount = { CourseContainerTab.entries.size } - ) - val accessStatus = viewModel.courseAccessStatus.observeAsState() - val tabState = rememberLazyListState() - val snackState = remember { SnackbarHostState() } - val pullRefreshState = rememberPullRefreshState( - refreshing = refreshing, - onRefresh = { onRefresh(pagerState.currentPage) } - ) + ) { paddingValues -> if (uiMessage is DatesShiftedSnackBar) { val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) LaunchedEffect(uiMessage) { @@ -359,14 +410,16 @@ fun CourseDashboard( windowSize = windowSize, viewModel = viewModel, pagerState = pagerState, - isNavigationEnabled = isNavigationEnabled, + contentTabPagerState = contentTabPagerState, isResumed = isResumed, fragmentManager = fragmentManager, + onContentTabSelected = { tab -> + selectedContentTab = tab + } ) } - else -> { - } + else -> {} } } ) @@ -376,24 +429,6 @@ fun CourseDashboard( Modifier.align(Alignment.TopCenter) ) - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = viewModel.hasInternetConnection - onRefresh(pagerState.currentPage) - } - ) - } - SnackbarHost( modifier = Modifier.align(Alignment.BottomStart), hostState = snackState @@ -420,37 +455,21 @@ private fun DashboardPager( windowSize: WindowSize, viewModel: CourseContainerViewModel, pagerState: PagerState, - isNavigationEnabled: Boolean, + contentTabPagerState: PagerState, isResumed: Boolean, fragmentManager: FragmentManager, + onContentTabSelected: (CourseContentTab) -> Unit, ) { + val scope = rememberCoroutineScope() + HorizontalPager( state = pagerState, - userScrollEnabled = isNavigationEnabled, + userScrollEnabled = false, beyondViewportPageCount = CourseContainerTab.entries.size ) { page -> when (CourseContainerTab.entries[page]) { CourseContainerTab.HOME -> { - CourseOutlineScreen( - windowSize = windowSize, - viewModel = koinViewModel( - parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } - ), - fragmentManager = fragmentManager, - onResetDatesClick = { - viewModel.onRefresh(CourseContainerTab.DATES) - } - ) - } - - CourseContainerTab.VIDEOS -> { - CourseVideosScreen( - windowSize = windowSize, - viewModel = koinViewModel( - parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } - ), - fragmentManager = fragmentManager - ) + // Home tab content will be implemented later } CourseContainerTab.DATES -> { @@ -519,6 +538,29 @@ private fun DashboardPager( } ) } + + CourseContainerTab.CONTENT -> { + ContentTabScreen( + viewModel = koinViewModel( + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } + ), + windowSize = windowSize, + fragmentManager = fragmentManager, + courseId = viewModel.courseId, + courseName = viewModel.courseName, + pagerState = contentTabPagerState, + onTabSelected = onContentTabSelected, + onNavigateToHome = { + scope.launch { + pagerState.animateScrollToPage( + CourseContainerTab.entries.indexOf( + CourseContainerTab.HOME + ) + ) + } + } + ) + } } } } @@ -642,3 +684,10 @@ private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } + +@OptIn(ExperimentalFoundationApi::class) +private fun scrollToProgress(scope: CoroutineScope, pagerState: PagerState) { + scope.launch { + pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.PROGRESS)) + } +} 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 236c548f6..f7abc1981 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 @@ -3,12 +3,12 @@ package org.openedx.course.presentation.container import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.List 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 import org.openedx.core.ui.TabItem import org.openedx.course.R @@ -19,10 +19,19 @@ enum class CourseContainerTab( override val icon: ImageVector, ) : TabItem { HOME(R.string.course_container_nav_home, Icons.Default.Home), - VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + CONTENT(R.string.course_container_nav_content, Icons.AutoMirrored.Filled.List), 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), } + +enum class CourseContentTab( + @StringRes + val labelResId: Int +) { + ALL(R.string.course_container_content_tab_all), + VIDEOS(R.string.course_container_content_tab_video), + ASSIGNMENTS(R.string.course_container_content_tab_assignment) +} 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 18f5f9b3c..ff9643bd4 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 @@ -284,7 +284,7 @@ class CourseContainerViewModel( updateData() } - CourseContainerTab.VIDEOS -> { + CourseContainerTab.CONTENT -> { updateData() } @@ -332,12 +332,12 @@ class CourseContainerViewModel( fun courseContainerTabClickedEvent(index: Int) { when (CourseContainerTab.entries[index]) { CourseContainerTab.HOME -> courseTabClickedEvent() - CourseContainerTab.VIDEOS -> videoTabClickedEvent() CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() CourseContainerTab.PROGRESS -> progressTabClickedEvent() CourseContainerTab.MORE -> moreTabClickedEvent() - CourseContainerTab.OFFLINE -> {} + CourseContainerTab.OFFLINE -> offlineTabClickedEvent() + CourseContainerTab.CONTENT -> contentTabClickedEvent() } } @@ -373,10 +373,6 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.HOME_TAB) } - private fun videoTabClickedEvent() { - logCourseContainerEvent(CourseAnalyticsEvent.VIDEOS_TAB) - } - private fun discussionTabClickedEvent() { logCourseContainerEvent(CourseAnalyticsEvent.DISCUSSION_TAB) } @@ -393,6 +389,13 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.PROGRESS_TAB) } + private fun offlineTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.OFFLINE_TAB) + } + + private fun contentTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.CONTENT_TAB) + } private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { courseAnalytics.logScreenEvent( screenName = event.eventName, diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt new file mode 100644 index 000000000..e5926b315 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabEmptyState.kt @@ -0,0 +1,122 @@ +package org.openedx.course.presentation.contenttab + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.course.R + +@Composable +fun ContentTabEmptyState( + message: String, + onReturnToCourseClick: () -> Unit +) { + val configuration = LocalConfiguration.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + Icon( + modifier = Modifier + .size(120.dp), + painter = painterResource(R.drawable.course_ic_warning), + contentDescription = null, + tint = MaterialTheme.appColors.textFieldHint + ) + Spacer(Modifier.height(24.dp)) + } + Text( + modifier = Modifier.padding(horizontal = 24.dp), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + textColor = MaterialTheme.appColors.secondaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = onReturnToCourseClick + ) { + IconText( + text = stringResource(id = R.string.course_return_to_course_home), + icon = Icons.AutoMirrored.Filled.ArrowBack, + color = MaterialTheme.appColors.secondaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + } +} + +@Composable +fun CourseContentAllEmptyState( + onReturnToCourseClick: () -> Unit +) { + ContentTabEmptyState( + message = stringResource(id = org.openedx.core.R.string.core_no_course_content), + onReturnToCourseClick = onReturnToCourseClick + ) +} + +@Composable +fun CourseContentVideoEmptyState( + onReturnToCourseClick: () -> Unit +) { + ContentTabEmptyState( + message = stringResource(id = org.openedx.core.R.string.core_no_videos), + onReturnToCourseClick = onReturnToCourseClick + ) +} + +@Composable +fun CourseContentAssignmentEmptyState( + onReturnToCourseClick: () -> Unit +) { + ContentTabEmptyState( + message = stringResource(id = org.openedx.core.R.string.core_no_assignments), + onReturnToCourseClick = onReturnToCourseClick + ) +} + +@Preview +@Composable +private fun CourseContentAllEmptyStatePreview() { + OpenEdXTheme { + CourseContentAllEmptyState({}) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabScreen.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabScreen.kt new file mode 100644 index 000000000..ad648da36 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabScreen.kt @@ -0,0 +1,189 @@ +package org.openedx.course.presentation.contenttab + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.launch +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.course.presentation.assignments.CourseContentAssignmentScreen +import org.openedx.course.presentation.container.CourseContentTab +import org.openedx.course.presentation.outline.CourseContentAllScreen +import org.openedx.course.presentation.videos.CourseContentVideoScreen +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@Composable +fun ContentTabScreen( + viewModel: ContentTabViewModel, + windowSize: WindowSize, + fragmentManager: FragmentManager, + courseId: String, + courseName: String, + pagerState: PagerState, + onTabSelected: (CourseContentTab) -> Unit = {}, + onNavigateToHome: () -> Unit = {}, +) { + val tabsWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + val scope = rememberCoroutineScope() + + LaunchedEffect(pagerState.currentPage) { + val selectedTab = CourseContentTab.entries[pagerState.currentPage] + onTabSelected(selectedTab) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .padding(16.dp) + .then(tabsWidth) + .height(40.dp) + .clip(MaterialTheme.appShapes.buttonShape) + .border( + 1.dp, + MaterialTheme.appColors.primary, + MaterialTheme.appShapes.buttonShape + ), + horizontalArrangement = Arrangement.Center + ) { + CourseContentTab.entries.forEachIndexed { index, tab -> + val isSelected = pagerState.currentPage == index + val isEdgeItem = index == 0 || index == CourseContentTab.entries.size - 1 + Box( + modifier = Modifier + .background( + if (isSelected) { + MaterialTheme.appColors.primary + } else { + MaterialTheme.appColors.background + } + ) + .weight(1f) + .fillMaxHeight() + .clickable { + scope.launch { + pagerState.scrollToPage(index) + } + viewModel.logTabClickEvent(CourseContentTab.entries[index]) + }, + contentAlignment = Alignment.Center + ) { + if (!isEdgeItem) { + Divider( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .align(Alignment.CenterStart), + color = MaterialTheme.appColors.primary + ) + } + Text( + text = stringResource(tab.labelResId), + color = if (isSelected) { + MaterialTheme.appColors.primaryButtonText + } else { + MaterialTheme.appColors.primary + }, + style = MaterialTheme.typography.button + ) + if (!isEdgeItem) { + Divider( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .align(Alignment.CenterEnd), + color = MaterialTheme.appColors.primary + ) + } + } + } + } + + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondViewportPageCount = CourseContentTab.entries.size + ) { page -> + when (CourseContentTab.entries[page]) { + CourseContentTab.ALL -> CourseContentAllScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { + parametersOf( + courseId, + courseName + ) + }), + fragmentManager = fragmentManager, + onNavigateToHome = onNavigateToHome + ) + + CourseContentTab.VIDEOS -> CourseContentVideoScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { + parametersOf( + courseId, + courseName + ) + }), + fragmentManager = fragmentManager, + onNavigateToHome = onNavigateToHome + ) + + CourseContentTab.ASSIGNMENTS -> CourseContentAssignmentScreen( + windowSize = windowSize, + viewModel = koinViewModel(parameters = { parametersOf(courseId) }), + fragmentManager = fragmentManager, + onNavigateToHome = onNavigateToHome + ) + } + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt new file mode 100644 index 000000000..7aebe86f3 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt @@ -0,0 +1,29 @@ +package org.openedx.course.presentation.contenttab + +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.container.CourseContentTab +import org.openedx.foundation.presentation.BaseViewModel + +class ContentTabViewModel( + val courseId: String, + private val courseTitle: String, + private val analytics: CourseAnalytics, +) : BaseViewModel() { + + fun logTabClickEvent(contentTab: CourseContentTab) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_TAB_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_TAB_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.COURSE_NAME.key, courseTitle) + put(CourseAnalyticsKey.TAB_NAME.key, contentTab.name) + } + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt similarity index 70% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index 0bb3c0593..82e69dfd0 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -4,21 +4,16 @@ import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface @@ -34,20 +29,18 @@ 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.platform.AndroidUriHandler import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -56,10 +49,10 @@ import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.OfflineDownload import org.openedx.core.domain.model.Progress +import org.openedx.core.extension.getChapterBlocks import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.NoContentScreen import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.TextIcon import org.openedx.core.ui.displayCutoutForLandscape @@ -67,9 +60,11 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentAllEmptyState import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage +import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseSection import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.UIMessage @@ -79,11 +74,11 @@ import org.openedx.foundation.presentation.windowSizeValue import java.util.Date @Composable -fun CourseOutlineScreen( +fun CourseContentAllScreen( windowSize: WindowSize, - viewModel: CourseOutlineViewModel, + viewModel: CourseContentAllViewModel, fragmentManager: FragmentManager, - onResetDatesClick: () -> Unit, + onNavigateToHome: () -> Unit = {}, ) { val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) @@ -96,10 +91,11 @@ fun CourseOutlineScreen( } } - CourseOutlineUI( + CourseContentAllUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, + onNavigateToHome = onNavigateToHome, onExpandClick = { block -> if (viewModel.switchCourseSections(block.id)) { viewModel.sequentialClickedEvent( @@ -148,11 +144,7 @@ fun CourseOutlineScreen( ) }, onResetDatesClick = { - viewModel.resetCourseDatesBanner( - onResetDates = { - onResetDatesClick() - } - ) + viewModel.resetCourseDatesBanner() }, onCertificateClick = { viewModel.viewCertificateTappedEvent() @@ -163,10 +155,11 @@ fun CourseOutlineScreen( } @Composable -private fun CourseOutlineUI( +private fun CourseContentAllUI( windowSize: WindowSize, - uiState: CourseOutlineUIState, + uiState: CourseContentAllUIState, uiMessage: UIMessage?, + onNavigateToHome: () -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, @@ -224,9 +217,11 @@ private fun CourseOutlineUI( ) { Box { when (uiState) { - is CourseOutlineUIState.CourseData -> { + is CourseContentAllUIState.CourseData -> { if (uiState.courseStructure.blockData.isEmpty()) { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + CourseContentAllEmptyState( + onReturnToCourseClick = onNavigateToHome + ) } else { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -276,40 +271,39 @@ private fun CourseOutlineUI( } } - val progress = uiState.courseStructure.progress - if (progress != null && progress.totalAssignmentsCount > 0) { - item { - CourseProgress( - modifier = Modifier - .fillMaxWidth() - .padding( - top = 16.dp, - start = 24.dp, - end = 24.dp - ), - progress = progress + val sections = + uiState.courseStructure.blockData.getChapterBlocks() + val progress = Progress( + total = sections.size, + completed = sections.filter { it.isCompleted() }.size + ) + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 24.dp, + end = 24.dp + ), + progress = progress, + description = pluralStringResource( + R.plurals.course_sections_complete, + progress.completed, + progress.completed, + progress.total ) - } + ) } if (uiState.resumeComponent != null) { item { Box(listPadding) { - if (windowSize.isTablet) { - ResumeCourseTablet( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } else { - ResumeCourse( - modifier = Modifier.padding(vertical = 16.dp), - block = uiState.resumeComponent, - displayName = uiState.resumeUnitTitle, - onResumeClick = onResumeClick - ) - } + ResumeCourse( + modifier = Modifier.padding(vertical = 16.dp), + block = uiState.resumeComponent, + displayName = uiState.resumeUnitTitle, + onResumeClick = onResumeClick + ) } } } @@ -329,7 +323,7 @@ private fun CourseOutlineUI( block = section, onItemClick = onExpandClick, useRelativeDates = uiState.useRelativeDates, - courseSectionsState = courseSectionsState, + isSectionVisible = courseSectionsState, courseSubSections = courseSubSections, downloadedStateMap = uiState.downloadedState, onSubSectionClick = onSubSectionClick, @@ -341,11 +335,13 @@ private fun CourseOutlineUI( } } - CourseOutlineUIState.Error -> { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_OUTLINE) + CourseContentAllUIState.Error -> { + CourseContentAllEmptyState( + onReturnToCourseClick = onNavigateToHome + ) } - CourseOutlineUIState.Loading -> { + CourseContentAllUIState.Loading -> { CircularProgress() } } @@ -362,139 +358,35 @@ private fun ResumeCourse( displayName: String, onResumeClick: (String) -> Unit, ) { - Column( - modifier = modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.course_continue_with), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - Spacer(Modifier.height(6.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(id = getUnitBlockIcon(block)), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) - Text( - text = displayName, - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(Modifier.height(24.dp)) - OpenEdXButton( - text = stringResource(id = R.string.course_resume), - onClick = { - onResumeClick(block.id) - }, - content = { - TextIcon( - text = stringResource(id = R.string.course_resume), - icon = Icons.AutoMirrored.Filled.ArrowForward, - color = MaterialTheme.appColors.primaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) - } -} - -@Composable -private fun ResumeCourseTablet( - modifier: Modifier = Modifier, - block: Block, - displayName: String, - onResumeClick: (String) -> Unit, -) { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column( - Modifier - .weight(1f) - .padding(end = 35.dp) - ) { - Text( - text = stringResource(id = R.string.course_continue_with), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - Spacer(Modifier.height(6.dp)) + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 54.dp), + onClick = { + onResumeClick(block.id) + }, + content = { Row( - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(4.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Icon( - modifier = Modifier.size(size = (MaterialTheme.appTypography.titleMedium.fontSize.value + 4).dp), - painter = painterResource(id = getUnitBlockIcon(block)), - contentDescription = null, - tint = MaterialTheme.appColors.textPrimary - ) Text( + modifier = Modifier.weight(1f), text = displayName, - color = MaterialTheme.appColors.textPrimary, + color = MaterialTheme.appColors.primaryButtonText, style = MaterialTheme.appTypography.titleMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 4 + fontWeight = FontWeight.W600 ) - } - } - OpenEdXButton( - modifier = Modifier.width(210.dp), - text = stringResource(id = R.string.course_resume), - onClick = { - onResumeClick(block.id) - }, - content = { TextIcon( - text = stringResource(id = R.string.course_resume), + text = stringResource(id = R.string.course_continue), icon = Icons.AutoMirrored.Filled.ArrowForward, color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge ) } - ) - } -} - -@Composable -private fun CourseProgress( - modifier: Modifier = Modifier, - progress: Progress, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(10.dp) - .clip(CircleShape), - progress = progress.value, - color = MaterialTheme.appColors.progressBarColor, - backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor - ) - Text( - text = pluralStringResource( - R.plurals.course_assignments_complete, - progress.assignmentsCompleted, - progress.assignmentsCompleted, - progress.totalAssignmentsCount - ), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.labelSmall - ) - } + } + ) } fun getUnitBlockIcon(block: Block): Int { @@ -511,9 +403,9 @@ fun getUnitBlockIcon(block: Block): Int { @Composable private fun CourseOutlineScreenPreview() { OpenEdXTheme { - CourseOutlineUI( + CourseContentAllUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = CourseOutlineUIState.CourseData( + uiState = CourseContentAllUIState.CourseData( mockCourseStructure, mapOf(), mockChapterBlock, @@ -537,6 +429,7 @@ private fun CourseOutlineScreenPreview() { onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onNavigateToHome = {}, ) } } @@ -544,11 +437,11 @@ private fun CourseOutlineScreenPreview() { @Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) @Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable -private fun CourseOutlineScreenTabletPreview() { +private fun CourseContentAllScreenTabletPreview() { OpenEdXTheme { - CourseOutlineUI( + CourseContentAllUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = CourseOutlineUIState.CourseData( + uiState = CourseContentAllUIState.CourseData( mockCourseStructure, mapOf(), mockChapterBlock, @@ -572,6 +465,7 @@ private fun CourseOutlineScreenTabletPreview() { onDownloadClick = {}, onResetDatesClick = {}, onCertificateClick = {}, + onNavigateToHome = {}, ) } } @@ -588,7 +482,8 @@ private fun ResumeCoursePreview() { private val mockAssignmentProgress = AssignmentProgress( assignmentType = "Home", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HM1" ) private val mockChapterBlock = Block( id = "id", diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt similarity index 80% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt index 55cf52137..9a2deed32 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt @@ -5,7 +5,7 @@ import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState -sealed class CourseOutlineUIState { +sealed class CourseContentAllUIState { data class CourseData( val courseStructure: CourseStructure, val downloadedState: Map, @@ -16,8 +16,8 @@ sealed class CourseOutlineUIState { val subSectionsDownloadsCount: Map, val datesBannerInfo: CourseDatesBannerInfo, val useRelativeDates: Boolean, - ) : CourseOutlineUIState() + ) : CourseContentAllUIState() - data object Error : CourseOutlineUIState() - data object Loading : CourseOutlineUIState() + data object Error : CourseContentAllUIState() + data object Loading : CourseContentAllUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt similarity index 93% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt index 50fedd2dc..2c966a0cf 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -20,6 +20,7 @@ import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.extension.getChapterBlocks import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.module.DownloadWorkerController @@ -47,7 +48,7 @@ import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import org.openedx.course.R as courseR -class CourseOutlineViewModel( +class CourseContentAllViewModel( val courseId: String, private val courseTitle: String, private val config: Config, @@ -73,8 +74,9 @@ class CourseOutlineViewModel( ) { val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled - private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) - val uiState: StateFlow + private val _uiState = + MutableStateFlow(CourseContentAllUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() @@ -115,9 +117,9 @@ class CourseOutlineViewModel( viewModelScope.launch { downloadModelsStatusFlow.collect { - if (_uiState.value is CourseOutlineUIState.CourseData) { - val state = _uiState.value as CourseOutlineUIState.CourseData - _uiState.value = CourseOutlineUIState.CourseData( + if (_uiState.value is CourseContentAllUIState.CourseData) { + val state = _uiState.value as CourseContentAllUIState.CourseData + _uiState.value = CourseContentAllUIState.CourseData( courseStructure = state.courseStructure, downloadedState = it.toMap(), resumeComponent = state.resumeComponent, @@ -158,12 +160,12 @@ class CourseOutlineViewModel( } fun switchCourseSections(blockId: String): Boolean { - return if (_uiState.value is CourseOutlineUIState.CourseData) { - val state = _uiState.value as CourseOutlineUIState.CourseData + return if (_uiState.value is CourseContentAllUIState.CourseData) { + val state = _uiState.value as CourseContentAllUIState.CourseData val courseSectionsState = state.courseSectionsState.toMutableMap() courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - _uiState.value = CourseOutlineUIState.CourseData( + _uiState.value = CourseContentAllUIState.CourseData( courseStructure = state.courseStructure, downloadedState = state.downloadedState, resumeComponent = state.resumeComponent, @@ -221,9 +223,10 @@ class CourseOutlineViewModel( initDownloadModelsStatus() val courseSectionsState = - (_uiState.value as? CourseOutlineUIState.CourseData)?.courseSectionsState.orEmpty() + (_uiState.value as? CourseContentAllUIState.CourseData)?.courseSectionsState + ?: blocks.getChapterBlocks().associate { it.id to !it.isCompleted() } - _uiState.value = CourseOutlineUIState.CourseData( + _uiState.value = CourseContentAllUIState.CourseData( courseStructure = sortedStructure, downloadedState = getDownloadModelsStatus(), resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), @@ -237,7 +240,7 @@ class CourseOutlineViewModel( } private suspend fun handleCourseDataError(e: Throwable?) { - _uiState.value = CourseOutlineUIState.Error + _uiState.value = CourseContentAllUIState.Error val errorMessage = when { e?.isInternetError() == true -> R.string.core_error_no_connection else -> R.string.core_error_unknown_error @@ -286,13 +289,12 @@ class CourseOutlineViewModel( return resumeBlock } - fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { + fun resetCourseDatesBanner() { viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) getCourseData() courseNotifier.send(CourseDatesShifted) - onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit( @@ -307,7 +309,6 @@ class CourseOutlineViewModel( ) ) } - onResetDates(false) } } } @@ -359,7 +360,7 @@ class CourseOutlineViewModel( private fun resumeCourseTappedEvent(blockId: String) { val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { + if (currentState is CourseContentAllUIState.CourseData) { analytics.logEvent( CourseAnalyticsEvent.RESUME_COURSE_CLICKED.eventName, buildMap { @@ -377,7 +378,7 @@ class CourseOutlineViewModel( fun sequentialClickedEvent(blockId: String, blockName: String) { val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { + if (currentState is CourseContentAllUIState.CourseData) { analytics.sequentialClickedEvent( courseId, currentState.courseStructure.name, @@ -389,7 +390,7 @@ class CourseOutlineViewModel( fun logUnitDetailViewedEvent(blockId: String, blockName: String) { val currentState = uiState.value - if (currentState is CourseOutlineUIState.CourseData) { + if (currentState is CourseContentAllUIState.CourseData) { analytics.logEvent( CourseAnalyticsEvent.UNIT_DETAIL.eventName, buildMap { @@ -417,7 +418,7 @@ class CourseOutlineViewModel( fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { viewModelScope.launch { - val courseData = _uiState.value as? CourseOutlineUIState.CourseData ?: return@launch + val courseData = _uiState.value as? CourseContentAllUIState.CourseData ?: return@launch val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } 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 index 57b13d80b..47a01e416 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -449,7 +449,7 @@ private fun CourseCompletionView( ) .padding(3.dp), progress = progress.completion, - color = MaterialTheme.appColors.progressBarColor, + color = MaterialTheme.appColors.primary, backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor, strokeWidth = 10.dp, strokeCap = StrokeCap.Round @@ -513,7 +513,7 @@ private fun AssignmentTypeRow( ) { Text( text = stringResource( - R.string.progress_earned_possible_assignment_problems, + R.string.course_progress_earned_possible_assignment_problems, earned.toInt(), possible.toInt() ), @@ -526,7 +526,7 @@ private fun AssignmentTypeRow( append("${(policy.weight * 100).toInt()}%") } append(" ") - append(stringResource(R.string.progress_of_grade)) + append(stringResource(R.string.course_progress_of_grade)) }, style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.textDark, @@ -534,7 +534,7 @@ private fun AssignmentTypeRow( } Text( stringResource( - R.string.progress_current_and_max_weighted_graded_percent, + R.string.course_progress_current_and_max_weighted_graded_percent, progress.getAssignmentWeightedGradedPercent(policy).toInt(), (policy.weight * 100).toInt() ), diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index d1f784227..0fb24ebd6 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -405,7 +405,7 @@ private val mockBlock = Block( descendantsType = BlockType.HTML, completion = 0.0, containsGatedContent = false, - assignmentProgress = AssignmentProgress("", 1f, 2f), + assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), due = Date(), offlineDownload = null ) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 755ecbafa..54075d183 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -14,12 +14,15 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.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.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -27,7 +30,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -62,8 +68,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -78,12 +86,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import coil.compose.AsyncImage +import coil.request.ImageRequest import org.jsoup.Jsoup import org.openedx.core.BlockType import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.Progress import org.openedx.core.extension.safeDivBy import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState @@ -99,6 +110,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils +import org.openedx.core.utils.VideoPreview import org.openedx.course.R import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.getUnitBlockIcon @@ -155,15 +167,6 @@ fun CourseSectionCard( tint = completedIconColor ) Spacer(modifier = Modifier.width(16.dp)) - Text( - modifier = Modifier.weight(1f), - text = block.displayName, - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.width(16.dp)) Row( modifier = Modifier.fillMaxHeight(), horizontalArrangement = Arrangement.spacedBy(24.dp), @@ -177,11 +180,12 @@ fun CourseSectionCard( } else { Icons.Outlined.CloudDownload } - val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } + val downloadIconDescription = + if (downloadedState == DownloadedState.DOWNLOADED) { + stringResource(id = R.string.course_accessibility_remove_course_section) + } else { + stringResource(id = R.string.course_accessibility_download_course_section) + } IconButton( modifier = iconModifier, onClick = { onDownloadClick(block) } @@ -211,7 +215,7 @@ fun CourseSectionCard( Icon( imageVector = Icons.Filled.Close, contentDescription = - stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + stringResource(id = R.string.course_accessibility_stop_downloading_course_section), tint = MaterialTheme.appColors.error ) } @@ -300,11 +304,12 @@ fun OfflineQueueCard( @Composable fun CardArrow( degrees: Float, + tint: Color = MaterialTheme.appColors.textDark, ) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - tint = MaterialTheme.appColors.textDark, - contentDescription = "Expandable Arrow", + tint = tint, + contentDescription = null, modifier = Modifier.rotate(degrees), ) } @@ -604,20 +609,308 @@ fun VideoSubtitles( } } +@Composable +fun CourseVideoSection( + block: Block, + videoBlocks: List, + preview: Map, + progress: Map, + downloadedStateMap: Map, + onVideoClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, +) { + val state = rememberLazyListState() + val subSectionIds = videoBlocks.map { it.id } + val filteredStatuses = downloadedStateMap.filterKeys { it in subSectionIds }.values + val downloadedState = when { + filteredStatuses.isEmpty() -> null + filteredStatuses.all { it.isDownloaded } -> DownloadedState.DOWNLOADED + filteredStatuses.any { it.isWaitingOrDownloading } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + + LaunchedEffect(Unit) { + try { + val uncompletedBlockIndex = videoBlocks.indexOf(videoBlocks.find { !it.isCompleted() }) + state.scrollToItem(uncompletedBlockIndex) + } catch (e: Exception) { + e.printStackTrace() + } + } + + Column( + modifier = Modifier.padding(vertical = 8.dp) + ) { + CourseVideoSectionHeader( + block = block, + downloadedState = downloadedState, + videoBlocks = videoBlocks, + onDownloadClick = { + onDownloadClick(block.descendants) + } + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues( + top = 8.dp, + bottom = 16.dp, + start = 16.dp, + end = 16.dp, + ) + ) { + items(videoBlocks) { block -> + CourseVideoItem( + videoBlock = block, + preview = preview[block.id], + progress = progress[block.id] ?: 0f, + onClick = { + onVideoClick(block) + } + ) + } + } + Divider(modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +fun CourseVideoItem( + videoBlock: Block, + preview: VideoPreview?, + progress: Float, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .width(192.dp) + .height(108.dp) + .clip(MaterialTheme.appShapes.videoPreviewShape) + .let { + if (videoBlock.isCompleted()) { + it.border( + width = 3.dp, + color = MaterialTheme.appColors.successGreen, + shape = MaterialTheme.appShapes.videoPreviewShape + ) + } else { + it + } + } + .clickable { onClick() } + ) { + AsyncImage( + modifier = Modifier + .fillMaxSize(), + model = ImageRequest.Builder(LocalContext.current) + .data(preview?.link ?: preview?.bitmap) + .error(coreR.drawable.core_no_image_course) + .placeholder(coreR.drawable.core_no_image_course) + .build(), + contentDescription = stringResource(R.string.course_accessibility_video_player), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.6f), + Color.Transparent, + ), + startY = 0f, + endY = Float.POSITIVE_INFINITY + ) + ) + ) + + Image( + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + painter = painterResource(id = R.drawable.course_video_play_button), + contentDescription = null, + ) + + // Title (top-left) + Text( + text = videoBlock.displayName, + color = Color.White, + style = MaterialTheme.appTypography.bodySmall, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + // Progress bar (bottom) + if (progress > 0.0f) { + Box( + modifier = Modifier + .padding(bottom = 4.dp) + .height(16.dp) + .align(Alignment.BottomCenter), + contentAlignment = Alignment.Center + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .padding(horizontal = 8.dp) + .clip(CircleShape), + progress = progress, + color = if (videoBlock.isCompleted() && progress > 0.95f) { + MaterialTheme.appColors.progressBarColor + } else { + MaterialTheme.appColors.primary + }, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + if (videoBlock.isCompleted()) { + Image( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(16.dp) + .offset(x = (-4).dp), + painter = painterResource(id = coreR.drawable.ic_core_check), + contentDescription = stringResource(R.string.course_accessibility_video_watched), + ) + } + } + } + } +} + +@Composable +fun CourseVideoSectionHeader( + modifier: Modifier = Modifier, + block: Block, + videoBlocks: List?, + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + Row( + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = block.displayName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource( + R.string.course_video_watched, + videoBlocks?.filter { it.isCompleted() }?.size ?: 0, + videoBlocks?.size ?: 0 + ), + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textPrimary, + ) + } + DownloadIcon( + downloadedState = downloadedState, + onDownloadClick = onDownloadClick + ) + } +} + +@Composable +fun DownloadIcon( + downloadedState: DownloadedState?, + onDownloadClick: () -> Unit, +) { + val iconModifier = Modifier.size(24.dp) + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { + val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { + Icons.Default.CloudDone + } else { + Icons.Outlined.CloudDownload + } + val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { + stringResource(id = R.string.course_accessibility_remove_course_section) + } else { + stringResource(id = R.string.course_accessibility_download_course_section) + } + val downloadIconTint = if (downloadedState == DownloadedState.DOWNLOADED) { + MaterialTheme.appColors.successGreen + } else { + MaterialTheme.appColors.textAccent + } + IconButton( + modifier = iconModifier, + onClick = { onDownloadClick() } + ) { + Icon( + imageVector = downloadIcon, + contentDescription = downloadIconDescription, + tint = downloadIconTint + ) + } + } else if (downloadedState != null) { + Box(contentAlignment = Alignment.Center) { + if (downloadedState == DownloadedState.DOWNLOADING) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } else if (downloadedState == DownloadedState.WAITING) { + Icon( + painter = painterResource(id = coreR.drawable.core_download_waiting), + contentDescription = stringResource( + id = R.string.course_accessibility_stop_downloading_course_section + ), + tint = MaterialTheme.appColors.error + ) + } + IconButton( + modifier = iconModifier.padding(2.dp), + onClick = { onDownloadClick() } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource( + id = R.string.course_accessibility_stop_downloading_course_section + ), + tint = MaterialTheme.appColors.error + ) + } + } + } + } +} + @Composable fun CourseSection( modifier: Modifier = Modifier, block: Block, useRelativeDates: Boolean, onItemClick: (Block) -> Unit, - courseSectionsState: Boolean?, + isSectionVisible: Boolean?, courseSubSections: List?, downloadedStateMap: Map, onSubSectionClick: (Block) -> Unit, onDownloadClick: (blocksIds: List) -> Unit, ) { val arrowRotation by animateFloatAsState( - targetValue = if (courseSectionsState == true) { + targetValue = if (isSectionVisible == true) { -90f } else { 90f @@ -633,17 +926,30 @@ fun CourseSection( else -> DownloadedState.NOT_DOWNLOADED } + // Section progress + val completedCount = courseSubSections?.count { it.isCompleted() } ?: 0 + val totalCount = courseSubSections?.size ?: 0 + val progress = if (totalCount > 0) completedCount.toFloat() / totalCount else 0f + Column( modifier = modifier - .clip(MaterialTheme.appShapes.cardShape) + .clip(MaterialTheme.appShapes.sectionCardShape) .noRippleClickable { onItemClick(block) } .background(MaterialTheme.appColors.cardViewBackground) .border( 1.dp, MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape + MaterialTheme.appShapes.sectionCardShape ) ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + progress = progress, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) CourseExpandableChapterCard( block = block, arrowDegrees = arrowRotation, @@ -654,7 +960,7 @@ fun CourseSection( ) courseSubSections?.forEach { subSectionBlock -> AnimatedVisibility( - visible = courseSectionsState == true + visible = isSectionVisible == true ) { CourseSubSectionItem( block = subSectionBlock, @@ -674,7 +980,6 @@ fun CourseExpandableChapterCard( downloadedState: DownloadedState?, onDownloadClick: () -> Unit, ) { - val iconModifier = Modifier.size(24.dp) Row( modifier .fillMaxWidth() @@ -688,7 +993,8 @@ fun CourseExpandableChapterCard( if (block.isCompleted()) { val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) val completedIconColor = MaterialTheme.appColors.successGreen - val completedIconDescription = stringResource(id = R.string.course_accessibility_section_completed) + val completedIconDescription = + stringResource(id = R.string.course_accessibility_section_completed) Icon( painter = completedIconPainter, @@ -704,69 +1010,10 @@ fun CourseExpandableChapterCard( maxLines = 1, overflow = TextOverflow.Ellipsis ) - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically - ) { - if (downloadedState == DownloadedState.DOWNLOADED || downloadedState == DownloadedState.NOT_DOWNLOADED) { - val downloadIcon = if (downloadedState == DownloadedState.DOWNLOADED) { - Icons.Default.CloudDone - } else { - Icons.Outlined.CloudDownload - } - val downloadIconDescription = if (downloadedState == DownloadedState.DOWNLOADED) { - stringResource(id = R.string.course_accessibility_remove_course_section) - } else { - stringResource(id = R.string.course_accessibility_download_course_section) - } - val downloadIconTint = if (downloadedState == DownloadedState.DOWNLOADED) { - MaterialTheme.appColors.successGreen - } else { - MaterialTheme.appColors.textAccent - } - IconButton( - modifier = iconModifier, - onClick = { onDownloadClick() } - ) { - Icon( - imageVector = downloadIcon, - contentDescription = downloadIconDescription, - tint = downloadIconTint - ) - } - } else if (downloadedState != null) { - Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } else if (downloadedState == DownloadedState.WAITING) { - Icon( - painter = painterResource(id = coreR.drawable.core_download_waiting), - contentDescription = stringResource( - id = R.string.course_accessibility_stop_downloading_course_section - ), - tint = MaterialTheme.appColors.error - ) - } - IconButton( - modifier = iconModifier.padding(2.dp), - onClick = { onDownloadClick() } - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource( - id = R.string.course_accessibility_stop_downloading_course_section - ), - tint = MaterialTheme.appColors.error - ) - } - } - } - } + DownloadIcon( + downloadedState = downloadedState, + onDownloadClick = onDownloadClick + ) } } @@ -789,9 +1036,10 @@ fun CourseSubSectionItem( MaterialTheme.appColors.onSurface } val due by rememberSaveable { - mutableStateOf(block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } ?: "") + mutableStateOf( + block.due?.let { TimeUtils.formatToString(context, it, useRelativeDates) } + ) } - val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && due.isNotEmpty() Column( modifier = modifier .fillMaxWidth() @@ -817,7 +1065,7 @@ fun CourseSubSectionItem( maxLines = 1 ) Spacer(modifier = Modifier.width(16.dp)) - if (isAssignmentEnable) { + if (due != null) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, @@ -825,16 +1073,27 @@ fun CourseSubSectionItem( ) } } - - if (isAssignmentEnable) { - val assignmentString = + val strings = listOf( + block.assignmentProgress?.assignmentType, + due?.let { stringResource( - coreR.string.core_subsection_assignment_info, - block.assignmentProgress?.assignmentType ?: "", - stringResource(id = coreR.string.core_date_format_assignment_due, due), - block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, - block.assignmentProgress?.numPointsPossible?.toInt() ?: 0 + id = coreR.string.core_date_format_assignment_due, + it ) + }, + block.assignmentProgress?.numPointsPossible?.let { + if (it > 0) { + block.assignmentProgress?.toPointString(" ") + } else { + null + } + } + ) + val assignmentString = strings + .filter { !it.isNullOrEmpty() } + .joinToString(" - ") + + if (assignmentString.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Text( text = assignmentString, @@ -1211,6 +1470,74 @@ fun CourseMessage( } } +@Composable +fun CourseProgress( + modifier: Modifier = Modifier, + progress: Progress, + description: String, + isCompletedShown: Boolean = false, + onVisibilityChanged: (() -> Unit)? = null +) { + val arrowRotation by animateFloatAsState( + targetValue = if (isCompletedShown) { + -90f + } else { + 90f + }, + label = "" + ) + val buttonText = if (isCompletedShown) { + stringResource(R.string.course_hide_completed) + } else { + stringResource(R.string.course_view_completed) + } + Column( + modifier = modifier, + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + progress = progress.value, + color = MaterialTheme.appColors.progressBarColor, + backgroundColor = MaterialTheme.appColors.progressBarBackgroundColor + ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = description, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelSmall + ) + if (onVisibilityChanged != null) { + Row( + modifier = Modifier.clickable { + onVisibilityChanged() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = buttonText, + color = MaterialTheme.appColors.textAccent, + style = MaterialTheme.appTypography.labelMedium + ) + CardArrow( + degrees = arrowRotation, + tint = MaterialTheme.appColors.textAccent, + ) + } + } + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1375,7 +1702,7 @@ private val mockChapterBlock = Block( descendantsType = BlockType.CHAPTER, completion = 0.0, containsGatedContent = false, - assignmentProgress = AssignmentProgress("", 1f, 2f), + assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), due = Date(), offlineDownload = null ) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt deleted file mode 100644 index b020a11cc..000000000 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ /dev/null @@ -1,776 +0,0 @@ -package org.openedx.course.presentation.ui - -import android.content.res.Configuration -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.AlertDialog -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Switch -import androidx.compose.material.SwitchDefaults -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Videocam -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.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.fragment.app.FragmentManager -import org.koin.compose.koinInject -import org.openedx.core.AppDataConstants -import org.openedx.core.BlockType -import org.openedx.core.NoContentScreenType -import org.openedx.core.domain.model.AssignmentProgress -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.Progress -import org.openedx.core.domain.model.VideoSettings -import org.openedx.core.module.download.DownloadModelsSize -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.video.VideoQualityType -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.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography -import org.openedx.course.presentation.videos.CourseVideoViewModel -import org.openedx.course.presentation.videos.CourseVideosUIState -import org.openedx.foundation.extension.toFileSize -import org.openedx.foundation.presentation.UIMessage -import org.openedx.foundation.presentation.WindowSize -import org.openedx.foundation.presentation.WindowType -import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.foundation.utils.FileUtil -import java.util.Date -import org.openedx.core.R as coreR - -@Composable -fun CourseVideosScreen( - windowSize: WindowSize, - viewModel: CourseVideoViewModel, - fragmentManager: FragmentManager -) { - val uiState by viewModel.uiState.collectAsState(CourseVideosUIState.Loading) - val uiMessage by viewModel.uiMessage.collectAsState(null) - val videoSettings by viewModel.videoSettings.collectAsState() - val fileUtil: FileUtil = koinInject() - - CourseVideosUI( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - courseTitle = viewModel.courseTitle, - videoSettings = videoSettings, - onExpandClick = { block -> - viewModel.switchCourseSections(block.id) - }, - onSubSectionClick = { subSectionBlock -> - if (viewModel.isCourseDropdownNavigationEnabled) { - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.courseRouter.navigateToCourseContainer( - fragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS - ) - } - } else { - viewModel.sequentialClickedEvent( - subSectionBlock.blockId, - subSectionBlock.displayName - ) - viewModel.courseRouter.navigateToCourseSubsections( - fm = fragmentManager, - courseId = viewModel.courseId, - subSectionId = subSectionBlock.id, - mode = CourseViewMode.VIDEOS - ) - } - }, - onDownloadClick = { blocksIds -> - viewModel.downloadBlocks( - blocksIds = blocksIds, - fragmentManager = fragmentManager, - ) - }, - onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - viewModel.logBulkDownloadToggleEvent( - !isAllBlocksDownloadedOrDownloading, - viewModel.courseId - ) - if (isAllBlocksDownloadedOrDownloading) { - viewModel.removeAllDownloadModels() - } else { - viewModel.saveAllDownloadModels( - fileUtil.getExternalAppDir().path, - viewModel.courseId - ) - } - }, - onDownloadQueueClick = { - if (viewModel.hasDownloadModelsInQueue()) { - viewModel.courseRouter.navigateToDownloadQueue(fm = fragmentManager) - } - }, - onVideoDownloadQualityClick = { - if (viewModel.hasDownloadModelsInQueue()) { - viewModel.onChangingVideoQualityWhileDownloading() - } else { - viewModel.courseRouter.navigateToVideoQuality( - fragmentManager, - VideoQualityType.Download - ) - } - } - ) -} - -@Composable -private fun CourseVideosUI( - windowSize: WindowSize, - uiState: CourseVideosUIState, - uiMessage: UIMessage?, - courseTitle: String, - videoSettings: VideoSettings, - onExpandClick: (Block) -> Unit, - onSubSectionClick: (Block) -> Unit, - onDownloadClick: (blocksIds: List) -> Unit, - onDownloadAllClick: (Boolean) -> Unit, - onDownloadQueueClick: () -> Unit, - onVideoDownloadQualityClick: () -> Unit -) { - val scaffoldState = rememberScaffoldState() - - 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() - ) - ) - } - - val listBottomPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = PaddingValues(bottom = 24.dp), - compact = PaddingValues(bottom = 24.dp) - ) - ) - } - - val listPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.padding(horizontal = 6.dp), - compact = Modifier.padding(horizontal = 24.dp) - ) - ) - } - - var isDownloadConfirmationShowed by rememberSaveable { - mutableStateOf(false) - } - - var isDeleteDownloadsConfirmationShowed by rememberSaveable { - mutableStateOf(false) - } - - var deleteDownloadBlock by rememberSaveable { - mutableStateOf(null) - } - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background - ) { - Box { - Column( - Modifier - .fillMaxSize() - ) { - when (uiState) { - is CourseVideosUIState.Empty -> { - NoContentScreen(noContentScreenType = NoContentScreenType.COURSE_VIDEOS) - } - - is CourseVideosUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (uiState.downloadModelsSize.allCount > 0) { - item { - AllVideosDownloadItem( - downloadModelsSize = uiState.downloadModelsSize, - videoSettings = videoSettings, - onShowDownloadConfirmationDialog = { - isDownloadConfirmationShowed = true - }, - onDownloadAllClick = { isSwitched -> - if (isSwitched) { - isDeleteDownloadsConfirmationShowed = true - } else { - onDownloadAllClick(false) - } - }, - onDownloadQueueClick = onDownloadQueueClick, - onVideoDownloadQualityClick = onVideoDownloadQualityClick - ) - } - } - - item { - Spacer(modifier = Modifier.height(12.dp)) - } - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] - - item { - CourseSection( - modifier = listPadding.padding(vertical = 4.dp), - block = section, - onItemClick = onExpandClick, - courseSectionsState = courseSectionsState, - courseSubSections = courseSubSections, - downloadedStateMap = uiState.downloadedState, - useRelativeDates = uiState.useRelativeDates, - onSubSectionClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - } - } - } - } - - CourseVideosUIState.Loading -> { - CircularProgress() - } - } - } - } - } - } - - if (isDownloadConfirmationShowed) { - AlertDialog( - title = { - Text( - text = stringResource(id = coreR.string.core_download_big_files_confirmation_title) - ) - }, - text = { - Text( - text = stringResource(id = coreR.string.core_download_big_files_confirmation_text) - ) - }, - onDismissRequest = { - isDownloadConfirmationShowed = false - }, - confirmButton = { - TextButton( - onClick = { - isDownloadConfirmationShowed = false - onDownloadAllClick(false) - } - ) { - Text( - text = stringResource(id = coreR.string.core_confirm) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - isDownloadConfirmationShowed = false - } - ) { - Text(text = stringResource(id = coreR.string.core_dismiss)) - } - } - ) - } - - if (isDeleteDownloadsConfirmationShowed) { - val downloadModelsSize = - (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize - val isDownloadedAllVideos = - downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && - downloadModelsSize.remainingCount == 0 - val dialogTextId = if (isDownloadedAllVideos) { - coreR.string.core_delete_confirmation - } else { - coreR.string.core_delete_in_process_confirmation - } - - AlertDialog( - title = { - Text( - text = stringResource(id = coreR.string.core_warning) - ) - }, - text = { - Text( - text = stringResource(id = dialogTextId, courseTitle) - ) - }, - onDismissRequest = { - isDeleteDownloadsConfirmationShowed = false - }, - confirmButton = { - TextButton( - onClick = { - isDeleteDownloadsConfirmationShowed = false - onDownloadAllClick(true) - } - ) { - Text( - text = stringResource(id = coreR.string.core_delete) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - isDeleteDownloadsConfirmationShowed = false - } - ) { - Text(text = stringResource(id = coreR.string.core_cancel)) - } - } - ) - } - - if (deleteDownloadBlock != null) { - AlertDialog( - title = { - Text( - text = stringResource(id = coreR.string.core_warning) - ) - }, - text = { - Text( - text = stringResource( - id = coreR.string.core_delete_download_confirmation_text, - deleteDownloadBlock?.displayName ?: "" - ) - ) - }, - onDismissRequest = { - deleteDownloadBlock = null - }, - confirmButton = { - TextButton( - onClick = { - deleteDownloadBlock?.let { block -> - onDownloadClick(listOf(block.id)) - } - deleteDownloadBlock = null - } - ) { - Text( - text = stringResource(id = coreR.string.core_delete) - ) - } - }, - dismissButton = { - TextButton( - onClick = { - deleteDownloadBlock = null - } - ) { - Text(text = stringResource(id = coreR.string.core_cancel)) - } - } - ) - } - } -} - -@Composable -private fun AllVideosDownloadItem( - downloadModelsSize: DownloadModelsSize, - videoSettings: VideoSettings, - onShowDownloadConfirmationDialog: () -> Unit, - onDownloadAllClick: (Boolean) -> Unit, - onDownloadQueueClick: () -> Unit, - onVideoDownloadQualityClick: () -> Unit -) { - val isDownloadingAllVideos = - downloadModelsSize.isAllBlocksDownloadedOrDownloading && - downloadModelsSize.remainingCount > 0 - val isDownloadedAllVideos = - downloadModelsSize.isAllBlocksDownloadedOrDownloading && - downloadModelsSize.remainingCount == 0 - - val downloadVideoTitleRes = when { - isDownloadingAllVideos -> coreR.string.core_video_downloading_to_device - isDownloadedAllVideos -> coreR.string.core_video_downloaded_to_device - else -> coreR.string.core_video_download_to_device - } - val downloadVideoSubTitle = - if (isDownloadedAllVideos) { - stringResource( - id = coreR.string.core_video_downloaded_subtitle, - downloadModelsSize.allCount, - downloadModelsSize.allSize.toFileSize() - ) - } else { - stringResource( - id = coreR.string.core_video_remaining_to_download, - downloadModelsSize.remainingCount, - downloadModelsSize.remainingSize.toFileSize() - ) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onDownloadQueueClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - if (isDownloadingAllVideos) { - CircularProgressIndicator( - modifier = Modifier - .padding(start = 16.dp) - .size(24.dp), - color = MaterialTheme.appColors.primary, - strokeWidth = 2.dp - ) - } else { - Icon( - modifier = Modifier - .padding(start = 16.dp), - imageVector = Icons.Outlined.Videocam, - tint = MaterialTheme.appColors.onSurface, - contentDescription = null - ) - } - Column( - modifier = Modifier - .weight(1f) - .padding(8.dp) - ) { - Text( - text = stringResource(id = downloadVideoTitleRes), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = downloadVideoSubTitle, - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - val isChecked = downloadModelsSize.isAllBlocksDownloadedOrDownloading - Switch( - modifier = Modifier - .padding(end = 16.dp), - checked = isChecked, - onCheckedChange = { - if (!isChecked) { - if ( - downloadModelsSize.remainingSize > AppDataConstants.DOWNLOADS_CONFIRMATION_SIZE - ) { - onShowDownloadConfirmationDialog() - } else { - onDownloadAllClick(false) - } - } else { - onDownloadAllClick(true) - } - }, - colors = SwitchDefaults.colors( - uncheckedThumbColor = MaterialTheme.appColors.primary, - checkedThumbColor = MaterialTheme.appColors.primary, - checkedTrackColor = MaterialTheme.appColors.primary - ) - ) - } - if (isDownloadingAllVideos) { - val progress = - if (downloadModelsSize.allSize == 0L) { - 0f - } else { - 1 - downloadModelsSize.remainingSize.toFloat() / downloadModelsSize.allSize - } - - val animatedProgress by animateFloatAsState( - targetValue = progress, - animationSpec = tween(durationMillis = 2000, easing = LinearEasing), - label = "ProgressAnimation" - ) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth(), - progress = animatedProgress - ) - } - Divider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onVideoDownloadQualityClick() - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - modifier = Modifier - .padding(start = 16.dp), - imageVector = Icons.Outlined.Settings, - tint = MaterialTheme.appColors.onSurface, - contentDescription = null - ) - Column( - modifier = Modifier - .weight(1f) - .padding(8.dp) - ) { - Text( - text = stringResource(id = coreR.string.core_video_download_quality), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleMedium - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), - color = MaterialTheme.appColors.textSecondary, - style = MaterialTheme.appTypography.labelMedium - ) - } - Icon( - modifier = Modifier - .padding(end = 16.dp), - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - tint = MaterialTheme.appColors.onSurface, - contentDescription = "Expandable Arrow" - ) - } - Divider() -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 1, - allSize = 0 - ), - useRelativeDates = true - ), - courseTitle = "", - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenEmptyPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.Empty, - courseTitle = "", - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) -@Composable -private fun CourseVideosScreenTabletPreview() { - OpenEdXTheme { - CourseVideosUI( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 0, - allSize = 0 - ), - useRelativeDates = true - ), - courseTitle = "", - onExpandClick = { }, - onSubSectionClick = { }, - videoSettings = VideoSettings.default, - onDownloadClick = {}, - onDownloadAllClick = {}, - onDownloadQueueClick = {}, - onVideoDownloadQualityClick = {} - ) - } -} - -private val mockAssignmentProgress = AssignmentProgress( - assignmentType = "Home", - numPointsEarned = 1f, - numPointsPossible = 3f -) - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) - -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.SEQUENTIAL, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockChapterBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = Progress(1, 3), -) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 08fde815b..76ded08a9 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -32,7 +32,8 @@ import java.util.concurrent.Executors @SuppressLint("StaticFieldLeak") class EncodedVideoUnitViewModel( courseId: String, - val blockId: String, + videoUrl: String, + blockId: String, private val context: Context, private val preferencesManager: CorePreferences, courseRepository: CourseRepository, @@ -42,6 +43,8 @@ class EncodedVideoUnitViewModel( courseAnalytics: CourseAnalytics, ) : VideoUnitViewModel( courseId, + videoUrl, + blockId, courseRepository, notifier, networkConnection, @@ -65,6 +68,11 @@ class EncodedVideoUnitViewModel( var isPlayerSetUp = false private val exoPlayerListener = object : Player.Listener { + override fun onRenderedFirstFrame() { + duration = exoPlayer?.duration ?: 0L + super.onRenderedFirstFrame() + } + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { super.onPlayWhenReadyChanged(playWhenReady, reason) isPlaying = playWhenReady diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 745f3c67a..a0439d2ed 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -204,6 +204,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { override fun onDestroyView() { viewModel.currentVideoTime = exoPlayer?.currentPosition ?: C.TIME_UNSET + viewModel.duration = exoPlayer?.duration ?: 0L viewModel.sendTime() super.onDestroyView() } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index e599b0f95..15725c19e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -49,6 +49,7 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_VIDEO_URL, ""), requireArguments().getString(ARG_BLOCK_ID, ""), ) } @@ -79,7 +80,6 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { lifecycle.addObserver(viewModel) handler.post(videoTimeRunnable) requireArguments().apply { - viewModel.videoUrl = getString(ARG_VIDEO_URL, "") viewModel.transcripts = stringToObject>( getString(ARG_TRANSCRIPT_URL, "") ) ?: emptyMap() diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt index 0360d9dc6..bd9199942 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt @@ -21,6 +21,8 @@ import subtitleFile.TimedTextObject open class VideoUnitViewModel( val courseId: String, + val videoUrl: String, + val blockId: String, private val courseRepository: CourseRepository, private val notifier: CourseNotifier, private val networkConnection: NetworkConnection, @@ -28,7 +30,6 @@ open class VideoUnitViewModel( courseAnalytics: CourseAnalytics, ) : BaseVideoViewModel(courseId, courseAnalytics) { - var videoUrl = "" var transcripts = emptyMap() var isPlaying = true var transcriptLanguage = AppDataConstants.defaultLocale.language ?: "en" @@ -40,6 +41,8 @@ open class VideoUnitViewModel( val currentVideoTime: LiveData get() = _currentVideoTime + var duration = 0L + protected val isUpdatedMutable = MutableLiveData(true) val isUpdated: LiveData get() = isUpdatedMutable @@ -58,6 +61,10 @@ open class VideoUnitViewModel( private var isBlockAlreadyCompleted = false + init { + initVideoProgress() + } + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -65,6 +72,7 @@ open class VideoUnitViewModel( if (it is CourseVideoPositionChanged && videoUrl == it.videoUrl) { isUpdatedMutable.value = false _currentVideoTime.value = it.videoTime + saveVideoProgress() isUpdatedMutable.value = true isPlaying = it.isPlaying } else if (it is CourseSubtitleLanguageChanged) { @@ -76,6 +84,22 @@ open class VideoUnitViewModel( } } + override fun onPause(owner: LifecycleOwner) { + saveVideoProgress() + super.onPause(owner) + } + + private fun saveVideoProgress() { + viewModelScope.launch { + courseRepository.saveVideoProgress( + blockId, + videoUrl, + _currentVideoTime.value ?: 0L, + duration + ) + } + } + fun downloadSubtitles() { viewModelScope.launch(Dispatchers.IO) { transcriptManager.downloadTranscriptsForVideo(getTranscriptUrl())?.let { result -> @@ -131,4 +155,15 @@ open class VideoUnitViewModel( } fun getCurrentVideoTime() = currentVideoTime.value ?: 0 + + private fun initVideoProgress() { + viewModelScope.launch { + try { + val videoProgress = courseRepository.getVideoProgress(blockId) + _currentVideoTime.value = videoProgress.videoTime + } catch (e: Exception) { + e.printStackTrace() + } + } + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index 423c825ce..c9da7aaec 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -20,6 +20,7 @@ class VideoViewModel( var videoUrl = "" var currentVideoTime = 0L + var duration = 0L var isPlaying: Boolean? = null private var isBlockAlreadyCompleted = false @@ -31,7 +32,8 @@ class VideoViewModel( CourseVideoPositionChanged( videoUrl, currentVideoTime, - isPlaying ?: false + duration, + isPlaying == true ) ) } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index 03f8b906a..0f4a75697 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -124,6 +124,11 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ } youTubePlayer.addListener(youtubeTrackerListener) } + + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + viewModel.duration = (duration * 1000).toLong() + super.onVideoDuration(youTubePlayer, duration) + } }, options ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index c1cd33aa3..1afe71e91 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -39,7 +39,11 @@ import org.openedx.foundation.presentation.WindowSize class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) { private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_VIDEO_URL, ""), + requireArguments().getString(ARG_BLOCK_ID, ""), + ) } private val router by inject() private val appReviewManager by inject { parametersOf(requireActivity()) } @@ -61,7 +65,6 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) windowSize = computeWindowSizeClasses() lifecycle.addObserver(viewModel) requireArguments().apply { - viewModel.videoUrl = getString(ARG_VIDEO_URL, "") viewModel.transcripts = stringToObject>( getString(ARG_TRANSCRIPT_URL, "") ) ?: emptyMap() @@ -220,6 +223,11 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) CourseAnalyticsKey.YOUTUBE.key ) } + + override fun onVideoDuration(youTubePlayer: YouTubePlayer, duration: Float) { + viewModel.duration = (duration * 1000).toLong() + super.onVideoDuration(youTubePlayer, duration) + } } if (!isPlayerInitialized) { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt new file mode 100644 index 000000000..571fde683 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -0,0 +1,390 @@ +package org.openedx.course.presentation.videos + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +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.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.openedx.core.BlockType +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.Progress +import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.ui.CircularProgress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.course.R +import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState +import org.openedx.course.presentation.ui.CourseProgress +import org.openedx.course.presentation.ui.CourseVideoSection +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import java.util.Date + +@Composable +fun CourseContentVideoScreen( + windowSize: WindowSize, + viewModel: CourseVideoViewModel, + fragmentManager: FragmentManager, + onNavigateToHome: () -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsState(CourseVideoUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + + CourseVideosUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onNavigateToHome = onNavigateToHome, + onVideoClick = { videoBlock -> + viewModel.courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = viewModel.courseId, + unitId = viewModel.getBlockParent(videoBlock.id)?.id ?: return@CourseVideosUI, + mode = CourseViewMode.VIDEOS + ) + viewModel.logVideoClick(videoBlock.id) + }, + onDownloadClick = { blocksIds -> + viewModel.downloadBlocks( + blocksIds = blocksIds, + fragmentManager = fragmentManager, + ) + }, + onCompletedSectionVisibilityChange = { + viewModel.onCompletedSectionVisibilityChange() + }, + ) +} + +@Composable +private fun CourseVideosUI( + windowSize: WindowSize, + uiState: CourseVideoUIState, + uiMessage: UIMessage?, + onNavigateToHome: () -> Unit, + onVideoClick: (Block) -> Unit, + onDownloadClick: (blocksIds: List) -> Unit, + onCompletedSectionVisibilityChange: () -> Unit, +) { + val scaffoldState = rememberScaffoldState() + + 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() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box { + Column( + modifier = Modifier.fillMaxSize() + ) { + when (uiState) { + is CourseVideoUIState.Empty -> { + CourseContentVideoEmptyState( + onReturnToCourseClick = onNavigateToHome + ) + } + + is CourseVideoUIState.CourseData -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + val allVideos = uiState.courseVideos.values.flatten() + val hasCompletedSection = + uiState.courseVideos.values.any { sectionVideos -> + sectionVideos.all { video -> + video.isCompleted() + } + } + val progress = Progress( + completed = allVideos.filter { it.isCompleted() }.size, + total = allVideos.size, + ) + item { + CourseProgress( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 8.dp, + start = 24.dp, + end = 24.dp, + ), + progress = progress, + isCompletedShown = uiState.isCompletedSectionsShown, + onVisibilityChanged = if (hasCompletedSection) { + { onCompletedSectionVisibilityChange() } + } else { + null + }, + description = stringResource( + R.string.course_completed, + progress.completed, + progress.total + ) + ) + } + item { + Divider(modifier = Modifier.fillMaxWidth()) + } + + uiState.courseStructure.blockData + .let { list -> + if (uiState.isCompletedSectionsShown) { + list.sortedBy { section -> + uiState.courseVideos[section.id]?.any { !it.isCompleted() } + } + } else { + list + } + } + .forEach { section -> + val sectionVideos = + uiState.courseVideos[section.id] ?: emptyList() + + val shouldShowSection = + sectionVideos.any { !it.isCompleted() } || + uiState.isCompletedSectionsShown + if (shouldShowSection) { + item { + CourseVideoSection( + block = section, + videoBlocks = sectionVideos, + downloadedStateMap = uiState.downloadedState, + onVideoClick = onVideoClick, + onDownloadClick = onDownloadClick, + preview = uiState.videoPreview, + progress = uiState.videoProgress, + ) + } + } + } + } + } + + CourseVideoUIState.Loading -> { + CircularProgress() + } + } + } + } + } + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideoUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 1, + allSize = 0 + ), + isCompletedSectionsShown = false, + videoPreview = mapOf(), + videoProgress = mapOf(), + ), + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenEmptyPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideoUIState.Empty, + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseVideosScreenTabletPreview() { + OpenEdXTheme { + CourseVideosUI( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiMessage = null, + uiState = CourseVideoUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 0, + allSize = 0 + ), + isCompletedSectionsShown = true, + videoPreview = mapOf(), + videoProgress = mapOf(), + ), + onVideoClick = { }, + onDownloadClick = {}, + onCompletedSectionVisibilityChange = {}, + onNavigateToHome = {}, + ) + } +} + +private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" +) + +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null +) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockChapterBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), +) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt similarity index 55% rename from course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt rename to course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt index 245fb2380..61f1c9283 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoUIState.kt @@ -4,18 +4,20 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.utils.VideoPreview -sealed class CourseVideosUIState { +sealed class CourseVideoUIState { data class CourseData( val courseStructure: CourseStructure, val downloadedState: Map, - val courseSubSections: Map>, - val courseSectionsState: Map, + val courseVideos: Map>, val subSectionsDownloadsCount: Map, val downloadModelsSize: DownloadModelsSize, - val useRelativeDates: Boolean - ) : CourseVideosUIState() + val isCompletedSectionsShown: Boolean, + val videoPreview: Map, + val videoProgress: Map, + ) : CourseVideoUIState() - data object Empty : CourseVideosUIState() - data object Loading : CourseVideosUIState() + data object Empty : CourseVideoUIState() + data object Loading : CourseVideoUIState() } diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 242b667b7..c37b8709e 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -1,7 +1,10 @@ package org.openedx.course.presentation.videos +import android.annotation.SuppressLint +import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -9,11 +12,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel @@ -24,30 +28,30 @@ import org.openedx.core.system.connection.NetworkConnection 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.VideoNotifier -import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil +@SuppressLint("StaticFieldLeak") class CourseVideoViewModel( val courseId: String, - val courseTitle: String, + private val context: Context, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val courseNotifier: CourseNotifier, - private val videoNotifier: VideoNotifier, - private val analytics: CourseAnalytics, private val downloadDialogManager: DownloadDialogManager, private val fileUtil: FileUtil, val courseRouter: CourseRouter, + private val analytics: CourseAnalytics, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -59,20 +63,15 @@ class CourseVideoViewModel( coreAnalytics, downloadHelper, ) { - - val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled - - private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) - val uiState: StateFlow + private val _uiState = MutableStateFlow(CourseVideoUIState.Loading) + val uiState: StateFlow get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _videoSettings = MutableStateFlow(VideoSettings.default) - val videoSettings = _videoSettings.asStateFlow() - + private val courseVideos = mutableMapOf>() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() @@ -92,8 +91,8 @@ class CourseVideoViewModel( viewModelScope.launch { downloadModelsStatusFlow.collect { - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData + if (_uiState.value is CourseVideoUIState.CourseData) { + val state = _uiState.value as CourseVideoUIState.CourseData _uiState.value = state.copy( downloadedState = it.toMap(), downloadModelsSize = getDownloadModelsSize() @@ -102,23 +101,6 @@ class CourseVideoViewModel( } } - viewModelScope.launch { - videoNotifier.notifier.collect { event -> - if (event is VideoQualityChanged) { - _videoSettings.value = preferencesManager.videoSettings - - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData - _uiState.value = state.copy( - downloadModelsSize = getDownloadModelsSize() - ) - } - } - } - } - - _videoSettings.value = preferencesManager.videoSettings - getVideos() } @@ -159,68 +141,53 @@ class CourseVideoViewModel( var courseStructure = interactor.getCourseStructureForVideos(courseId) val blocks = courseStructure.blockData if (blocks.isEmpty()) { - _uiState.value = CourseVideosUIState.Empty + _uiState.value = CourseVideoUIState.Empty } else { setBlocks(courseStructure.blockData) - courseSubSections.clear() + courseVideos.clear() courseSubSectionUnit.clear() courseStructure = courseStructure.copy(blockData = sortBlocks(blocks)) initDownloadModelsStatus() - - val courseSectionsState = - (_uiState.value as? CourseVideosUIState.CourseData)?.courseSectionsState.orEmpty() + val downloadingModels = getDownloadModelList() + val videoPreview = withContext(Dispatchers.IO) { + courseVideos.values.flatten().associate { block -> + block.id to block.getVideoPreview( + context, + networkConnection.isOnline(), + downloadingModels.find { block.id == it.id }?.path + ) + } + } + val videoProgress = courseVideos.values.flatten().associate { block -> + val videoProgressEntity = interactor.getVideoProgress(block.id) + val progress = videoProgressEntity.videoTime.toFloat() + .safeDivBy(videoProgressEntity.duration.toFloat()) + block.id to progress + } + val isCompletedSectionsShown = + (_uiState.value as? CourseVideoUIState.CourseData)?.isCompletedSectionsShown + ?: false _uiState.value = - CourseVideosUIState.CourseData( + CourseVideoUIState.CourseData( courseStructure = courseStructure, downloadedState = getDownloadModelsStatus(), - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, + courseVideos = courseVideos, subSectionsDownloadsCount = subSectionsDownloadsCount, downloadModelsSize = getDownloadModelsSize(), - useRelativeDates = preferencesManager.isRelativeDatesEnabled + isCompletedSectionsShown = isCompletedSectionsShown, + videoPreview = videoPreview, + videoProgress = videoProgress, ) } courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { e.printStackTrace() - _uiState.value = CourseVideosUIState.Empty + _uiState.value = CourseVideoUIState.Empty } } } - fun switchCourseSections(blockId: String) { - if (_uiState.value is CourseVideosUIState.CourseData) { - val state = _uiState.value as CourseVideosUIState.CourseData - val courseSectionsState = state.courseSectionsState.toMutableMap() - courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - - _uiState.value = state.copy(courseSectionsState = courseSectionsState) - } - } - - fun sequentialClickedEvent(blockId: String, blockName: String) { - val currentState = uiState.value - if (currentState is CourseVideosUIState.CourseData) { - analytics.sequentialClickedEvent( - courseId, - courseTitle, - blockId, - blockName - ) - } - } - - fun onChangingVideoQualityWhileDownloading() { - viewModelScope.launch { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.course_change_quality_when_downloading) - ) - ) - } - } - private fun sortBlocks(blocks: List): List { if (blocks.isEmpty()) return emptyList() @@ -237,7 +204,14 @@ class CourseVideoViewModel( private fun processDescendants(chapterBlock: Block, blocks: List) { chapterBlock.descendants.forEach { descendantId -> val sequentialBlock = blocks.find { it.id == descendantId } ?: return@forEach + val verticalBlocks = blocks.filter { block -> + block.id in sequentialBlock.descendants + } + val videoBlocks = blocks.filter { block -> + verticalBlocks.any { vertical -> block.id in vertical.descendants } && block.type == BlockType.VIDEO + } addToSubSections(chapterBlock, sequentialBlock) + addToVideo(chapterBlock, videoBlocks) updateSubSectionUnit(sequentialBlock, blocks) updateDownloadsCount(sequentialBlock, blocks) addDownloadableChildrenForSequentialBlock(sequentialBlock) @@ -248,6 +222,10 @@ class CourseVideoViewModel( courseSubSections.getOrPut(chapterBlock.id) { mutableListOf() }.add(sequentialBlock) } + private fun addToVideo(chapterBlock: Block, videoBlocks: List) { + courseVideos.getOrPut(chapterBlock.id) { mutableListOf() }.addAll(videoBlocks) + } + private fun updateSubSectionUnit(sequentialBlock: Block, blocks: List) { courseSubSectionUnit[sequentialBlock.id] = sequentialBlock.getFirstDescendantBlock(blocks) } @@ -258,10 +236,8 @@ class CourseVideoViewModel( fun downloadBlocks(blocksIds: List, fragmentManager: FragmentManager) { viewModelScope.launch { - val courseData = _uiState.value as? CourseVideosUIState.CourseData ?: return@launch - val subSectionsBlocks = - courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + courseSubSections.values.flatten().filter { it.id in blocksIds } val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> val verticalBlocks = @@ -315,4 +291,42 @@ class CourseVideoViewModel( } } } + + fun onCompletedSectionVisibilityChange() { + if (_uiState.value is CourseVideoUIState.CourseData) { + val state = _uiState.value as CourseVideoUIState.CourseData + _uiState.value = state.copy(isCompletedSectionsShown = !state.isCompletedSectionsShown) + + analytics.logEvent( + CourseAnalyticsEvent.VIDEO_SHOW_COMPLETED.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.VIDEO_SHOW_COMPLETED.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + } + ) + } + } + + fun logVideoClick(blockId: String) { + if (_uiState.value is CourseVideoUIState.CourseData) { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.eventName, + buildMap { + put( + CourseAnalyticsKey.NAME.key, + CourseAnalyticsEvent.COURSE_CONTENT_VIDEO_CLICK.biValue + ) + put(CourseAnalyticsKey.COURSE_ID.key, courseId) + put(CourseAnalyticsKey.BLOCK_ID.key, blockId) + } + ) + } + } + + fun getBlockParent(blockId: String): Block? { + return allBlocks.values.find { blockId in it.descendants } + } } diff --git a/course/src/main/res/drawable/course_ic_warning.xml b/course/src/main/res/drawable/course_ic_warning.xml new file mode 100644 index 000000000..635c5ca80 --- /dev/null +++ b/course/src/main/res/drawable/course_ic_warning.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_video_play_button.xml b/course/src/main/res/drawable/course_video_play_button.xml new file mode 100644 index 000000000..ce96d5ec1 --- /dev/null +++ b/course/src/main/res/drawable/course_video_play_button.xml @@ -0,0 +1,12 @@ + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 47374e30f..33852c242 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -19,8 +19,6 @@ Explore other parts of this course or view this on web. Open in browser Subtitles - Continue with: - Resume To proceed with \"%s\" press \"Next section\". 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 @@ -35,16 +33,18 @@ Assignment Type Current / Max % - - Home - Videos + Content Discussions More Dates Downloads Progress + All + Videos + Assignments + Video player Remove course section @@ -52,19 +52,33 @@ Stop downloading course section Section completed Section uncompleted + Video watched + Assignment completed + Assignment due date past - - %1$s of %2$s assignment complete - %1$s of %2$s assignments complete + + %1$s/%2$s Section Completed + %1$s/%2$s Sections Completed Back Your free audit access to this course expired on %s. This course will begin on %s. Come back then to start learning! An error occurred while loading your course + Continue + View Completed + Hide Completed Completed - %1$s / %2$s Complete - of Grade - %1$s / %2$s%% + %1$s / %2$s Complete + of Grade + %1$s / %2$s%% This course does not contain graded assignments. + %1$s/%2$s Watched + %1$s/%2$s Completed + Complete - %1$s points + Past Due - %1$s points + In Progress - %1$s points + %1$s %% of Grade + Review Course Grading Policy + Return to Course Home diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index f4e21f843..62fc097b7 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -95,7 +95,8 @@ class CourseOutlineViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( @@ -271,7 +272,7 @@ class CourseOutlineViewModelTest { } returns Unit coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -300,7 +301,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Error) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) } @Suppress("TooGenericExceptionThrown") @@ -310,7 +311,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns true every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw Exception() } - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -339,7 +340,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Error) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) } @Test @@ -361,7 +362,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -393,7 +394,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) } @Test @@ -415,7 +416,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -446,7 +447,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) } @Test @@ -468,7 +469,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -499,7 +500,7 @@ class CourseOutlineViewModelTest { coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) } @Test @@ -510,7 +511,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -560,7 +561,7 @@ class CourseOutlineViewModelTest { coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -612,7 +613,7 @@ class CourseOutlineViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { coreAnalytics.logEvent(any(), any()) } returns Unit - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 02eda9622..7336e9307 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -73,7 +73,8 @@ class CourseSectionViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 9d0f0c7c1..becf35187 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -50,7 +50,8 @@ class CourseUnitContainerViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt index effd426a0..1d8524a7b 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt @@ -57,6 +57,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -96,6 +98,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted success`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -135,6 +139,8 @@ class VideoUnitViewModelTest { @Test fun `CourseVideoPositionChanged notifier test`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -147,10 +153,12 @@ class VideoUnitViewModelTest { CourseVideoPositionChanged( "", 10, - false + 10000L, + false, ) ) } + coEvery { courseRepository.saveVideoProgress(any(), any(), any(), any()) } returns Unit val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) lifecycleRegistry.addObserver(viewModel) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index ad04283d7..ae954c5f7 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -52,11 +52,11 @@ class VideoViewModelTest { fun `sendTime test`() = runTest { val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) - coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit + coEvery { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() - coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, false)) } + coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } } @Test diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index ae34756a5..38ff2e49f 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.videos +import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -16,7 +17,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -30,6 +30,7 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block @@ -47,10 +48,8 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection -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.VideoNotifier import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -65,15 +64,15 @@ class CourseVideoViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher() + private val dispatcher = UnconfinedTestDispatcher() + private val context = mockk() private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val courseNotifier = spyk() - private val videoNotifier = spyk() - private val analytics = mockk() private val coreAnalytics = mockk() + private val courseAnalytics = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() private val downloadDao = mockk() @@ -88,7 +87,8 @@ class CourseVideoViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( @@ -196,7 +196,7 @@ class CourseVideoViewModelTest { every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" - every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) + every { courseNotifier.notifier } returns flowOf() every { preferencesManager.isRelativeDatesEnabled } returns true every { downloadDialogManager.showPopup( @@ -219,7 +219,7 @@ class CourseVideoViewModelTest { } @Test - fun `getVideos empty list`() = runTest { + fun `getVideos empty list`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) @@ -228,18 +228,17 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -251,72 +250,77 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.Empty) + assert(viewModel.uiState.value is CourseVideoUIState.Empty) } @Test - fun `getVideos success`() = runTest { + fun `getVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { + repeat(5) { + delay(10000) + emit(emptyList()) + } + } every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, downloadHelper, ) - viewModel.getVideos() + val mockLifeCycleOwner: LifecycleOwner = mockk() + val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) + lifecycleRegistry.addObserver(viewModel) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `updateVideos success`() = runTest { + fun `updateVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } every { downloadDao.getAllDataFlow() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } + emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default + every { networkConnection.isOnline() } returns true + coEvery { interactor.getVideoProgress(any()) } returns VideoProgressEntity("", "", 0L, 0L) val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -332,11 +336,11 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `setIsUpdating success`() = runTest { + fun `setIsUpdating success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -348,20 +352,21 @@ class CourseVideoViewModelTest { fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -389,20 +394,21 @@ class CourseVideoViewModelTest { runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, @@ -434,20 +440,21 @@ class CourseVideoViewModelTest { runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure val viewModel = CourseVideoViewModel( "", - "", + context, config, interactor, resourceManager, networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, coreAnalytics, downloadDao, workerController, diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 5e1622352..c57445b42 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -93,7 +93,8 @@ class DownloadsViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( Block(