diff --git a/app/build.gradle b/app/build.gradle index f7ad7ef16..f41d93cec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -127,6 +127,7 @@ dependencies { implementation project(path: ':profile') implementation project(path: ':discussion') implementation project(path: ':whatsnew') + implementation project(path: ':dates') implementation project(path: ':downloads') ksp "androidx.room:room-compiler:$room_version" diff --git a/app/schemas/org.openedx.app.room.AppDatabase/6.json b/app/schemas/org.openedx.app.room.AppDatabase/6.json new file mode 100644 index 000000000..de1e51a90 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/6.json @@ -0,0 +1,1206 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "3c35a346cc635ac7115a9f5021306a61", + "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" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "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" + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "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" + }, + { + "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" + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + }, + { + "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" + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "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" + ] + } + }, + { + "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" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT" + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "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, `start` INTEGER, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` INTEGER, `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" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT" + }, + { + "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.start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "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" + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "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" + ] + } + }, + { + "tableName": "course_dates_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `first_component_block_id` TEXT, `course_id` TEXT NOT NULL, `due_date` TEXT, `assignment_title` TEXT, `learner_has_access` INTEGER, `relative` INTEGER, `course_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstComponentBlockId", + "columnName": "first_component_block_id", + "affinity": "TEXT" + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "due_date", + "affinity": "TEXT" + }, + { + "fieldPath": "assignmentTitle", + "columnName": "assignment_title", + "affinity": "TEXT" + }, + { + "fieldPath": "learnerHasAccess", + "columnName": "learner_has_access", + "affinity": "INTEGER" + }, + { + "fieldPath": "relative", + "columnName": "relative", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseName", + "columnName": "course_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "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, `duration` INTEGER, 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" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + } + }, + { + "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" + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT" + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT" + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL" + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER" + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + } + ], + "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, '3c35a346cc635ac7115a9f5021306a61')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 6c29cdf12..5e96784d8 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -6,6 +6,7 @@ import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dates.presentation.DatesAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.foundation.interfaces.Analytics @@ -23,7 +24,8 @@ class AnalyticsManager : DiscussionAnalytics, ProfileAnalytics, WhatsNewAnalytics, - DownloadsAnalytics { + DownloadsAnalytics, + DatesAnalytics { private val analytics: MutableList = mutableListOf() diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 55b26b492..997ab096d 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -20,6 +20,10 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), + DATES( + "MainDashboard:DATES", + "edx.bi.app.main_dashboard.dates" + ), DOWNLOADS( "MainDashboard:Downloads", "edx.bi.app.main_dashboard.downloads" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 4678344ee..c168a9b5a 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -29,6 +29,7 @@ import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment import org.openedx.courses.presentation.AllEnrolledCoursesFragment import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.presentation.DatesRouter import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment import org.openedx.discovery.presentation.WebViewDiscoveryFragment @@ -69,7 +70,8 @@ class AppRouter : AppUpgradeRouter, WhatsNewRouter, CalendarRouter, - DownloadsRouter { + DownloadsRouter, + DatesRouter { // region AuthRouter override fun navigateToMain( diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 82092e439..397216b74 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -27,6 +27,7 @@ import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.dates.presentation.dates.DatesFragment import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment @@ -104,6 +105,9 @@ class MainFragment : Fragment(R.layout.fragment_main) { if (viewModel.isDownloadsFragmentEnabled) { add(R.id.fragmentDownloads to { DownloadsFragment() }) } + if (viewModel.isDatesFragmentEnabled) { + add(R.id.fragmentDates to { DatesFragment() }) + } add(R.id.fragmentProfile to { ProfileFragment() }) } } @@ -113,12 +117,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), + R.id.fragmentDates to resources.getString(R.string.app_navigation_dates), R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), ) val tabIconSelectors = mapOf( R.id.fragmentLearn to R.drawable.app_ic_learn_selector, R.id.fragmentDiscover to R.drawable.app_ic_discover_selector, R.id.fragmentDownloads to R.drawable.app_ic_downloads_selector, + R.id.fragmentDates to R.drawable.app_ic_dates_selector, R.id.fragmentProfile to R.drawable.app_ic_profile_selector ) @@ -136,6 +142,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn -> viewModel.logLearnTabClickedEvent() R.id.fragmentDiscover -> viewModel.logDiscoveryTabClickedEvent() R.id.fragmentDownloads -> viewModel.logDownloadsTabClickedEvent() + R.id.fragmentDates -> viewModel.logDatesTabClickedEvent() R.id.fragmentProfile -> viewModel.logProfileTabClickedEvent() } menuIdToIndex[menuItem.itemId]?.let { index -> @@ -174,6 +181,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn } + HomeTab.DATES.name -> R.id.fragmentDates HomeTab.PROFILE.name -> R.id.fragmentProfile else -> R.id.fragmentLearn } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 8723d6dbe..74f309e68 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -41,6 +41,7 @@ class MainViewModel( val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() + val isDatesFragmentEnabled get() = config.getDatesConfig().isEnabled val isDownloadsFragmentEnabled get() = config.getDownloadsConfig().isEnabled override fun onCreate(owner: LifecycleOwner) { @@ -65,6 +66,10 @@ class MainViewModel( logScreenEvent(AppAnalyticsEvent.DOWNLOADS) } + fun logDatesTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DATES) + } + fun logProfileTabClickedEvent() { logScreenEvent(AppAnalyticsEvent.PROFILE) } diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index a4daf0809..baafe5a86 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -25,7 +25,7 @@ class HeadersInterceptor( addHeader("Accept", "application/json") val httpAgent = System.getProperty("http.agent") ?: "" - addHeader("User-Agent", "$httpAgent ${appData.versionName}") + addHeader("User-Agent", "$httpAgent ${appData.appUserAgent}") }.build() ) } diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 2192a6b89..32d8ed20e 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -212,7 +212,9 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "VIDEOS" + openTab = "VIDEOS", + resumeBlockId = "", + ) } } @@ -223,7 +225,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "DATES" + openTab = "DATES", + resumeBlockId = "", ) } } @@ -234,7 +237,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "DISCUSSIONS" + openTab = "DISCUSSIONS", + resumeBlockId = "", ) } } @@ -245,7 +249,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "MORE" + openTab = "MORE", + resumeBlockId = "", ) } } diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt index ce72703ad..e687f1589 100644 --- a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -4,6 +4,7 @@ enum class HomeTab { LEARN, PROGRAMS, DISCOVER, + DATES, DOWNLOADS, PROFILE } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index cdb240387..92e4feada 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -65,6 +65,8 @@ import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.presentation.DatesAnalytics +import org.openedx.dates.presentation.DatesRouter import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -131,6 +133,7 @@ val appModule = module { single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } single { get() } + single { get() } single { NetworkConnection(get()) } @@ -177,6 +180,11 @@ val appModule = module { room.calendarDao() } + single { + val room = get() + room.datesDao() + } + single { FileDownloader() } @@ -209,6 +217,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { get() } factory { AgreementProvider(get(), get()) } 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 1d3604050..f2d531918 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -42,6 +42,9 @@ import org.openedx.courses.presentation.DashboardGalleryViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardListViewModel +import org.openedx.dates.data.repository.DatesRepository +import org.openedx.dates.domain.interactor.DatesInteractor +import org.openedx.dates.presentation.dates.DatesViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -583,4 +586,28 @@ val screenModule = module { analytics = get() ) } + + factory { + DatesRepository( + api = get(), + dao = get(), + preferencesManager = get(), + ) + } + factory { + DatesInteractor( + repository = get() + ) + } + viewModel { + DatesViewModel( + datesRouter = get(), + networkConnection = get(), + resourceManager = get(), + datesInteractor = get(), + corePreferences = get(), + analytics = get(), + calendarSyncScheduler = 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 b2f275bb3..3a3316bd0 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -6,6 +6,7 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.CourseDateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity @@ -19,11 +20,12 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter import org.openedx.dashboard.data.DashboardDao +import org.openedx.dates.data.storage.DatesDao 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 = 5 +const val DATABASE_VERSION = 6 const val DATABASE_NAME = "OpenEdX_db" @Suppress("MagicNumber") @@ -38,6 +40,7 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarStateEntity::class, DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class, + CourseDateEntity::class, VideoProgressEntity::class, CourseProgressEntity::class, ], @@ -45,7 +48,8 @@ const val DATABASE_NAME = "OpenEdX_db" AutoMigration(1, 2), AutoMigration(2, 3), AutoMigration(3, 4), - AutoMigration(4, DATABASE_VERSION), + AutoMigration(4, 5), + AutoMigration(5, DATABASE_VERSION), ], version = DATABASE_VERSION ) @@ -55,5 +59,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun courseDao(): CourseDao abstract fun dashboardDao(): DashboardDao abstract fun downloadDao(): DownloadDao + abstract fun datesDao(): DatesDao abstract fun calendarDao(): CalendarDao } diff --git a/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml b/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml new file mode 100644 index 000000000..a3fdccec3 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml b/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml new file mode 100644 index 000000000..000fc5893 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_dates_selector.xml b/app/src/main/res/drawable/app_ic_dates_selector.xml new file mode 100644 index 000000000..9e20819bf --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/main_manu_tab_ids.xml b/app/src/main/res/values/main_manu_tab_ids.xml index f769b5bde..d78543a76 100644 --- a/app/src/main/res/values/main_manu_tab_ids.xml +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -3,5 +3,6 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 801ce0c80..65440a993 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,6 @@ Discover Learn Profile + Dates Downloads diff --git a/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt new file mode 100644 index 000000000..73392bf72 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class AppLevelDatesConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index d26741699..7285a6b66 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -96,6 +96,10 @@ class Config(context: Context) { return getExperimentalFeaturesConfig().appLevelDownloadsConfig } + fun getDatesConfig(): AppLevelDatesConfig { + return getExperimentalFeaturesConfig().appLevelDatesConfig + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } diff --git a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt index 03dd43150..738938835 100644 --- a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -5,4 +5,6 @@ import com.google.gson.annotations.SerializedName data class ExperimentalFeaturesConfig( @SerializedName("APP_LEVEL_DOWNLOADS") val appLevelDownloadsConfig: AppLevelDownloadsConfig = AppLevelDownloadsConfig(), + @SerializedName("APP_LEVEL_DATES") + val appLevelDatesConfig: AppLevelDatesConfig = AppLevelDatesConfig(), ) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index d6e44cfe2..bcc57d826 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -6,6 +6,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseDatesResponse import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseProgressResponse @@ -64,7 +65,8 @@ interface CourseApi { @GET("/api/course_home/v1/dates/{course_id}") suspend fun getCourseDates( @Path("course_id") courseId: String, - @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true + @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true, + @Query("mobile") mobile: Boolean = true, ): CourseDates @POST("/api/course_experience/v1/reset_course_deadlines") @@ -111,8 +113,17 @@ interface CourseApi { @Path("username") username: String ): List + @GET("/api/mobile/v1/course_dates/{username}/") + suspend fun getUserDates( + @Path("username") username: String, + @Query("page") page: Int + ): CourseDatesResponse + @GET("/api/course_home/progress/{course_id}") suspend fun getCourseProgress( @Path("course_id") courseId: String, ): CourseProgressResponse + + @POST("/api/course_experience/v1/reset_all_relative_course_deadlines/") + suspend fun shiftAllDueDates() } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt new file mode 100644 index 000000000..c86500671 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -0,0 +1,56 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseDate as DomainCourseDate +import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesResponse + +data class CourseDate( + @SerializedName("course_id") + val courseId: String, + @SerializedName("first_component_block_id") + val firstComponentBlockId: String?, + @SerializedName("due_date") + val dueDate: String?, + @SerializedName("assignment_title") + val assignmentTitle: String?, + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean?, + @SerializedName("relative") + val relative: Boolean?, + @SerializedName("course_name") + val courseName: String? +) { + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + firstComponentBlockId = firstComponentBlockId ?: "", + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + courseName = courseName ?: "", + relative = relative ?: false + ) + } +} + +data class CourseDatesResponse( + @SerializedName("count") + val count: Int, + @SerializedName("next") + val next: String?, + @SerializedName("previous") + val previous: String?, + @SerializedName("results") + val results: List +) { + fun mapToDomain(): DomainCourseDatesResponse { + return DomainCourseDatesResponse( + count = count, + next = next, + previous = previous, + results = results.mapNotNull { it.mapToDomain() } + ) + } +} 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 00d55a9b5..6c191ee3a 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 @@ -93,22 +93,24 @@ data class CourseProgressResponse( @SerializedName("assignment_colors") val assignmentColors: List? ) { // TODO Temporary solution. Backend will returns color list later - val defaultColors = listOf( - "#D24242", - "#7B9645", - "#5A5AD8", - "#B0842C", - "#2E90C2", - "#D13F88", - "#36A17D", - "#AE5AD8", - "#3BA03B" - ) + companion object { + val DEFAULT_COLORS = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) + } fun mapToRoomEntity() = GradingPolicyDb( assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), gradeRange = gradeRange ?: emptyMap(), - assignmentColors = assignmentColors ?: defaultColors + assignmentColors = assignmentColors ?: DEFAULT_COLORS ) fun mapToDomain() = CourseProgress.GradingPolicy( @@ -116,7 +118,7 @@ data class CourseProgressResponse( gradeRange = gradeRange ?: emptyMap(), assignmentColors = assignmentColors?.map { colorString -> Color(colorString.toColorInt()) - } ?: defaultColors.map { Color(it.toColorInt()) } + } ?: DEFAULT_COLORS.map { Color(it.toColorInt()) } ) data class AssignmentPolicy( diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt new file mode 100644 index 000000000..9d1c1b9a4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt @@ -0,0 +1,60 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.data.model.CourseDate +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseDate as DomainCourseDate + +@Entity(tableName = "course_dates_table") +data class CourseDateEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo("id") + val id: Int, + @ColumnInfo("first_component_block_id") + val firstComponentBlockId: String?, + @ColumnInfo("course_id") + val courseId: String, + @ColumnInfo("due_date") + val dueDate: String?, + @ColumnInfo("assignment_title") + val assignmentTitle: String?, + @ColumnInfo("learner_has_access") + val learnerHasAccess: Boolean?, + @ColumnInfo("relative") + val relative: Boolean?, + @ColumnInfo("course_name") + val courseName: String?, +) { + + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + firstComponentBlockId = firstComponentBlockId ?: "", + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + relative = relative ?: false, + courseName = courseName ?: "" + ) + } + + companion object { + fun createFrom(courseDate: CourseDate): CourseDateEntity { + with(courseDate) { + return CourseDateEntity( + id = 0, + courseId = courseId, + firstComponentBlockId = firstComponentBlockId, + dueDate = dueDate, + assignmentTitle = assignmentTitle, + learnerHasAccess = learnerHasAccess, + relative = relative, + courseName = courseName + ) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt new file mode 100644 index 000000000..5a317b69c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -0,0 +1,20 @@ +package org.openedx.core.domain.model + +import java.util.Date + +data class CourseDatesResponse( + val count: Int, + val next: String?, + val previous: String?, + val results: List +) + +data class CourseDate( + val courseId: String, + val firstComponentBlockId: String, + val dueDate: Date, + val assignmentTitle: String, + val learnerHasAccess: Boolean, + val relative: Boolean, + val courseName: String +) diff --git a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt index d641c79d8..33d884bed 100644 --- a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt @@ -1,6 +1,10 @@ package org.openedx.core.domain.model +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import org.openedx.core.R +import org.openedx.core.ui.theme.appColors enum class DatesSection(val stringResId: Int) { COMPLETED(R.string.core_date_type_completed), @@ -9,5 +13,19 @@ enum class DatesSection(val stringResId: Int) { THIS_WEEK(R.string.core_date_type_this_week), NEXT_WEEK(R.string.core_date_type_next_week), UPCOMING(R.string.core_date_type_upcoming), - NONE(R.string.core_date_type_none) + NONE(R.string.core_date_type_none); + + val color: Color + @Composable + get() { + return when (this) { + COMPLETED -> MaterialTheme.appColors.cardViewBackground + PAST_DUE -> MaterialTheme.appColors.datesSectionBarPastDue + TODAY -> MaterialTheme.appColors.datesSectionBarToday + THIS_WEEK -> MaterialTheme.appColors.datesSectionBarThisWeek + NEXT_WEEK -> MaterialTheme.appColors.datesSectionBarNextWeek + UPCOMING -> MaterialTheme.appColors.datesSectionBarUpcoming + else -> MaterialTheme.appColors.background + } + } } diff --git a/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt b/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt new file mode 100644 index 000000000..016856eb8 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt @@ -0,0 +1,16 @@ +package org.openedx.core.presentation + +enum class ListItemPosition { + FIRST, MIDDLE, LAST, SINGLE; + + companion object { + fun detectPosition(index: Int, list: List): ListItemPosition { + return when { + list.lastIndex == 0 -> SINGLE + index == 0 -> FIRST + index == list.lastIndex -> LAST + else -> MIDDLE + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt new file mode 100644 index 000000000..c57874865 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt @@ -0,0 +1,322 @@ +package org.openedx.core.presentation.dates + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.wrapContentHeight +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.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.unit.dp +import org.openedx.core.R +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils.formatToString +import org.openedx.core.utils.clearTime +import org.openedx.core.utils.isToday + +@Composable +private fun CourseDateBlockSectionGeneric( + sectionKey: DatesSection = DatesSection.NONE, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.padding(start = 8.dp)) { + if (sectionKey != DatesSection.COMPLETED) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 4.dp), + text = stringResource(id = sectionKey.stringResId), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) // ensures all cards share the height of the tallest one. + ) { + if (sectionKey != DatesSection.COMPLETED) { + DateBullet(section = sectionKey) + } + content() + } + } +} + +@Composable +private fun DateBlockContainer(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 8.dp, end = 8.dp) + ) { + content() + } +} + +@Composable +fun CourseDateBlockSection( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + sectionDates: List, + onItemClick: (CourseDateBlock) -> Unit, +) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { + DateBlock( + dateBlocks = sectionDates, + onItemClick = onItemClick, + useRelativeDates = useRelativeDates + ) + } +} + +@JvmName("CourseDateBlockSectionCourseDates") +@Composable +fun CourseDateBlockSection( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + sectionDates: List, + onItemClick: (CourseDate) -> Unit, +) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { + DateBlock( + dateBlocks = sectionDates, + onItemClick = onItemClick, + useRelativeDates = useRelativeDates + ) + } +} + +@Composable +private fun DateBullet( + section: DatesSection = DatesSection.NONE, +) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .padding(top = 2.dp, bottom = 2.dp) + .background( + color = section.color, + shape = MaterialTheme.shapes.medium + ) + ) +} + +@Composable +private fun DateBlock( + dateBlocks: List, + useRelativeDates: Boolean, + onItemClick: (CourseDateBlock) -> Unit, +) { + DateBlockContainer { + var lastAssignmentDate = dateBlocks.first().date.clearTime() + dateBlocks.forEachIndexed { index, dateBlock -> + val canShowDate = if (index == 0) true else (lastAssignmentDate != dateBlock.date) + CourseDateItem(dateBlock, canShowDate, index != 0, useRelativeDates, onItemClick) + lastAssignmentDate = dateBlock.date + } + } +} + +@JvmName("DateBlockCourseDate") +@Composable +private fun DateBlock( + dateBlocks: List, + useRelativeDates: Boolean, + onItemClick: (CourseDate) -> Unit, +) { + DateBlockContainer { + dateBlocks.forEachIndexed { index, dateBlock -> + CourseDateItem(dateBlock, index != 0, useRelativeDates, onItemClick) + } + } +} + +@Composable +private fun CourseDateItem( + dateBlock: CourseDateBlock, + canShowDate: Boolean, + isMiddleChild: Boolean, + useRelativeDates: Boolean, + onItemClick: (CourseDateBlock) -> Unit, +) { + val context = LocalContext.current + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) { + if (isMiddleChild) { + Spacer(modifier = Modifier.height(20.dp)) + } + if (canShowDate) { + val timeTitle = formatToString(context, dateBlock.date, useRelativeDates) + Text( + text = timeTitle, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + .clickable( + enabled = dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess, + onClick = { onItemClick(dateBlock) } + ) + ) { + dateBlock.dateType.drawableResId?.let { icon -> + Icon( + modifier = Modifier + .padding(end = 4.dp) + .align(Alignment.CenterVertically), + painter = painterResource( + id = if (!dateBlock.learnerHasAccess) { + R.drawable.core_ic_lock + } else { + icon + } + ), + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + } + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = if (!dateBlock.assignmentType.isNullOrEmpty()) { + "${dateBlock.assignmentType}: ${dateBlock.title}" + } else { + dateBlock.title + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(7.dp)) + if (dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.textDark, + contentDescription = "Open Block Arrow", + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + ) + } + } + if (dateBlock.description.isNotEmpty()) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = dateBlock.description, + style = MaterialTheme.appTypography.labelMedium, + ) + } + } +} + +@Composable +private fun CourseDateItem( + dateBlock: CourseDate, + isMiddleChild: Boolean, + useRelativeDates: Boolean, + onItemClick: (CourseDate) -> Unit, +) { + val context = LocalContext.current + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) { + if (isMiddleChild) { + Spacer(modifier = Modifier.height(20.dp)) + } + if (!dateBlock.dueDate.isToday()) { + val timeTitle = formatToString(context, dateBlock.dueDate, useRelativeDates) + Text( + text = timeTitle, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + .clickable( + enabled = dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess, + onClick = { onItemClick(dateBlock) } + ) + ) { + Icon( + modifier = Modifier + .padding(end = 4.dp) + .align(Alignment.CenterVertically), + painter = painterResource(R.drawable.core_ic_assignment), + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = dateBlock.assignmentTitle, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(7.dp)) + if (dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.textDark, + contentDescription = "Open Block Arrow", + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + ) + } + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = dateBlock.courseName, + maxLines = 1, + style = MaterialTheme.appTypography.labelMedium, + ) + } +} 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 eed214567..4230980be 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -214,40 +214,6 @@ fun Toolbar( } } -@Composable -fun MainToolbar( - modifier: Modifier = Modifier, - label: String, - onSettingsClick: () -> Unit, -) { - Box( - modifier = modifier.fillMaxWidth() - ) { - Text( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp), - text = label, - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.headlineBold - ) - IconButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 12.dp), - onClick = { - onSettingsClick() - } - ) { - Icon( - imageVector = Icons.Default.ManageAccounts, - tint = MaterialTheme.appColors.textAccent, - contentDescription = stringResource(id = R.string.core_accessibility_settings) - ) - } - } -} - @Composable fun SearchBar( modifier: Modifier, @@ -1310,6 +1276,51 @@ private fun RoundTab( } } +@Composable +fun MainScreenToolbar( + modifier: Modifier = Modifier, + label: String, + onSettingsClick: () -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + text = label, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp), + onClick = { + onSettingsClick() + } + ) { + Icon( + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, + contentDescription = stringResource(id = R.string.core_accessibility_settings) + ) + } + } +} + +@Preview +@Composable +private fun MainScreenTitlePreview() { + OpenEdXTheme { + MainScreenToolbar( + label = "Title", + onSettingsClick = {} + ) + } +} + @Composable fun OpenEdXDropdownMenuItem( modifier: Modifier = Modifier, diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 31541459b..80dca9c03 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -64,6 +64,7 @@ import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.DatesSection import org.openedx.core.presentation.CoreAnalyticsScreen +import org.openedx.core.presentation.dates.CourseDateBlockSection import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.ui.CircularProgress diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index 80c0d5fce..237c8f35a 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -203,7 +203,9 @@ class AllEnrolledCoursesViewModel( dashboardRouter.navigateToCourseOutline( fm = fragmentManager, courseId = courseId, - courseTitle = courseName + courseTitle = courseName, + openTab = "", + resumeBlockId = "" ) } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 780d52569..3e59ee3cd 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -136,6 +136,8 @@ class DashboardListFragment : Fragment() { fm = requireActivity().supportFragmentManager, courseId = it.course.id, courseTitle = it.course.name, + resumeBlockId = "", + openTab = "" ) }, onSwipeRefresh = { diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt index d96744ff1..42251cf05 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -9,8 +9,8 @@ interface DashboardRouter { fm: FragmentManager, courseId: String, courseTitle: String, - openTab: String = "", - resumeBlockId: String = "" + openTab: String, + resumeBlockId: String ) fun navigateToSettings(fm: FragmentManager) diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index b7fe74fd0..1c77ffa72 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -41,7 +41,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.viewBinding -import org.openedx.core.ui.MainToolbar +import org.openedx.core.ui.MainScreenToolbar import org.openedx.core.ui.crop import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -137,7 +137,7 @@ private fun Header( .then(contentWidth), horizontalAlignment = Alignment.CenterHorizontally ) { - MainToolbar( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -240,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - MainToolbar( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = {} ) diff --git a/dates/.gitignore b/dates/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/dates/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/dates/build.gradle b/dates/build.gradle new file mode 100644 index 000000000..605a731bf --- /dev/null +++ b/dates/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" +} + +android { + compileSdk 34 + + defaultConfig { + minSdk 24 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + namespace 'org.openedx.dates' + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") + } + + buildFeatures { + viewBinding true + compose true + } + + flavorDimensions += "env" + productFlavors { + prod { + dimension 'env' + } + develop { + dimension 'env' + } + stage { + dimension 'env' + } + } +} + +dependencies { + implementation project(path: ':core') + + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "io.mockk:mockk-android:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" +} diff --git a/dates/consumer-rules.pro b/dates/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/dates/proguard-rules.pro b/dates/proguard-rules.pro new file mode 100644 index 000000000..cdb308aa0 --- /dev/null +++ b/dates/proguard-rules.pro @@ -0,0 +1,7 @@ +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/dates/src/main/AndroidManifest.xml b/dates/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/dates/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt new file mode 100644 index 000000000..f261d312d --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -0,0 +1,38 @@ +package org.openedx.dates.data.repository + +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.CourseDateEntity +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse +import org.openedx.dates.data.storage.DatesDao + +class DatesRepository( + private val api: CourseApi, + private val dao: DatesDao, + private val preferencesManager: CorePreferences +) { + suspend fun getUserDates(page: Int): CourseDatesResponse { + val username = preferencesManager.user?.username ?: "" + val response = api.getUserDates(username, page) + if (page == 1) { + dao.clearCachedData() + } + dao.insertCourseDates(response.results.map { CourseDateEntity.createFrom(it) }) + return response.mapToDomain() + } + + suspend fun getUserDatesFromCache(): List { + return dao.getCourseDates().mapNotNull { it.mapToDomain() } + } + + suspend fun preloadFirstPageCachedDates(): List { + return dao.getCourseDates(PAGE_SIZE).mapNotNull { it.mapToDomain() } + } + + suspend fun shiftAllDueDates() = api.shiftAllDueDates() + + companion object { + private const val PAGE_SIZE = 20 + } +} diff --git a/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt new file mode 100644 index 000000000..e8df66ad2 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -0,0 +1,23 @@ +package org.openedx.dates.data.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.openedx.core.data.model.room.CourseDateEntity + +@Dao +interface DatesDao { + + @Query("SELECT * FROM course_dates_table") + suspend fun getCourseDates(): List + + @Query("SELECT * FROM course_dates_table LIMIT :limit") + suspend fun getCourseDates(limit: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseDates(courseDates: List) + + @Query("DELETE FROM course_dates_table") + suspend fun clearCachedData() +} diff --git a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt new file mode 100644 index 000000000..5bcb8abf1 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -0,0 +1,16 @@ +package org.openedx.dates.domain.interactor + +import org.openedx.dates.data.repository.DatesRepository + +class DatesInteractor( + private val repository: DatesRepository +) { + + suspend fun getUserDates(page: Int) = repository.getUserDates(page) + + suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() + + suspend fun preloadFirstPageCachedDates() = repository.preloadFirstPageCachedDates() + + suspend fun shiftAllDueDates() = repository.shiftAllDueDates() +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt new file mode 100644 index 000000000..1abd002e7 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt @@ -0,0 +1,20 @@ +package org.openedx.dates.presentation + +interface DatesAnalytics { + fun logEvent(event: String, params: Map) +} + +enum class DatesAnalyticsEvent(val eventName: String, val biValue: String) { + ASSIGNMENT_CLICK( + "Dates:Assignment click", + "edx.bi.app.dates.assignment_click" + ), + SHIFT_DUE_DATE_CLICK( + "Dates:Shift due date click", + "edx.bi.app.dates.shift_due_date_click" + ), +} + +enum class DatesAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt new file mode 100644 index 000000000..01e06ed38 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt @@ -0,0 +1,16 @@ +package org.openedx.dates.presentation + +import androidx.fragment.app.FragmentManager + +interface DatesRouter { + + fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + openTab: String, + resumeBlockId: String + ) +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt new file mode 100644 index 000000000..2d28bb389 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -0,0 +1,72 @@ +package org.openedx.dates.presentation.dates + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.theme.OpenEdXTheme + +class DatesFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + DatesScreen( + uiState = uiState, + uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, + useRelativeDates = viewModel.useRelativeDates, + onAction = { action -> + when (action) { + DatesViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DatesViewActions.SwipeRefresh -> { + viewModel.refreshData() + } + + DatesViewActions.LoadMore -> { + viewModel.fetchMore() + } + + DatesViewActions.ShiftDueDate -> { + viewModel.shiftAllDueDates() + } + + is DatesViewActions.OpenEvent -> { + viewModel.navigateToCourseOutline( + requireActivity().supportFragmentManager, + action.date + ) + } + } + } + ) + } + } + } + + companion object { + const val LOAD_MORE_THRESHOLD = 0.8f + } +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt new file mode 100644 index 000000000..89f38f13f --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -0,0 +1,328 @@ +package org.openedx.dates.presentation.dates + +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.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.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.presentation.dates.CourseDateBlockSection +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.MainScreenToolbar +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.statusBarsInset +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.dates.R +import org.openedx.dates.presentation.dates.DatesFragment.Companion.LOAD_MORE_THRESHOLD +import org.openedx.foundation.extension.isNotEmptyThenLet +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DatesScreen( + uiState: DatesUIState, + uiMessage: UIMessage?, + hasInternetConnection: Boolean, + useRelativeDates: Boolean, + onAction: (DatesViewActions) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val windowSize = rememberWindowSize() + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { onAction(DatesViewActions.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + val scrollState = rememberLazyListState() + val layoutInfo by remember { derivedStateOf { scrollState.layoutInfo } } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainScreenToolbar( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.dates_title), + onSettingsClick = { + onAction(DatesViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading && uiState.dates.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else if (uiState.dates.isEmpty()) { + EmptyState() + } else { + Box( + modifier = Modifier + .fillMaxSize() + .displayCutoutForLandscape() + .padding(paddingValues) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + LazyColumn( + modifier = contentWidth.fillMaxSize(), + state = scrollState, + contentPadding = PaddingValues(bottom = 48.dp, top = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + uiState.dates.keys.forEach { sectionKey -> + val dates = uiState.dates[sectionKey].orEmpty() + dates.isNotEmptyThenLet { sectionDates -> + val isHavePastRelatedDates = + sectionKey == DatesSection.PAST_DUE && dates.any { it.relative } + if (isHavePastRelatedDates) { + item { + ShiftDueDatesCard( + isButtonEnabled = !uiState.isShiftDueDatesPressed, + onClick = { + onAction(DatesViewActions.ShiftDueDate) + } + ) + } + } + item { + CourseDateBlockSection( + sectionKey = sectionKey, + sectionDates = sectionDates, + onItemClick = { + onAction(DatesViewActions.OpenEvent(it)) + }, + useRelativeDates = useRelativeDates + ) + } + } + } + if (uiState.canLoadMore) { + item { + Box( + Modifier + .fillMaxWidth() + .height(42.dp) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } + } + val lastVisibleItemIndex = + layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItemsCount = layoutInfo.totalItemsCount + if (totalItemsCount > 0 && + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + ) { + onAction(DatesViewActions.LoadMore) + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + PullRefreshIndicator( + uiState.isRefreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DatesViewActions.SwipeRefresh) + } + ) + } + } + } + ) +} + +@Composable +private fun ShiftDueDatesCard( + modifier: Modifier = Modifier, + isButtonEnabled: Boolean, + onClick: () -> Unit +) { + Card( + modifier = modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.cardShape, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.dates_shift_due_date_card_title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.dates_shift_due_date_card_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelLarge, + ) + OpenEdXButton( + text = stringResource(id = R.string.dates_shift_due_date), + enabled = isButtonEnabled, + onClick = onClick + ) + } + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(100.dp), + imageVector = Icons.Outlined.CalendarMonth, + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource(id = R.string.dates_empty_state_title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_description") + .fillMaxWidth(), + text = stringResource(id = R.string.dates_empty_state_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun DatesScreenPreview() { + OpenEdXTheme { + DatesScreen( + uiState = DatesUIState(isLoading = false), + uiMessage = null, + hasInternetConnection = true, + useRelativeDates = true, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun ShiftDueDatesCardPreview() { + OpenEdXTheme { + ShiftDueDatesCard( + isButtonEnabled = true, + onClick = {} + ) + } +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt new file mode 100644 index 000000000..0dd6464b2 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.dates.presentation.dates + +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.DatesSection + +data class DatesUIState( + val isLoading: Boolean = true, + val isShiftDueDatesPressed: Boolean = false, + val isRefreshing: Boolean = false, + val canLoadMore: Boolean = false, + val dates: Map> = emptyMap() +) diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt new file mode 100644 index 000000000..3fa4f3d02 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt @@ -0,0 +1,269 @@ +package org.openedx.dates.presentation.dates + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.R +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.extension.isNotNull +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.isToday +import org.openedx.core.utils.toCalendar +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.dates.domain.interactor.DatesInteractor +import org.openedx.dates.presentation.DatesAnalytics +import org.openedx.dates.presentation.DatesAnalyticsEvent +import org.openedx.dates.presentation.DatesAnalyticsKey +import org.openedx.dates.presentation.DatesRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import java.util.Calendar +import java.util.Date + +class DatesViewModel( + private val datesRouter: DatesRouter, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, + private val datesInteractor: DatesInteractor, + private val analytics: DatesAnalytics, + private val calendarSyncScheduler: CalendarSyncScheduler, + corePreferences: CorePreferences, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(DatesUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + var useRelativeDates = corePreferences.isRelativeDatesEnabled + + private var page = 1 + private var fetchDataJob: Job? = null + + init { + preloadFirstPageCachedDates() + fetchDates(false) + } + + private fun fetchDates(refresh: Boolean) { + if (refresh) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page = 1 + } + fetchDataJob = viewModelScope.launch { + try { + updateLoadingState(refresh) + val response = datesInteractor.getUserDates(page) + updateUIWithResponse(response, refresh) + } catch (e: Exception) { + page = -1 + updateUIWithCachedResponse() + handleFetchException(e) + } finally { + clearLoadingState() + } + } + } + + private fun updateLoadingState(refresh: Boolean) { + _uiState.update { state -> + state.copy( + isLoading = !refresh, + isRefreshing = refresh + ) + } + } + + private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { + _uiState.update { state -> + if (refresh || page == 1) { + state.copy(dates = groupCourseDates(response.results)) + } else { + val newDates = groupCourseDates(response.results) + state.copy(dates = mergeDates(state.dates, newDates)) + } + } + if (response.next.isNotNull()) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { state -> state.copy(canLoadMore = false) } + } + } + + private suspend fun updateUIWithCachedResponse() { + val cachedList = datesInteractor.getUserDatesFromCache() + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList), + canLoadMore = false + ) + } + } + + private fun preloadFirstPageCachedDates() { + viewModelScope.launch { + val cachedList = datesInteractor.preloadFirstPageCachedDates() + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList), + canLoadMore = true + ) + } + } + } + + private suspend fun handleFetchException(e: Throwable) { + if (e.isInternetError()) { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + ) + } else { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + ) + } + } + + private fun clearLoadingState() { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false + ) + } + } + + fun shiftAllDueDates() { + logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) + viewModelScope.launch { + try { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = true, + ) + } + datesInteractor.shiftAllDueDates() + refreshData() + calendarSyncScheduler.requestImmediateSync() + } catch (e: Exception) { + handleFetchException(e) + } finally { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = false, + ) + } + } + } + } + + fun fetchMore() { + if (!_uiState.value.isLoading && + !_uiState.value.isRefreshing && + _uiState.value.canLoadMore + ) { + fetchDates(false) + } + } + + fun refreshData() { + fetchDataJob?.cancel() + fetchDates(true) + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + datesRouter.navigateToSettings(fragmentManager) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + courseDate: CourseDate, + ) { + logEvent(DatesAnalyticsEvent.ASSIGNMENT_CLICK) + datesRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = courseDate.courseId, + courseTitle = courseDate.courseName, + openTab = "", + resumeBlockId = courseDate.firstComponentBlockId + ) + } + + private fun groupCourseDates(dates: List): Map> { + val now = Date() + val calendar = Calendar.getInstance().apply { time = now } + return dates.groupBy { courseDate -> + when { + courseDate.dueDate.before(now) -> DatesSection.PAST_DUE + courseDate.dueDate.isToday() -> DatesSection.TODAY + else -> { + val calDue = courseDate.dueDate.toCalendar() + val weekNow = calendar.get(Calendar.WEEK_OF_YEAR) + val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) + val yearNow = calendar.get(Calendar.YEAR) + val yearDue = calDue.get(Calendar.YEAR) + if (weekNow == weekDue && yearNow == yearDue) { + DatesSection.THIS_WEEK + } else if (yearNow == yearDue && weekDue == weekNow + 1) { + DatesSection.NEXT_WEEK + } else { + DatesSection.UPCOMING + } + } + } + } + } + + private fun mergeDates( + oldDates: Map>, + newDates: Map> + ): Map> { + val merged = oldDates.toMutableMap() + newDates.forEach { (section, newList) -> + val existingList = merged[section] ?: emptyList() + merged[section] = existingList + newList + } + return merged + } + + private fun logEvent( + event: DatesAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(DatesAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } +} + +interface DatesViewActions { + object OpenSettings : DatesViewActions + class OpenEvent(val date: CourseDate) : DatesViewActions + object LoadMore : DatesViewActions + object SwipeRefresh : DatesViewActions + object ShiftDueDate : DatesViewActions +} diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml new file mode 100644 index 000000000..93eaa1bc9 --- /dev/null +++ b/dates/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Dates + No Dates + You currently have no active courses with scheduled events. Enroll in a course to view important dates and deadlines. + Missed Some Deadlines? + Don\'t worry - shift our suggested schedule to complete the due assignments without losing any progress. + Shift Due Dates + diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt new file mode 100644 index 000000000..4bb903753 --- /dev/null +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -0,0 +1,378 @@ +package org.openedx.dates + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.fragment.app.FragmentManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.openedx.core.R +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.dates.domain.interactor.DatesInteractor +import org.openedx.dates.presentation.DatesAnalytics +import org.openedx.dates.presentation.DatesRouter +import org.openedx.dates.presentation.dates.DatesViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import java.net.UnknownHostException +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class DatesViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val datesRouter = mockk(relaxed = true) + private val networkConnection = mockk() + private val resourceManager = mockk() + private val datesInteractor = mockk() + private val corePreferences = mockk() + private val calendarSyncScheduler = mockk() + private val analytics = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + // By default, assume we have an internet connection + every { networkConnection.isOnline() } returns true + every { corePreferences.isRelativeDatesEnabled } returns true + every { analytics.logEvent(any(), any()) } returns Unit + coEvery { datesInteractor.preloadFirstPageCachedDates() } returns emptyList() + coEvery { datesInteractor.getUserDatesFromCache() } returns emptyList() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `init fetchDates online with pagination`() = runTest { + // Create a dummy CourseDate; grouping is done inside the view model so the exact grouping is not under test. + val courseDate: CourseDate = mockk(relaxed = true) + val courseDatesResponse = CourseDatesResponse( + count = 10, + next = "", + previous = "", + results = listOf(courseDate) + ) + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + + // Instantiate the view model; fetchDates is called in init. + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences, + ) + advanceUntilIdle() + + coVerify(exactly = 1) { datesInteractor.getUserDates(1) } + // Since next is not null and page (1) != count (10), canLoadMore should be true. + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(viewModel.uiState.value.canLoadMore) + } + + @Test + fun `init fetchDates offline uses cache`() = runTest { + every { networkConnection.isOnline() } returns false + val cachedCourseDate: CourseDate = mockk(relaxed = true) + coEvery { datesInteractor.getUserDatesFromCache() } returns listOf(cachedCourseDate) + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + coVerify(exactly = 1) { datesInteractor.getUserDatesFromCache() } + assertFalse(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.canLoadMore) + } + + @Test + fun `fetchDates unknown error emits unknown error message`() = + runTest(UnconfinedTestDispatcher()) { + every { networkConnection.isOnline() } returns true + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + advanceUntilIdle() + + assertEquals(somethingWrong, message.await()?.message) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `fetchDates internet error emits no connection message`() = + runTest(UnconfinedTestDispatcher()) { + every { networkConnection.isOnline() } returns true + coEvery { datesInteractor.getUserDates(any()) } throws UnknownHostException() + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + advanceUntilIdle() + + assertEquals(noInternet, message.await()?.message) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `shiftAllDueDates success`() = runTest { + every { networkConnection.isOnline() } returns true + // Prepare a dummy CourseDate that qualifies as past due and is marked as relative. + val courseDate: CourseDate = mockk(relaxed = true) { + every { relative } returns true + every { courseId } returns "course-123" + // Set dueDate to yesterday. + every { dueDate } returns Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + val courseDatesResponse = CourseDatesResponse( + count = 1, + next = null, + previous = null, + results = listOf(courseDate) + ) + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + // When refreshData is triggered from shiftDueDate, return the same response. + coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftAllDueDates() + advanceUntilIdle() + + coVerify { datesInteractor.shiftAllDueDates() } + // isShiftDueDatesPressed should be reset to false after processing. + assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) + } + + @Test + fun `shiftAllDueDates error emits error message and resets flag`() = + runTest(UnconfinedTestDispatcher()) { + every { networkConnection.isOnline() } returns true + val courseDate: CourseDate = mockk(relaxed = true) { + every { relative } returns true + every { courseId } returns "course-123" + every { dueDate } returns Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + val courseDatesResponse = CourseDatesResponse( + count = 1, + next = null, + previous = null, + results = listOf(courseDate) + ) + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + coEvery { datesInteractor.shiftAllDueDates() } throws Exception() + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftAllDueDates() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + advanceUntilIdle() + + assertEquals(somethingWrong, message.await()?.message) + assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) + } + + @Test + fun `onSettingsClick navigates to settings`() = runTest { + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val fragmentManager = mockk(relaxed = true) + + viewModel.onSettingsClick(fragmentManager) + verify { datesRouter.navigateToSettings(fragmentManager) } + } + + @Test + fun `navigateToCourseOutline calls router with correct parameters`() = runTest { + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val fragmentManager = mockk(relaxed = true) + val courseDate: CourseDate = mockk(relaxed = true) { + every { courseId } returns "course-123" + every { courseName } returns "Test Course" + every { firstComponentBlockId } returns "block-1" + } + + viewModel.navigateToCourseOutline(fragmentManager, courseDate) + verify { + datesRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = "course-123", + courseTitle = "Test Course", + openTab = "", + resumeBlockId = "block-1" + ) + } + } + + @Test + fun `fetchMore calls fetchDates when allowed`() = runTest { + every { networkConnection.isOnline() } returns true + val courseDate: CourseDate = mockk(relaxed = true) + val courseDatesResponse = CourseDatesResponse( + count = 10, + next = "", + previous = "", + results = listOf(courseDate) + ) + + // Initial fetch on page 1. + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + // For subsequent fetch, we return a similar response. + coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.fetchMore() + advanceUntilIdle() + + // Expect two calls (one from init and one from fetchMore) + coVerify(exactly = 2) { datesInteractor.getUserDates(any()) } + } + + @Test + fun `refreshData calls fetchDates with refresh true`() = runTest { + every { networkConnection.isOnline() } returns true + val courseDate: CourseDate = mockk(relaxed = true) + val courseDatesResponse = CourseDatesResponse( + count = 1, + next = null, + previous = null, + results = listOf(courseDate) + ) + // Initial fetch. + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + // For refresh, return the same response. + coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + // Two calls: one on init, one on refresh. + coVerify(exactly = 2) { datesInteractor.getUserDates(any()) } + // After refresh, isRefreshing should be false. + assertFalse(viewModel.uiState.value.isRefreshing) + } +} diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index a7f265a45..d30d38719 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -67,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: true #Platform names PLATFORM_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index a7f265a45..d30d38719 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -67,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: true #Platform names PLATFORM_NAME: "OpenEdX" diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt index ae060851c..dafbde1b6 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -78,7 +78,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.DownloadedState.LOADING_COURSE_STRUCTURE import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText -import org.openedx.core.ui.MainToolbar +import org.openedx.core.ui.MainScreenToolbar import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXDropdownMenuItem @@ -130,7 +130,7 @@ fun DownloadsScreen( .fillMaxSize(), backgroundColor = MaterialTheme.appColors.background, topBar = { - MainToolbar( + MainScreenToolbar( modifier = Modifier .statusBarsInset() .displayCutoutForLandscape(), diff --git a/settings.gradle b/settings.gradle index a58940420..eccc1db15 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,4 +46,5 @@ include ':discovery' include ':profile' include ':discussion' include ':whatsnew' +include ':dates' include ':downloads'