diff --git a/.github/ISSUE_TEMPLATE/roadmap-entry.md b/.github/ISSUE_TEMPLATE/roadmap-entry.md new file mode 100644 index 000000000..4167e9e34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/roadmap-entry.md @@ -0,0 +1,14 @@ +--- +name: Roadmap project entry +about: Add roadmap entry to the project +title: '[Mobile] [Android] Roadmap project name' +labels: '' +assignees: '' + +--- + +### Goal +Describe the goal for this project + +**Slack:** #wg-product-mobile +**Requirement Definition:** add Confluence project page link (or external public documentation) diff --git a/README.md b/README.md index 5c09e9f90..8e2c85867 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G [Documentation](Documentation/Documentation.md) ## Building + 1. Check out the source code: - git clone https://github.com/raccoongang/educationx-app-ios.git + git clone https://github.com/openedx/openedx-app-android.git 2. Open Android Studio and choose Open an Existing Android Studio Project. -3. Choose ``educationx-app-android``. +3. Choose ``openedx-app-android``. 4. Configure the [config.yaml](config.yaml) with URLs and OAuth credentials for your Open edX instance. @@ -20,11 +21,13 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 6. Click the **Run** button. ## API plugin + This project uses custom APIs to improve performance and reduce the number of requests to the server. You can find the plugin with the API and installation guide [here](https://github.com/raccoongang/mobile-api-extensions). ## License + The code in this repository is licensed under the Apache-2.0 license unless otherwise noted. -Please see [LICENSE](https://github.com/raccoongang/educationx-app-android/blob/main/LICENSE) file for details. +Please see [LICENSE](https://github.com/openedx/openedx-app-android/blob/main/LICENSE) file for details. diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 0efcb7d54..6ad466e90 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -320,6 +320,15 @@ class AnalyticsManager(context: Context) : DashboardAnalytics, AuthAnalytics, Ap ) } + override fun datesTabClickedEvent(courseId: String, courseName: String) { + logEvent( + Event.DATES_TAB_CLICKED, bundleOf( + Key.COURSE_ID.keyName to courseId, + Key.COURSE_NAME.keyName to courseName + ) + ) + } + override fun handoutsTabClickedEvent(courseId: String, courseName: String) { logEvent( Event.HANDOUTS_TAB_CLICKED, bundleOf( @@ -402,6 +411,7 @@ private enum class Event(val eventName: String) { COURSE_TAB_CLICKED("Course_Outline_Course_tab_Clicked"), VIDEO_TAB_CLICKED("Course_Outline_Videos_tab_Clicked"), DISCUSSION_TAB_CLICKED("Course_Outline_Discussion_tab_Clicked"), + DATES_TAB_CLICKED("Course_Outline_Dates_tab_Clicked"), HANDOUTS_TAB_CLICKED("Course_Outline_Handouts_tab_Clicked"), DISCUSSION_ALL_POSTS_CLICKED("Discussion_All_Posts_Clicked"), DISCUSSION_FOLLOWING_CLICKED("Discussion_Following_Clicked"), 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 f61cf393f..94c219d88 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -15,11 +15,14 @@ import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogView import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.detail.CourseDetailsViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel +import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.dashboard.data.repository.DashboardRepository @@ -41,14 +44,12 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account +import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.profile.ProfileViewModel import org.openedx.profile.presentation.settings.video.VideoQualityViewModel import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel -import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel -import org.openedx.course.presentation.unit.video.VideoUnitViewModel -import org.openedx.profile.presentation.anothers_account.AnothersProfileViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { @@ -88,9 +89,10 @@ val screenModule = module { viewModel { (courseId: String) -> CourseSectionViewModel(get(), get(), get(), get(), get(), get(), get(), get(), courseId) } viewModel { (courseId: String) -> CourseUnitContainerViewModel(get(), get(), get(), courseId) } viewModel { (courseId: String) -> CourseVideoViewModel(courseId, get(), get(), get(), get(), get(), get(), get()) } - viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get()) } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get()) } viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) } - viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), get(), get(), get(), get()) } + viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel(courseId, blockId, get(), get(), get(), get(), get(), get()) } + viewModel { (courseId: String) -> CourseDatesViewModel(courseId, get(), get(), get()) } viewModel { (courseId:String, handoutsType: String) -> HandoutsViewModel(courseId, handoutsType, get()) } viewModel { CourseSearchViewModel(get(), get(), get()) } viewModel { SelectDialogViewModel(get()) } diff --git a/build.gradle b/build.gradle index 20740b4cf..95c1adc89 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,8 @@ ext { in_app_review = '2.0.1' + extented_spans_version = "1.3.0" + //testing mockk_version = '1.13.3' android_arch_version = '2.2.0' diff --git a/core/build.gradle b/core/build.gradle index 86d5c31d4..208371e6d 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -40,66 +40,15 @@ android { productFlavors { prod { dimension 'env' - - def envMap = config.environments.find { it.key == "PROD" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(config, it, "PROD") } develop { dimension 'env' - - def envMap = config.environments.find { it.key == "DEV" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(config, it, "DEV") } stage { dimension 'env' - - def envMap = config.environments.find { it.key == "STAGE" } - def clientId = envMap.value.OAUTH_CLIENT_ID - def envUrls = envMap.value.URLS - def firebase = getFirebaseConfig(envMap) - - buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" - buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" - buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" - buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" - buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" - resValue "string", "google_app_id", firebase.appId - resValue "string", "platform_name", config.platformName - resValue "string", "platform_full_name", config.platformFullName - resValue "string", "privacy_policy_link", envUrls.privacyPolicy - resValue "string", "terms_of_service_link", envUrls.termsOfService - resValue "string", "contact_us_link", envUrls.contactUs - resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + insertBuildConfigFields(config, it, "STAGE") } } @@ -219,3 +168,23 @@ def setValue(value) { } return result } + +def insertBuildConfigFields(config, buildType, String keyName) { + def envMap = config.environments.find { it.key == keyName } + def clientId = envMap.value.OAUTH_CLIENT_ID + def envUrls = envMap.value.URLS + def firebase = getFirebaseConfig(envMap) + + buildType.buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" + buildType.buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" + buildType.buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" + buildType.buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" + buildType.buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" + buildType.resValue "string", "google_app_id", firebase.appId + buildType.resValue "string", "platform_name", config.platformName + buildType.resValue "string", "platform_full_name", config.platformFullName + buildType.resValue "string", "privacy_policy_link", envUrls.privacyPolicy + buildType.resValue "string", "terms_of_service_link", envUrls.termsOfService + buildType.resValue "string", "contact_us_link", envUrls.contactUs + buildType.resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS +} 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 89538252d..9ea1ead5a 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 @@ -1,7 +1,7 @@ package org.openedx.core.data.api -import org.openedx.core.data.model.* import okhttp3.ResponseBody +import org.openedx.core.data.model.* import retrofit2.http.* interface CourseApi { @@ -65,6 +65,9 @@ interface CourseApi { blocksCompletionBody: BlocksCompletionBody ) + @GET("/api/course_home/v1/dates/{course_id}") + suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + @GET("/api/mobile/v1/course_info/{course_id}/handouts") suspend fun getHandouts(@Path("course_id") courseId: String): HandoutsModel diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt new file mode 100644 index 000000000..887112845 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import java.util.* + +data class CourseDateBlock( + @SerializedName("complete") + val complete: Boolean = false, + @SerializedName("date") + val date: String = "", // ISO 8601 compliant format + @SerializedName("assignment_type") + val assignmentType: String? = "", + @SerializedName("date_type") + val dateType: DateType = DateType.NONE, + @SerializedName("description") + val description: String = "", + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean = false, + @SerializedName("link") + val link: String = "", + @SerializedName("link_text") + val linkText: String = "", + @SerializedName("title") + val title: String = "", + // component blockId in-case of navigating inside the app for component available in mobile + @SerializedName("first_component_block_id") + val blockId: String = "", +) diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt new file mode 100644 index 000000000..b1c9ca951 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt @@ -0,0 +1,163 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.presentation.course.CourseDatesBadge +import org.openedx.core.utils.TimeUtils +import java.util.Date +import org.openedx.core.domain.model.CourseDateBlock as DomainCourseDateBlock + +data class CourseDates( + @SerializedName("dates_banner_info") + val datesBannerInfo: CourseDatesBannerInfo?, + @SerializedName("course_date_blocks") + val courseDateBlocks: List, + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("learner_is_full_access") + val learnerIsFullAccess: Boolean = false, + @SerializedName("user_timezone") + val userTimezone: String? = "", + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String? = "", +) { + fun mapToDomain(): LinkedHashMap> { + var courseDatesDomain = organiseCourseDatesInBlock() + if (isContainToday().not()) { + // Adding today's date block manually if not present in the date + val todayBlock = DomainCourseDateBlock.getTodayDateBlock() + courseDatesDomain[TimeUtils.formatDate(TimeUtils.FORMAT_DATE, todayBlock.date)] = + arrayListOf(todayBlock) + } + // Sort the map entries date keys wise + courseDatesDomain = LinkedHashMap(courseDatesDomain.toSortedMap(compareBy { + TimeUtils.stringToDate(TimeUtils.FORMAT_DATE, it) + })) + reviseDateBlockBadge(courseDatesDomain) + return courseDatesDomain + } + + /** + * Map the date blocks according to dates and stack all the blocks of same date against one key + */ + private fun organiseCourseDatesInBlock(): LinkedHashMap> { + val courseDates = + LinkedHashMap>() + courseDateBlocks.forEach { item -> + val key = + TimeUtils.formatDate(TimeUtils.FORMAT_DATE, TimeUtils.iso8601ToDate(item.date)) + val dateBlock = DomainCourseDateBlock( + title = item.title, + description = item.description, + link = item.link, + blockId = item.blockId, + date = TimeUtils.iso8601ToDate(item.date), + complete = item.complete, + learnerHasAccess = item.learnerHasAccess, + dateType = item.dateType, + dateBlockBadge = CourseDatesBadge.BLANK + ) + if (courseDates.containsKey(key)) { + (courseDates[key] as ArrayList).add(dateBlock) + } else { + courseDates[key] = arrayListOf(dateBlock) + } + } + return courseDates + } + + /** + * Utility method to check that list contains today's date block or not. + */ + private fun isContainToday(): Boolean { + val today = Date() + return courseDateBlocks.any { blockDate -> + TimeUtils.iso8601ToDate(blockDate.date) == today + } + } + + /** + * Set the Date Block Badge based on the date block data + */ + private fun reviseDateBlockBadge(courseDatesDomain: LinkedHashMap>) { + var dueNextCount = 0 + courseDatesDomain.keys.forEach { key -> + courseDatesDomain[key]?.forEach { item -> + var dateBlockTag: CourseDatesBadge = getDateTypeBadge(item) + //Setting Due Next only for first occurrence + if (dateBlockTag == CourseDatesBadge.DUE_NEXT) { + if (dueNextCount == 0) + dueNextCount += 1 + else + dateBlockTag = CourseDatesBadge.BLANK + } + item.dateBlockBadge = dateBlockTag + } + } + } + + /** + * Return Pill/Badge type of date block based on data + */ + private fun getDateTypeBadge(item: DomainCourseDateBlock): CourseDatesBadge { + val dateBlockTag: CourseDatesBadge + val currentDate = Date() + val componentDate: Date = item.date ?: return CourseDatesBadge.BLANK + when (item.dateType) { + DateType.TODAY_DATE -> { + dateBlockTag = CourseDatesBadge.TODAY + } + + DateType.COURSE_START_DATE, + DateType.COURSE_END_DATE -> { + dateBlockTag = CourseDatesBadge.BLANK + } + + DateType.ASSIGNMENT_DUE_DATE -> { + when { + item.complete -> { + dateBlockTag = CourseDatesBadge.COMPLETED + } + + item.learnerHasAccess -> { + dateBlockTag = when { + item.link.isEmpty() -> { + CourseDatesBadge.NOT_YET_RELEASED + } + + TimeUtils.isDueDate(currentDate, componentDate) -> { + CourseDatesBadge.DUE_NEXT + } + + TimeUtils.isDatePassed(currentDate, componentDate) -> { + CourseDatesBadge.PAST_DUE + } + + else -> { + CourseDatesBadge.BLANK + } + } + } + + else -> { + dateBlockTag = CourseDatesBadge.VERIFIED_ONLY + } + } + } + + DateType.COURSE_EXPIRED_DATE -> { + dateBlockTag = CourseDatesBadge.COURSE_EXPIRED_DATE + } + + else -> { + // dateBlockTag is BLANK for all other cases + // DateTypes.CERTIFICATE_AVAILABLE_DATE, + // DateTypes.VERIFIED_UPGRADE_DEADLINE, + // DateTypes.VERIFICATION_DEADLINE_DATE + dateBlockTag = CourseDatesBadge.BLANK + } + } + return dateBlockTag + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt new file mode 100644 index 000000000..f3363dfed --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class CourseDatesBannerInfo( + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String = "", + @SerializedName("content_type_gating_enabled") + val contentTypeGatingEnabled: Boolean = false, +) { + fun getCourseBannerType(): CourseBannerType = when { + upgradeToGraded() -> CourseBannerType.UPGRADE_TO_GRADED + upgradeToReset() -> CourseBannerType.UPGRADE_TO_RESET + resetDates() -> CourseBannerType.RESET_DATES + showBannerInfo() -> CourseBannerType.INFO_BANNER + else -> CourseBannerType.BLANK + } + + private fun showBannerInfo(): Boolean = missedDeadlines.not() + + private fun upgradeToGraded(): Boolean = contentTypeGatingEnabled && missedDeadlines.not() + + private fun upgradeToReset(): Boolean = + upgradeToGraded().not() && missedDeadlines && missedGatedContent + + private fun resetDates(): Boolean = + upgradeToGraded().not() && missedDeadlines && missedGatedContent.not() +} + +enum class CourseBannerType { + BLANK, INFO_BANNER, UPGRADE_TO_GRADED, UPGRADE_TO_RESET, RESET_DATES; +} diff --git a/core/src/main/java/org/openedx/core/data/model/DateType.kt b/core/src/main/java/org/openedx/core/data/model/DateType.kt new file mode 100644 index 000000000..e9af5256b --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DateType.kt @@ -0,0 +1,31 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +enum class DateType { + @SerializedName("todays-date") + TODAY_DATE, + + @SerializedName("course-start-date") + COURSE_START_DATE, + + @SerializedName("course-end-date") + COURSE_END_DATE, + + @SerializedName("course-expired-date") + COURSE_EXPIRED_DATE, + + @SerializedName("assignment-due-date") + ASSIGNMENT_DUE_DATE, + + @SerializedName("certificate-available-date") + CERTIFICATE_AVAILABLE_DATE, + + @SerializedName("verified-upgrade-deadline") + VERIFIED_UPGRADE_DEADLINE, + + @SerializedName("verification-deadline-date") + VERIFICATION_DEADLINE_DATE, + + NONE, +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt new file mode 100644 index 000000000..60f3a49ba --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -0,0 +1,26 @@ +package org.openedx.core.domain.model + +import org.openedx.core.data.model.DateType +import org.openedx.core.presentation.course.CourseDatesBadge +import java.util.Date + +data class CourseDateBlock( + val title: String = "", + val description: String = "", + val link: String = "", + val blockId: String = "", + val learnerHasAccess: Boolean = false, + val complete: Boolean = false, + val date: Date?, + val dateType: DateType = DateType.NONE, + var dateBlockBadge: CourseDatesBadge = CourseDatesBadge.BLANK, +) { + companion object { + fun getTodayDateBlock() = + CourseDateBlock( + date = Date(), + dateType = DateType.TODAY_DATE, + dateBlockBadge = CourseDatesBadge.TODAY + ) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt index 9e2b84ddb..07241824b 100644 --- a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt +++ b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt @@ -11,11 +11,11 @@ data class VideoSettings( } } -enum class VideoQuality(val titleResId: Int) { - AUTO(R.string.auto_recommended_text), - OPTION_360P(R.string.video_quality_p360), - OPTION_540P(R.string.video_quality_p540), - OPTION_720P(R.string.video_quality_p720); +enum class VideoQuality(val titleResId: Int, val width: Int, val height: Int) { + AUTO(R.string.auto_recommended_text, 0, 0), + OPTION_360P(R.string.video_quality_p360, 640, 360), + OPTION_540P(R.string.video_quality_p540, 960, 540), + OPTION_720P(R.string.video_quality_p720, 1280, 720); val value: String = this.name.replace("OPTION_", "").lowercase() } diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt new file mode 100644 index 000000000..5c5da0b42 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/course/CourseDatesBadge.kt @@ -0,0 +1,26 @@ +package org.openedx.core.presentation.course + +import org.openedx.core.R + +/** + * This enum defines the Date type of Course Dates + */ +enum class CourseDatesBadge { + TODAY, BLANK, VERIFIED_ONLY, COMPLETED, PAST_DUE, DUE_NEXT, NOT_YET_RELEASED, + COURSE_EXPIRED_DATE; + + /** + * @return The string resource's ID if it's a valid enum inside [CourseDatesBadge], otherwise -1. + */ + fun getStringResIdForDateType(): Int { + return when (this) { + TODAY -> R.string.core_date_type_today + VERIFIED_ONLY -> R.string.core_date_type_verified_only + COMPLETED -> R.string.core_date_type_completed + PAST_DUE -> R.string.core_date_type_past_due + DUE_NEXT -> R.string.core_date_type_due_next + NOT_YET_RELEASED -> R.string.core_date_type_not_yet_released + else -> -1 + } + } +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/Color.kt b/core/src/main/java/org/openedx/core/ui/theme/Color.kt index d7e2d978c..99f3c320f 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Color.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Color.kt @@ -35,7 +35,15 @@ data class AppColors( val inactiveButtonBackground: Color, val inactiveButtonText: Color, - val accessGreen:Color + val accessGreen:Color, + + val datesBadgeDefault: Color, + val datesBadgeTextDefault: Color, + val datesBadgePastDue: Color, + val datesBadgeToday: Color, + val datesBadgeTextToday: Color, // also used for locked date block + val datesBadgeDue: Color, // Also used for not release date block text and stoke + val datesBadgeTextDue: Color // Also used for locked date block color ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 9b50bda1a..0260c2adc 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -56,7 +56,15 @@ private val DarkColorPalette = AppColors( inactiveButtonBackground = Color(0xFFCCD4E0), inactiveButtonText = Color(0xFF3D4964), - accessGreen = Color(0xFF23BCA0) + accessGreen = Color(0xFF23BCA0), + + datesBadgeDefault = Color(0xFFF2F0EF), + datesBadgeTextDefault = Color(0xFF454545), + datesBadgePastDue = Color(0xFFD7D3D1), + datesBadgeToday = Color(0xFFD6B600), + datesBadgeTextToday = Color(0xFF000000), + datesBadgeDue = Color(0xFF707070), + datesBadgeTextDue = Color(0xFFFFFFFF) ) private val LightColorPalette = AppColors( @@ -104,7 +112,15 @@ private val LightColorPalette = AppColors( inactiveButtonBackground = Color(0xFFCCD4E0), inactiveButtonText = Color(0xFF3D4964), - accessGreen = Color(0xFF23BCA0) + accessGreen = Color(0xFF23BCA0), + + datesBadgeDefault = Color(0xFFF2F0EF), + datesBadgeTextDefault = Color(0xFF454545), + datesBadgePastDue = Color(0xFFD7D3D1), + datesBadgeToday = Color(0xFFD6B600), + datesBadgeTextToday = Color(0xFF000000), + datesBadgeDue = Color(0xFF707070), + datesBadgeTextDue = Color(0xFFFFFFFF) ) val MaterialTheme.appColors: AppColors diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index dc31f7102..b70d9c7b7 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -2,26 +2,30 @@ package org.openedx.core.utils import android.content.Context import android.text.format.DateUtils +import com.google.gson.internal.bind.util.ISO8601Utils import org.openedx.core.R import org.openedx.core.domain.model.StartType import org.openedx.core.system.ResourceManager import java.text.ParseException +import java.text.ParsePosition import java.text.SimpleDateFormat -import java.util.* +import java.util.Date +import java.util.Locale object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" private const val FORMAT_APPLICATION = "dd.MM.yyyy HH:mm" - private const val FORMAT_DATE = "dd MMM, yyyy" + const val FORMAT_DATE = "dd MMM, yyyy" + const val FORMAT_DATE_TAB = "EEE, MMM dd, yyyy" private const val SEVEN_DAYS_IN_MILLIS = 604800000L fun iso8601ToDate(text: String): Date? { return try { - val sdf = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) - sdf.parse(text) + val parsePosition = ParsePosition(0) + return ISO8601Utils.parse(text, parsePosition) } catch (e: ParseException) { null } @@ -36,10 +40,21 @@ object TimeUtils { } } - fun iso8601ToDateWithTime(context: Context,text: String): String { + /** + * This method used to convert the date to ISO 8601 compliant format date string + * @param date [Date]needs to be converted + * @return The current date and time in a ISO 8601 compliant format. + */ + fun dateToIso8601(date: Date?): String { + return ISO8601Utils.format(date, true) + } + + fun iso8601ToDateWithTime(context: Context, text: String): String { return try { val courseDateFormat = SimpleDateFormat(FORMAT_ISO_8601, Locale.getDefault()) - val applicationDateFormat = SimpleDateFormat(context.getString(R.string.core_full_date_with_time), Locale.getDefault()) + val applicationDateFormat = SimpleDateFormat( + context.getString(R.string.core_full_date_with_time), Locale.getDefault() + ) applicationDateFormat.format(courseDateFormat.parse(text)!!) } catch (e: Exception) { e.printStackTrace() @@ -48,16 +63,30 @@ object TimeUtils { } fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { + return formatDate( + format = resourceManager.getString(R.string.core_date_format_MMMM_dd), date = date + ) + } + + fun formatDate(format: String, date: String): String { + return formatDate(format, iso8601ToDate(date)) + } + + fun formatDate(format: String, date: Date?): String { if (date == null) { return "" } - val sdf = SimpleDateFormat( - resourceManager.getString(R.string.core_date_format_MMMM_dd), - Locale.getDefault() - ) + val sdf = SimpleDateFormat(format, Locale.getDefault()) return sdf.format(date) } + fun stringToDate(dateFormat: String, date: String): Date? { + if (dateFormat.isEmpty() || date.isEmpty()) { + return null + } + return SimpleDateFormat(dateFormat, Locale.getDefault()).parse(date) + } + /** * Checks if the given date is past today. * @@ -70,6 +99,25 @@ object TimeUtils { return otherDate != null && today.after(otherDate) } + /** + * This function compare the provide date with current date + * @param today Today's date. + * @param otherDate Other date to cross-match with today's date. + * @return true if the other date is due today, + */ + fun isDueDate(today: Date, otherDate: Date?): Boolean { + return otherDate != null && today.before(otherDate) + } + + /** + * This function compare the provide date are same + * @return true if the provided date are same else false + */ + fun areDatesSame(date: Date?, otherDate: Date?): Boolean { + return date != null && otherDate != null && + formatDate(FORMAT_DATE, date) == formatDate(FORMAT_DATE, otherDate) + } + fun getCourseFormattedDate( context: Context, today: Date, @@ -79,7 +127,7 @@ object TimeUtils { startType: String, startDisplay: String ): String { - var formattedDate = "" + val formattedDate: String val resourceManager = ResourceManager(context) if (isDatePassed(today, start)) { @@ -98,8 +146,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expired, timeSpan) } @@ -111,8 +161,10 @@ object TimeUtils { ) } else { val timeSpan = DateUtils.getRelativeTimeSpanString( - expiry.time, today.time, - DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE + expiry.time, + today.time, + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE ).toString() resourceManager.getString(R.string.core_label_expires, timeSpan) } @@ -131,13 +183,11 @@ object TimeUtils { } } else if (isDatePassed(today, end)) { resourceManager.getString( - R.string.core_label_ended, - dateToCourseDate(resourceManager, end) + R.string.core_label_ended, dateToCourseDate(resourceManager, end) ) } else { resourceManager.getString( - R.string.core_label_ending, - dateToCourseDate(resourceManager, end) + R.string.core_label_ending, dateToCourseDate(resourceManager, end) ) } } @@ -155,5 +205,4 @@ object TimeUtils { } return formattedDate } - -} \ No newline at end of file +} diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index 0ab8627f3..31e98318a 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -21,7 +21,7 @@ Пароль незабаром Авто (Рекомендовано) - 360p (Найменший розмір) + 360p (Менше використання трафіку) 540p 720p (Найкраща якість) Офлайн diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 60f03618d..ba65c8b8a 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - + @string/platform_name Results @@ -27,7 +27,7 @@ Reload Downloading in progress Auto (Recommended) - 360p (Smallest file size) + 360p (Lower data usage) 540p 720p (Best quality) User account is not activated. Please activate your account first. @@ -66,6 +66,21 @@ Thank you for sharing your feedback with us. Would you like to share your review of this app with other users on the app store? We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing! + + + Today + + Verified Only + + Completed + + Past Due + + Due Next + + Not Yet Released + + %1$s profile image - \ No newline at end of file + diff --git a/course/build.gradle b/course/build.gradle index d7b0f2cab..f746f4d09 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -62,8 +62,10 @@ dependencies { implementation project(path: ':discussion') implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:$youtubeplayer_version" implementation "androidx.media3:media3-exoplayer:$media3_version" + implementation "androidx.media3:media3-exoplayer-hls:$media3_version" implementation "androidx.media3:media3-ui:$media3_version" implementation "androidx.media3:media3-cast:$media3_version" + implementation "me.saket.extendedspans:extendedspans:$extented_spans_version" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index f31d31bdf..637a5ad7b 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -1,5 +1,7 @@ package org.openedx.course.data.repository +import kotlinx.coroutines.flow.map +import okhttp3.ResponseBody import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.EnrollBody @@ -9,8 +11,6 @@ import org.openedx.core.domain.model.* import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao import org.openedx.course.data.storage.CourseDao -import kotlinx.coroutines.flow.map -import okhttp3.ResponseBody class CourseRepository( private val api: CourseApi, @@ -99,9 +99,10 @@ class CourseRepository( return api.markBlocksCompletion(blocksCompletionBody) } + suspend fun getCourseDates(courseId: String) = api.getCourseDates(courseId).mapToDomain() + suspend fun getHandouts(courseId: String) = api.getHandouts(courseId).mapToDomain() suspend fun getAnnouncements(courseId: String) = api.getAnnouncements(courseId).map { it.mapToDomain() } - -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index ac8d163ec..8bcd2c40a 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -71,6 +71,8 @@ class CourseInteractor( suspend fun getCourseStatus(courseId: String) = repository.getCourseStatus(courseId) + suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) + suspend fun getHandouts(courseId: String) = repository.getHandouts(courseId) suspend fun getAnnouncements(courseId: String) = repository.getAnnouncements(courseId) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 5cea3d63f..cdae67678 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -15,5 +15,6 @@ interface CourseAnalytics { fun courseTabClickedEvent(courseId: String, courseName: String) fun videoTabClickedEvent(courseId: String, courseName: String) fun discussionTabClickedEvent(courseId: String, courseName: String) + fun datesTabClickedEvent(courseId: String, courseName: String) fun handoutsTabClickedEvent(courseId: String, courseName: String) -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 17363a01b..acefd4020 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -7,17 +7,18 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.snackbar.Snackbar +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.dates.CourseDatesFragment import org.openedx.course.presentation.handouts.HandoutsFragment import org.openedx.course.presentation.outline.CourseOutlineFragment import org.openedx.course.presentation.videos.CourseVideosFragment import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -64,9 +65,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { binding.viewPager.setCurrentItem(2, false) } + R.id.dates -> { + viewModel.datesTabClickedEvent() + binding.viewPager.setCurrentItem(3, false) + } + R.id.resources -> { viewModel.handoutsTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + binding.viewPager.setCurrentItem(4, false) } } true @@ -110,13 +116,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun initViewPager() { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.offscreenPageLimit = 4 adapter = CourseContainerAdapter(this).apply { addFragment(CourseOutlineFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(CourseVideosFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(DiscussionTopicsFragment.newInstance(viewModel.courseId, courseTitle)) + addFragment(CourseDatesFragment.newInstance(viewModel.courseId, courseTitle)) addFragment(HandoutsFragment.newInstance(viewModel.courseId)) } + binding.viewPager.offscreenPageLimit = adapter?.itemCount ?: 1 binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 1fb6e9aa5..33b79e9c1 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -100,6 +100,10 @@ class CourseContainerViewModel( analytics.discussionTabClickedEvent(courseId, courseName) } + fun datesTabClickedEvent() { + analytics.datesTabClickedEvent(courseId, courseName) + } + fun handoutsTabClickedEvent() { analytics.handoutsTabClickedEvent(courseId, courseName) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt new file mode 100644 index 000000000..89607a653 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -0,0 +1,693 @@ +package org.openedx.course.presentation.dates + +import android.content.res.Configuration +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +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.Surface +import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import me.saket.extendedspans.ExtendedSpans +import me.saket.extendedspans.RoundedCornerSpanPainter +import me.saket.extendedspans.drawBehind +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.presentation.course.CourseDatesBadge +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +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.core.ui.windowSizeValue +import org.openedx.core.utils.TimeUtils +import org.openedx.course.R +import java.util.Date + +class CourseDatesFragment : Fragment() { + + private val viewModel by viewModel { + parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + with(requireArguments()) { + viewModel.courseTitle = getString(ARG_TITLE, "") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.observeAsState() + val uiMessage by viewModel.uiMessage.observeAsState() + val refreshing by viewModel.updating.observeAsState(false) + + CourseDatesScreen(windowSize = windowSize, + uiState = uiState, + courseTitle = viewModel.courseTitle, + uiMessage = uiMessage, + refreshing = refreshing, + hasInternetConnection = viewModel.hasInternetConnection, + onReloadClick = { + viewModel.getCourseDates() + }, + onSwipeRefresh = { + viewModel.getCourseDates() + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "courseId" + private const val ARG_TITLE = "title" + fun newInstance(courseId: String, title: String): CourseDatesFragment { + val fragment = CourseDatesFragment() + fragment.arguments = bundleOf(ARG_COURSE_ID to courseId, ARG_TITLE to title) + return fragment + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun CourseDatesScreen( + windowSize: WindowSize, + uiState: DatesUIState?, + courseTitle: String, + uiMessage: UIMessage?, + refreshing: Boolean, + hasInternetConnection: Boolean, + onReloadClick: () -> Unit, + onSwipeRefresh: () -> Unit, + onBackClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = + rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + val modifierScreenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .statusBarsInset() + .displayCutoutForLandscape(), contentAlignment = Alignment.TopCenter + ) { + Column( + modifierScreenWidth + ) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), contentAlignment = Alignment.CenterStart + ) { + BackBtn { + onBackClick() + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 56.dp), + text = courseTitle, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(6.dp)) + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + Box( + Modifier + .fillMaxWidth() + .pullRefresh(pullRefreshState) + ) { + uiState?.let { + when (uiState) { + is DatesUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is DatesUIState.Dates -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(10.dp), + contentPadding = listBottomPadding + ) { + itemsIndexed(uiState.courseDates.keys.toList()) { dateIndex, _ -> + CourseDateBlockSection( + courseDates = uiState.courseDates, + dateIndex = dateIndex + ) + } + } + } + + DatesUIState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.course_dates_unavailable_message), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + } + } + } + } + PullRefreshIndicator( + refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) + ) + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + }) + } + } + } + } + } + } +} + +@Composable +private fun CourseDateBlockSection( + courseDates: LinkedHashMap>, dateIndex: Int +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(intrinsicSize = IntrinsicSize.Min) // this make height of all cards to the tallest card. + .background(MaterialTheme.appColors.background) + ) { + val dateBlockKey = courseDates.keys.toList()[dateIndex] + val dateBlocks = courseDates[dateBlockKey] + dateBlocks?.let { + val dateBlockItem = courseDates[dateBlockKey]?.get(0) + dateBlockItem?.let { + DateBullet( + isFirstIndex = dateIndex == 0, + isLastIndex = dateIndex == courseDates.size - 1, + dateBlock = dateBlockItem + ) + DateBlock(dateBlocks) + } + } + } +} + +@Composable +private fun DateBullet( + isFirstIndex: Boolean = false, + isLastIndex: Boolean = false, + dateBlock: CourseDateBlock +) { + Column( + modifier = Modifier + .width(40.dp) + .padding(start = 6.dp) + ) { + if (!isFirstIndex) { + Box( + modifier = Modifier + .width(1.dp) + .height(6.dp) + .background(color = MaterialTheme.appColors.datesBadgeTextToday) + .align(Alignment.CenterHorizontally) + ) + } else { + Spacer(modifier = Modifier.height(6.dp)) + } + var circleColor: Color = MaterialTheme.appColors.datesBadgeDefault + var circleSize: Dp = 10.dp + when (dateBlock.dateBlockBadge) { + + CourseDatesBadge.TODAY -> { + circleColor = MaterialTheme.appColors.datesBadgeToday + circleSize = 14.dp + } + + CourseDatesBadge.PAST_DUE -> { + circleColor = MaterialTheme.appColors.datesBadgePastDue + } + + CourseDatesBadge.BLANK, + CourseDatesBadge.COMPLETED, + CourseDatesBadge.DUE_NEXT, + CourseDatesBadge.NOT_YET_RELEASED, + CourseDatesBadge.COURSE_EXPIRED_DATE, + CourseDatesBadge.VERIFIED_ONLY -> { + var isDatePassed = false + dateBlock.date?.let { + isDatePassed = TimeUtils.isDatePassed(Date(), it) + } + circleColor = + if (isDatePassed && (dateBlock.dateBlockBadge == CourseDatesBadge.VERIFIED_ONLY).not()) { + MaterialTheme.appColors.datesBadgePastDue + } else { + MaterialTheme.appColors.datesBadgeTextToday + } + } + + else -> {} + } + Box( + modifier = Modifier + .size(circleSize) + .border(1.dp, MaterialTheme.appColors.datesBadgeTextToday, CircleShape) + .clip(CircleShape) + .background(circleColor) + .align(Alignment.CenterHorizontally) + ) + if (!isLastIndex) { + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(color = MaterialTheme.appColors.datesBadgeTextToday) + .align(Alignment.CenterHorizontally) + ) + } + } +} + +@Composable +private fun DateBlock(dateBlocks: ArrayList) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(start = 16.dp, bottom = 30.dp) + ) { + val firstDateBlock = dateBlocks[0] + PlaceDateBadge( + title = TimeUtils.formatDate(TimeUtils.FORMAT_DATE_TAB, firstDateBlock.date), + titleSize = 18.sp, + blockBadge = firstDateBlock.dateBlockBadge + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + val parentBadgeAdded = hasSameDateTypes(dateBlocks) + dateBlocks.forEach { courseDateItem -> + CourseDateItem(courseDateItem, parentBadgeAdded) + } + } + } +} + +/** + * Method to create the Date badge as per given DateType + */ +@Composable +private fun PlaceDateBadge(title: String, titleSize: TextUnit, blockBadge: CourseDatesBadge) { + var badgeBackground: Color = Color.Transparent + var textAppearance: Color = Color.Transparent + var badgeStrokeColor: Color = Color.Transparent + var badgeIcon: Painter? = null + when (blockBadge) { + CourseDatesBadge.TODAY -> { + badgeBackground = MaterialTheme.appColors.datesBadgeToday + textAppearance = MaterialTheme.appColors.datesBadgeTextToday + } + + CourseDatesBadge.VERIFIED_ONLY -> { + badgeBackground = MaterialTheme.appColors.datesBadgeTextToday + textAppearance = MaterialTheme.appColors.datesBadgeTextDue + badgeIcon = painterResource(R.drawable.ic_lock) + } + + CourseDatesBadge.COMPLETED -> { + badgeBackground = MaterialTheme.appColors.datesBadgeDefault + textAppearance = MaterialTheme.appColors.datesBadgeTextDefault + } + + CourseDatesBadge.PAST_DUE -> { + badgeBackground = MaterialTheme.appColors.datesBadgePastDue + textAppearance = MaterialTheme.appColors.datesBadgeTextDefault + } + + CourseDatesBadge.DUE_NEXT -> { + badgeBackground = MaterialTheme.appColors.datesBadgeDue + textAppearance = MaterialTheme.appColors.datesBadgeTextDue + } + + CourseDatesBadge.NOT_YET_RELEASED -> { + badgeBackground = Color.Transparent + textAppearance = MaterialTheme.appColors.datesBadgeDue + badgeStrokeColor = MaterialTheme.appColors.datesBadgeDue + } + + else -> {} + } + val extendedSpans = remember { + ExtendedSpans( + RoundedCornerSpanPainter( + cornerRadius = 6.sp, + padding = RoundedCornerSpanPainter.TextPaddingValues( + horizontal = 8.sp, + vertical = 6.sp + ), topMargin = 5.sp, + bottomMargin = 4.sp, + stroke = RoundedCornerSpanPainter.Stroke( + color = badgeStrokeColor + ) + ) + ) + } + val titleWithBadge = buildAnnotatedString { + append(title) + append(" ") + withStyle( + SpanStyle( + color = textAppearance, + background = badgeBackground, + fontWeight = FontWeight.SemiBold, + fontStyle = FontStyle.Italic, + fontSize = 16.sp + ) + ) { + if (badgeIcon != null) { + appendInlineContent("icon_id") + append(" ") + } + val badgeTitle = blockBadge.getStringResIdForDateType() + if (badgeTitle != -1) { + append(stringResource(id = badgeTitle)) + } + } + } + val inlineContent = HashMap() + badgeIcon?.let { + inlineContent["icon_id"] = InlineTextContent( + Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Icon(badgeIcon, "", tint = MaterialTheme.appColors.datesBadgeTextDue) + } + } + Text( + modifier = Modifier.drawBehind(extendedSpans), + text = remember(titleWithBadge) { + extendedSpans.extend(titleWithBadge) + }, + onTextLayout = { result -> + extendedSpans.onTextLayout(result) + }, + inlineContent = inlineContent, + fontSize = titleSize, + fontWeight = FontWeight.SemiBold, + lineHeight = 22.sp + ) +} + +@Composable +private fun CourseDateItem(courseDateItem: CourseDateBlock, parentBadgeAdded: Boolean) { + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + if (!parentBadgeAdded) { + // Set update badge with sub date items + PlaceDateBadge(courseDateItem.title, 16.sp, courseDateItem.dateBlockBadge) + } else { + Text( + text = courseDateItem.title, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 20.sp + ) + } + if (!TextUtils.isEmpty(courseDateItem.description)) { + Text( + text = courseDateItem.description, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + lineHeight = 18.sp + ) + } + } +} + +/** + * Method to check that all Date Items have same badge status or not + * + * @return true if all the date items have update badge status else false + * */ +private fun hasSameDateTypes(dateBlockItems: ArrayList?): Boolean { + if (!dateBlockItems.isNullOrEmpty() && dateBlockItems.size > 1) { + val dateType = dateBlockItems.first().dateBlockBadge + for (i in 1 until dateBlockItems.size) { + if (dateBlockItems[i].dateBlockBadge != dateType && dateBlockItems[i].dateBlockBadge != CourseDatesBadge.BLANK) { + return false + } + } + } + return true +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseDatesScreenPreview() { + OpenEdXTheme { + CourseDatesScreen(windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DatesUIState.Dates(mockedCourseDates), + courseTitle = "Course Dates", + uiMessage = null, + hasInternetConnection = true, + refreshing = false, + onSwipeRefresh = {}, + onReloadClick = {}, + onBackClick = {}) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseDatesScreenTabletPreview() { + OpenEdXTheme { + CourseDatesScreen(windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = DatesUIState.Dates(mockedCourseDates), + courseTitle = "Course Dates", + uiMessage = null, + hasInternetConnection = true, + refreshing = false, + onSwipeRefresh = {}, + onReloadClick = {}, + onBackClick = {}) + } +} + +private var mockedCourseDates = linkedMapOf( + Pair( + "2023-10-20T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Course Start", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.PAST_DUE + ) + ) + ), Pair( + "2023-10-21T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Today", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-21T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.TODAY + ) + ) + ), + Pair( + "2023-10-22T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Due Next", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-22T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.DUE_NEXT + ) + ) + ), Pair( + "2023-10-23T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Assignment Due", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-23T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.VERIFIED_ONLY + ) + ) + ), Pair( + "2023-10-24T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Not Yet Released", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-24T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.NOT_YET_RELEASED + ) + ) + ), Pair( + "2023-10-25T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Blank", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-25T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.BLANK + ) + ) + ), Pair( + "2023-10-26T15:08:07Z", arrayListOf( + CourseDateBlock( + title = "Course End", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-26T15:08:07Z"), + dateBlockBadge = CourseDatesBadge.COMPLETED + ) + ) + ) +) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt new file mode 100644 index 000000000..9f29c223b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -0,0 +1,71 @@ +package org.openedx.course.presentation.dates + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.SingleEventLiveData +import org.openedx.core.UIMessage +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor + +class CourseDatesViewModel( + val courseId: String, + private val interactor: CourseInteractor, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, +) : BaseViewModel() { + + private val _uiState = MutableLiveData(DatesUIState.Loading) + val uiState: LiveData + get() = _uiState + + private val _uiMessage = SingleEventLiveData() + val uiMessage: LiveData + get() = _uiMessage + + private val _updating = MutableLiveData() + val updating: LiveData + get() = _updating + + var courseTitle = "" + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + getCourseDates() + } + + fun getCourseDates() { + _uiState.value = DatesUIState.Loading + loadingCourseDatesInternal() + } + + private fun loadingCourseDatesInternal() { + viewModelScope.launch { + try { + _updating.value = true + val datesResponse = interactor.getCourseDates(courseId = courseId) + if (datesResponse.isEmpty()) { + _uiState.value = DatesUIState.Empty + } else { + _uiState.value = DatesUIState.Dates(datesResponse) + } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + } else { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + } + } + _updating.value = false + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt new file mode 100644 index 000000000..975748ae4 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -0,0 +1,11 @@ +package org.openedx.course.presentation.dates + +import org.openedx.core.domain.model.CourseDateBlock + +sealed class DatesUIState { + data class Dates(val courseDates: LinkedHashMap>) : + DatesUIState() + + object Empty : DatesUIState() + object Loading : DatesUIState() +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 8579d9d94..73b15366f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -6,8 +6,19 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.media3.cast.CastPlayer import androidx.media3.common.Player +import androidx.media3.common.util.Clock +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.extractor.DefaultExtractorsFactory import com.google.android.gms.cast.framework.CastContext +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.module.TranscriptManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -21,6 +32,7 @@ class EncodedVideoUnitViewModel( notifier: CourseNotifier, networkConnection: NetworkConnection, transcriptManager: TranscriptManager, + val preferencesManager: CorePreferences, private val context: Context, ) : VideoUnitViewModel( courseId, @@ -62,13 +74,10 @@ class EncodedVideoUnitViewModel( @androidx.media3.common.util.UnstableApi override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - if (exoPlayer != null) { return } - - exoPlayer = ExoPlayer.Builder(context) - .build() + initPlayer() val executor = Executors.newSingleThreadExecutor() castContext = CastContext.getSharedInstance(context, executor).result @@ -80,6 +89,7 @@ class EncodedVideoUnitViewModel( override fun onResume(owner: LifecycleOwner) { super.onResume(owner) exoPlayer?.addListener(exoPlayerListener) + getActivePlayer()?.playWhenReady = isPlaying } override fun onPause(owner: LifecycleOwner) { @@ -103,4 +113,33 @@ class EncodedVideoUnitViewModel( exoPlayer = null castPlayer = null } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + fun initPlayer() { + val videoQuality = getVideoQuality() + val params = DefaultTrackSelector.Parameters.Builder(context) + .apply { + if (videoQuality != VideoQuality.AUTO) { + setMaxVideoSize(videoQuality.width, videoQuality.height) + setViewportSize(videoQuality.width, videoQuality.height, false) + } + } + .build() + + val factory = AdaptiveTrackSelection.Factory() + val selector = DefaultTrackSelector(context, factory) + selector.parameters = params + + exoPlayer = ExoPlayer.Builder( + context, + DefaultRenderersFactory(context), + DefaultMediaSourceFactory(context, DefaultExtractorsFactory()), + selector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(context), + DefaultAnalyticsCollector(Clock.DEFAULT) + ).build() + } + + private fun getVideoQuality() = preferencesManager.videoSettings.videoQuality } \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt index 8a2e7e496..78133286e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoFullScreenFragment.kt @@ -10,10 +10,22 @@ import androidx.fragment.app.Fragment import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.util.Clock +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import org.koin.android.ext.android.inject +import androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.AdaptiveTrackSelection +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter +import androidx.media3.extractor.DefaultExtractorsFactory import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.extension.requestApplyInsetsWhenAttached import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.viewBinding @@ -35,6 +47,7 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { super.onPlayWhenReadyChanged(playWhenReady, reason) viewModel.isPlaying = playWhenReady } + override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) if (playbackState == Player.STATE_ENDED) { @@ -80,14 +93,35 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { private fun initPlayer() { with(binding) { if (exoPlayer == null) { - exoPlayer = ExoPlayer.Builder(requireContext()) + val videoQuality = viewModel.getVideoQuality() + val params = DefaultTrackSelector.Parameters.Builder(requireContext()) + .apply { + if (videoQuality != VideoQuality.AUTO) { + setMaxVideoSize(videoQuality.width, videoQuality.height) + setViewportSize(videoQuality.width, videoQuality.height, false) + } + } .build() + + val factory = AdaptiveTrackSelection.Factory() + val selector = DefaultTrackSelector(requireContext(), factory) + selector.parameters = params + + exoPlayer = ExoPlayer.Builder( + requireContext(), + DefaultRenderersFactory(requireContext()), + DefaultMediaSourceFactory(requireContext(), DefaultExtractorsFactory()), + selector, + DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(requireContext()), + DefaultAnalyticsCollector(Clock.DEFAULT) + ).build() } playerView.player = exoPlayer playerView.setShowNextButton(false) playerView.setShowPreviousButton(false) val mediaItem = MediaItem.fromUri(viewModel.videoUrl) - exoPlayer?.setMediaItem(mediaItem, viewModel.currentVideoTime) + setPlayerMedia(mediaItem) exoPlayer?.prepare() exoPlayer?.playWhenReady = viewModel.isPlaying ?: false @@ -106,6 +140,20 @@ class VideoFullScreenFragment : Fragment(R.layout.fragment_video_full_screen) { } } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun setPlayerMedia(mediaItem: MediaItem) { + if (viewModel.videoUrl.endsWith(".m3u8")) { + val factory = DefaultDataSource.Factory(requireContext()) + val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + exoPlayer?.setMediaSource(mediaSource, viewModel.currentVideoTime) + } else { + exoPlayer?.setMediaItem( + mediaItem, + viewModel.currentVideoTime + ) + } + } + private fun releasePlayer() { exoPlayer?.stop() exoPlayer?.release() diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt index 2b9327c46..a2a540141 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitFragment.kt @@ -21,6 +21,8 @@ import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.window.layout.WindowMetricsCalculator import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -195,13 +197,9 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { .build() if (!viewModel.isPlayerSetUp) { - viewModel.getActivePlayer()?.setMediaItem( - mediaItem, - viewModel.getCurrentVideoTime() - ) + setPlayerMedia(mediaItem) viewModel.getActivePlayer()?.prepare() viewModel.getActivePlayer()?.playWhenReady = viewModel.isPlaying - viewModel.isPlayerSetUp = true } @@ -247,14 +245,11 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } @UnstableApi - override fun onDestroyView() { - super.onDestroyView() + override fun onDestroy() { if (!requireActivity().isChangingConfigurations) { viewModel.releasePlayers() + viewModel.isPlayerSetUp = false } - } - - override fun onDestroy() { handler.removeCallbacks(videoTimeRunnable) super.onDestroy() } @@ -271,6 +266,20 @@ class VideoUnitFragment : Fragment(R.layout.fragment_video_unit) { } } + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun setPlayerMedia(mediaItem: MediaItem) { + if (viewModel.videoUrl.endsWith(".m3u8")) { + val factory = DefaultDataSource.Factory(requireContext()) + val mediaSource: HlsMediaSource = HlsMediaSource.Factory(factory).createMediaSource(mediaItem) + viewModel.exoPlayer?.setMediaSource(mediaSource, viewModel.getCurrentVideoTime()) + } else { + viewModel.getActivePlayer()?.setMediaItem( + mediaItem, + viewModel.getCurrentVideoTime() + ) + } + } + companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_VIDEO_URL = "videoUrl" diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index 79f4e8acc..bfcda93c8 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -7,11 +7,13 @@ import org.openedx.course.data.repository.CourseRepository import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseVideoPositionChanged import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CorePreferences class VideoViewModel( private val courseId: String, private val courseRepository: CourseRepository, - private val notifier: CourseNotifier + private val notifier: CourseNotifier, + private val preferencesManager: CorePreferences ) : BaseViewModel() { var videoUrl = "" @@ -45,4 +47,5 @@ class VideoViewModel( } } + fun getVideoQuality() = preferencesManager.videoSettings.videoQuality } \ No newline at end of file diff --git a/course/src/main/res/drawable/ic_calendar_month.xml b/course/src/main/res/drawable/ic_calendar_month.xml new file mode 100644 index 000000000..434cf9907 --- /dev/null +++ b/course/src/main/res/drawable/ic_calendar_month.xml @@ -0,0 +1,5 @@ + + + diff --git a/course/src/main/res/drawable/ic_lock.xml b/course/src/main/res/drawable/ic_lock.xml new file mode 100644 index 000000000..68cb9c1f5 --- /dev/null +++ b/course/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/menu/bottom_course_container_menu.xml b/course/src/main/res/menu/bottom_course_container_menu.xml index 8ee927041..e65c6ea5e 100644 --- a/course/src/main/res/menu/bottom_course_container_menu.xml +++ b/course/src/main/res/menu/bottom_course_container_menu.xml @@ -19,6 +19,12 @@ android:enabled="true" android:icon="@drawable/ic_course_navigation_discussions"/> + + - + Enroll now View course Course details @@ -47,4 +47,10 @@ Continue with: Continue To proceed with \"%s\" press \"Next section\". - \ No newline at end of file + Dates + + + + Course dates are not currently available. + + diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt new file mode 100644 index 000000000..d65f1fa4b --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -0,0 +1,135 @@ +package org.openedx.course.presentation.dates + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.data.model.DateType +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.course.domain.interactor.CourseInteractor +import java.net.UnknownHostException +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class CourseDatesViewModelTest { + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val resourceManager = mockk() + private val interactor = mockk() + private val networkConnection = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + private val dateBlock = CourseDateBlock( + complete = false, + date = Date(), + dateType = DateType.TODAY_DATE, + description = "Mocked Course Date Description" + ) + private val mockDateBlocks = linkedMapOf( + Pair( + "2023-10-20T15:08:07Z", + arrayListOf(dateBlock, dateBlock) + ), + Pair( + "2023-10-30T15:08:07Z", + arrayListOf(dateBlock, dateBlock) + ) + ) + + @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 + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseDates no internet connection exception`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + + Assert.assertEquals(noInternet, message?.message) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates unknown exception`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } throws Exception() + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + + Assert.assertEquals(somethingWrong, message?.message) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Loading) + } + + @Test + fun `getCourseDates success with internet`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns mockDateBlocks + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Dates) + } + + @Test + fun `getCourseDates success with EmptyList`() = runTest { + val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseDates(any()) } returns linkedMapOf() + + advanceUntilIdle() + + coVerify(exactly = 1) { interactor.getCourseDates(any()) } + + assert(viewModel.uiMessage.value == null) + assert(viewModel.updating.value == false) + assert(viewModel.uiState.value is DatesUIState.Empty) + } +} diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index 144698ec5..ce1799432 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -15,6 +15,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.data.storage.CorePreferences @OptIn(ExperimentalCoroutinesApi::class) class VideoViewModelTest { @@ -26,6 +27,7 @@ class VideoViewModelTest { private val courseRepository = mockk() private val notifier = mockk() + private val preferenceManager = mockk() @Before fun setUp() { @@ -39,7 +41,7 @@ class VideoViewModelTest { @Test fun `sendTime test`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() @@ -49,7 +51,7 @@ class VideoViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), @@ -69,7 +71,7 @@ class VideoViewModelTest { @Test fun `markBlockCompleted success`() = runTest { - val viewModel = VideoViewModel("", courseRepository, notifier) + val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager) coEvery { courseRepository.markBlocksCompletion( any(), diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt index 7acb2a9c4..46c645a76 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt @@ -121,7 +121,7 @@ private fun VideoQualityScreen( Text( modifier = Modifier .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, textAlign = TextAlign.Center, style = MaterialTheme.appTypography.titleMedium diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt index ca86d48ec..747df792a 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt @@ -198,7 +198,7 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( - text = stringResource(id = profileR.string.profile_video_download_quality), + text = stringResource(id = profileR.string.profile_video_streaming_quality), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 123d4aee6..1cbb0a60a 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -30,7 +30,7 @@ Налаштування відео Завантаження тільки через Wi-Fi Завантажуйте вміст лише тоді, коли ввімкнено wi-fi - Якість завантаження відео + Якість транслювання відео Видалити акаунт Ви впевнені, що бажаєте видалити свій акаунт? diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 9ad0c47c9..03f82fa8a 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -38,7 +38,7 @@ Video settings Wi-fi only download Only download content when wi-fi is turned on - Video download quality + Video streaming quality Leave profile? Leave Keep editing