From f723215df34a6ffcf912d74a97b46a6ebac4191c Mon Sep 17 00:00:00 2001 From: omerhabib26 Date: Thu, 18 Jul 2024 15:30:26 +0500 Subject: [PATCH] feat: added ability to handle course errors - Integrate and parse CourseEnrollmentDetails API - Handle CourseAccess Errors on course Dashboard - Update UI based on CourseAccess Errors. fix:LEARNER-10019 --- .../main/java/org/openedx/app/AppRouter.kt | 11 +- .../openedx/app/deeplink/DeepLinkRouter.kt | 5 - .../main/java/org/openedx/app/di/AppModule.kt | 5 + .../java/org/openedx/app/di/ScreenModule.kt | 3 +- .../edx/org/openedx/core/ui/theme/Colors.kt | 2 +- .../org/openedx/core/data/api/CourseApi.kt | 6 + .../core/data/model/CourseAccessDetails.kt | 26 +- .../data/model/CourseEnrollmentDetails.kt | 60 +++ .../core/data/model/CourseInfoOverview.kt | 60 +++ .../org/openedx/core/data/model/CourseMode.kt | 25 +- .../core/data/model/CourseStructureModel.kt | 10 +- .../openedx/core/data/model/EnrolledCourse.kt | 8 +- .../core/data/model/EnrollmentDetails.kt | 25 +- .../room/discovery/EnrolledCourseEntity.kt | 13 +- .../core/domain/model/CourseAccessDetails.kt | 3 + .../domain/model/CourseEnrollmentDetails.kt | 36 ++ .../core/domain/model/CourseInfoOverview.kt | 26 + .../openedx/core/domain/model/CourseMode.kt | 3 + .../org/openedx/core/extension/BooleanExt.kt | 9 + .../org/openedx/core/extension/DateExt.kt | 7 + .../org/openedx/core/extension/ObjectExt.kt | 9 + .../org/openedx/core/extension/StringExt.kt | 4 + .../openedx/core/ui/UpgradeToAccessView.kt | 8 +- .../java/org/openedx/core/utils/TimeUtils.kt | 8 + .../data/repository/CourseRepository.kt | 5 + .../domain/interactor/CourseInteractor.kt | 6 +- .../course/presentation/CourseRouter.kt | 2 + .../container/CollapsingLayout.kt | 46 +- .../container/CourseContainerFragment.kt | 486 ++++++++++++------ .../container/CourseContainerViewModel.kt | 112 +++- .../outline/CourseOutlineScreen.kt | 5 +- .../course/presentation/ui/CourseVideosUI.kt | 5 +- .../main/res/drawable/course_ic_calendar.xml | 9 + .../drawable/course_ic_circled_arrow_up.xml | 32 ++ course/src/main/res/values/strings.xml | 6 + .../container/CourseContainerViewModelTest.kt | 55 +- .../dates/CourseDatesViewModelTest.kt | 7 +- .../outline/CourseOutlineViewModelTest.kt | 8 +- .../section/CourseSectionViewModelTest.kt | 6 +- .../CourseUnitContainerViewModelTest.kt | 8 +- .../videos/CourseVideoViewModelTest.kt | 8 +- .../presentation/AllEnrolledCoursesView.kt | 2 - .../AllEnrolledCoursesViewModel.kt | 8 +- .../presentation/DashboardGalleryViewModel.kt | 1 - .../presentation/DashboardListFragment.kt | 7 +- .../dashboard/presentation/DashboardRouter.kt | 1 - .../discovery/presentation/DiscoveryRouter.kt | 1 - .../detail/CourseDetailsFragment.kt | 1 - .../presentation/info/CourseInfoViewModel.kt | 1 - .../presentation/program/ProgramViewModel.kt | 1 - 50 files changed, 924 insertions(+), 277 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt create mode 100644 core/src/main/java/org/openedx/core/extension/BooleanExt.kt create mode 100644 core/src/main/java/org/openedx/core/extension/DateExt.kt create mode 100644 core/src/main/java/org/openedx/core/extension/ObjectExt.kt create mode 100644 course/src/main/res/drawable/course_ic_calendar.xml create mode 100644 course/src/main/res/drawable/course_ic_circled_arrow_up.xml diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 99eb919dc..89327a14b 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -149,11 +149,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + CourseContainerFragment.newInstance(courseId, courseTitle) ) } //endregion @@ -164,7 +163,6 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, openTab: String, resumeBlockId: String, ) { @@ -173,7 +171,6 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di CourseContainerFragment.newInstance( courseId, courseTitle, - enrollmentMode, openTab, resumeBlockId ) @@ -397,6 +394,12 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) } + override fun navigateToDiscover(fm: FragmentManager) { + fm.beginTransaction() + .replace(R.id.container, MainFragment.newInstance("", "", "DISCOVER")) + .commit() + } + override fun navigateToWebContent(fm: FragmentManager, title: String, url: String) { replaceFragmentWithBackStack( fm, 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 31564edf7..a55d45ff6 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -300,7 +300,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = courseTitle, - enrollmentMode = "" ) } } @@ -311,7 +310,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "VIDEOS" ) } @@ -323,7 +321,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "DATES" ) } @@ -335,7 +332,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "DISCUSSIONS" ) } @@ -347,7 +343,6 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - enrollmentMode = "", openTab = "MORE" ) } 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 817f05ffd..4e7f087cb 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -27,6 +27,7 @@ import org.openedx.auth.presentation.sso.MicrosoftAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ImageProcessor import org.openedx.core.config.Config +import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.storage.CorePreferences @@ -97,6 +98,10 @@ val appModule = module { CourseStructureModel::class.java, CourseStructureModel.Deserializer(get()) ) + .registerTypeAdapter( + CourseEnrollmentDetails::class.java, + CourseEnrollmentDetails.Deserializer(get()) + ) .create() } 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 b33a0a092..9075e5fab 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -252,12 +252,11 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String, resumeBlockId: String) -> + viewModel { (courseId: String, courseTitle: String, resumeBlockId: String) -> CourseContainerViewModel( courseId, courseTitle, resumeBlockId, - enrollmentMode, get(), get(), get(), diff --git a/core/src/edx/org/openedx/core/ui/theme/Colors.kt b/core/src/edx/org/openedx/core/ui/theme/Colors.kt index 6bc9a48d0..bb2614a3e 100644 --- a/core/src/edx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/edx/org/openedx/core/ui/theme/Colors.kt @@ -84,7 +84,7 @@ val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = light_surface val light_settings_title_content = light_surface val light_progress_bar_color = light_primary_button_background -val light_progress_bar_background_color = light_secondary_variant +val light_progress_bar_background_color = Color(0xFFCCD4E0) // Dark theme colors scheme 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 6d30a9044..292360b4d 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 @@ -5,6 +5,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.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.HandoutsModel @@ -76,4 +77,9 @@ interface CourseApi { @Query("status") status: String? = null, @Query("requested_fields") fields: List = emptyList() ): CourseEnrollments + + @GET("/api/mobile/v1/course_info/{course_id}/enrollment_details") + suspend fun getEnrollmentDetails( + @Path("course_id") courseId: String, + ): CourseEnrollmentDetails } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt index 1b4275f08..c69b092ed 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt @@ -6,17 +6,31 @@ import org.openedx.core.utils.TimeUtils import org.openedx.core.domain.model.CourseAccessDetails as DomainCourseAccessDetails data class CourseAccessDetails( + @SerializedName("has_unmet_prerequisites") + val hasUnmetPrerequisites: Boolean, + @SerializedName("is_too_early") + val isTooEarly: Boolean, + @SerializedName("is_staff") + val isStaff: Boolean, @SerializedName("audit_access_expires") val auditAccessExpires: String?, @SerializedName("courseware_access") var coursewareAccess: CoursewareAccess?, ) { - fun mapToDomain(): DomainCourseAccessDetails = - DomainCourseAccessDetails( - TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), - coursewareAccess?.mapToDomain() - ) + fun mapToDomain() = DomainCourseAccessDetails( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain(), + ) fun mapToRoomEntity(): CourseAccessDetailsDb = - CourseAccessDetailsDb(auditAccessExpires, coursewareAccess?.mapToRoomEntity()) + CourseAccessDetailsDb( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = auditAccessExpires, + coursewareAccess = coursewareAccess?.mapToRoomEntity() + ) } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt new file mode 100644 index 000000000..145470c35 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt @@ -0,0 +1,60 @@ +package org.openedx.core.data.model + +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.extension.takeIfNotEmpty +import java.lang.reflect.Type +import org.openedx.core.domain.model.CourseEnrollmentDetails as DomainCourseEnrollmentDetails + +data class CourseEnrollmentDetails( + @SerializedName("id") + val id: String, + @SerializedName("course_updates") + val courseUpdates: String, + @SerializedName("course_handouts") + val courseHandouts: String, + @SerializedName("discussion_url") + val discussionUrl: String, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, + @SerializedName("certificate") + val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, + @SerializedName("course_info_overview") + val courseInfoOverview: CourseInfoOverview, +) { + fun mapToDomain(): DomainCourseEnrollmentDetails { + return DomainCourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain(), + ) + } + + class Deserializer(val corePreferences: CorePreferences) : + JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext?, + ): CourseEnrollmentDetails { + val courseDetails = Gson().fromJson(json, CourseEnrollmentDetails::class.java) + corePreferences.appConfig.iapConfig.productPrefix?.takeIfNotEmpty()?.let { + courseDetails.courseInfoOverview.courseModes?.forEach { courseModes -> + courseModes.setStoreProductSku(it) + } + } + return courseDetails + } + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt new file mode 100644 index 000000000..d019d2b35 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt @@ -0,0 +1,60 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.iap.ProductInfo +import org.openedx.core.extension.isNotNullOrEmpty +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseInfoOverview as DomainCourseInfoOverview + +data class CourseInfoOverview( + @SerializedName("name") + val name: String, + @SerializedName("number") + val number: String, + @SerializedName("org") + val org: String, + @SerializedName("start") + val start: String?, + @SerializedName("start_display") + val startDisplay: String, + @SerializedName("start_type") + val startType: String, + @SerializedName("end") + val end: String?, + @SerializedName("is_self_paced") + val isSelfPaced: Boolean, + @SerializedName("media") + var media: Media?, + @SerializedName("course_sharing_utm_parameters") + val courseSharingUtmParameters: CourseSharingUtmParameters, + @SerializedName("course_about") + val courseAbout: String, + @SerializedName("course_modes") + val courseModes: List?, +) { + fun mapToDomain() = DomainCourseInfoOverview( + name = name, + number = number, + org = org, + start = TimeUtils.iso8601ToDate(start ?: ""), + startDisplay = startDisplay, + startType = startType, + end = TimeUtils.iso8601ToDate(end ?: ""), + isSelfPaced = isSelfPaced, + media = media?.mapToDomain(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToDomain(), + courseAbout = courseAbout, + courseModes = courseModes?.map { it.mapToDomain() }, + productInfo = courseModes?.find { + it.isVerifiedMode() + }?.takeIf { + it.androidSku.isNotNullOrEmpty() && it.storeSku.isNotNullOrEmpty() + }?.run { + ProductInfo( + courseSku = androidSku!!, + storeSku = storeSku!!, + lmsUSDPrice = minPrice ?: 0.0 + ) + } + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseMode.kt b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt index d534d67a4..32d4cda7e 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseMode.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt @@ -1,7 +1,9 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.EnrollmentMode import kotlin.math.ceil +import org.openedx.core.domain.model.CourseMode as DomainCourseMode /** * Data class representing the mode of a course ("audit, verified etc"), with various attributes @@ -10,20 +12,31 @@ import kotlin.math.ceil data class CourseMode( @SerializedName("slug") val slug: String?, - @SerializedName("sku") val sku: String?, - @SerializedName("android_sku") val androidSku: String?, - + @SerializedName("ios_sku") + val iosSku: String?, @SerializedName("min_price") - val price: Double?, - + val minPrice: Double?, var storeSku: String?, ) { + fun mapToDomain() = DomainCourseMode( + slug = slug, + sku = sku, + androidSku = androidSku, + iosSku = iosSku, + minPrice = minPrice, + storeSku = storeSku + ) + + fun isVerifiedMode(): Boolean { + return EnrollmentMode.VERIFIED.toString().equals(slug, ignoreCase = true) + } + fun setStoreProductSku(storeProductPrefix: String) { - val ceilPrice = price + val ceilPrice = minPrice ?.let { ceil(it).toInt() } ?.takeIf { it > 0 } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt index 9aba5482b..43aac47b5 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt @@ -11,8 +11,8 @@ import org.openedx.core.data.model.room.MediaDb import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.EnrollmentMode import org.openedx.core.domain.model.iap.ProductInfo +import org.openedx.core.extension.isNotNullOrEmpty import org.openedx.core.utils.TimeUtils import java.lang.reflect.Type @@ -73,14 +73,14 @@ data class CourseStructureModel( progress = progress?.mapToDomain(), enrollmentDetails = enrollmentDetails.mapToDomain(), productInfo = courseModes?.find { - EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true) + it.isVerifiedMode() }?.takeIf { - it.androidSku.isNullOrEmpty().not() && it.storeSku.isNullOrEmpty().not() + it.androidSku.isNotNullOrEmpty() && it.storeSku.isNotNullOrEmpty() }?.run { ProductInfo( courseSku = androidSku!!, storeSku = storeSku!!, - lmsUSDPrice = price ?: 0.0 + lmsUSDPrice = minPrice ?: 0.0 ) } ) @@ -112,7 +112,7 @@ data class CourseStructureModel( override fun deserialize( json: JsonElement?, typeOfT: Type?, - context: JsonDeserializationContext? + context: JsonDeserializationContext?, ): CourseStructureModel { val courseStructure = Gson().fromJson(json, CourseStructureModel::class.java) if (corePreferences.appConfig.iapConfig.productPrefix.isNullOrEmpty().not()) { diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt index 05cb07fde..da0e685c5 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt @@ -4,8 +4,8 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.EnrolledCourse -import org.openedx.core.domain.model.EnrollmentMode import org.openedx.core.domain.model.iap.ProductInfo +import org.openedx.core.extension.isNotNullOrEmpty import org.openedx.core.utils.TimeUtils import org.openedx.core.domain.model.Progress as ProgressDomain @@ -43,14 +43,14 @@ data class EnrolledCourse( courseStatus = courseStatus?.mapToDomain(), courseAssignments = courseAssignments?.mapToDomain(), productInfo = courseModes?.find { - EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true) + it.isVerifiedMode() }?.takeIf { - it.androidSku.isNullOrEmpty().not() && it.storeSku.isNullOrEmpty().not() + it.androidSku.isNotNullOrEmpty() && it.storeSku.isNotNullOrEmpty() }?.run { ProductInfo( courseSku = androidSku!!, storeSku = storeSku!!, - lmsUSDPrice = price ?: 0.0 + lmsUSDPrice = minPrice ?: 0.0 ) } ) diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt index e1172d713..63daecb37 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -9,24 +9,21 @@ import org.openedx.core.domain.model.EnrollmentDetails as DomainEnrollmentDetail data class EnrollmentDetails( @SerializedName("created") var created: String?, - + @SerializedName("date") + val date: String?, @SerializedName("mode") - var mode: String?, - + val mode: String?, @SerializedName("is_active") - var isActive: Boolean = false, - + val isActive: Boolean = false, @SerializedName("upgrade_deadline") - var upgradeDeadline: String?, + val upgradeDeadline: String?, ) { - fun mapToDomain(): DomainEnrollmentDetails { - return DomainEnrollmentDetails( - created = TimeUtils.iso8601ToDate(created ?: ""), - mode = mode, - isActive = isActive, - upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), - ) - } + fun mapToDomain() = DomainEnrollmentDetails( + created = TimeUtils.iso8601ToDate(date ?: ""), + mode = mode, + isActive = isActive, + upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), + ) fun mapToRoomEntity() = EnrollmentDetailsDB( created = created, diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index fce33e00d..535ef9b45 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -267,6 +267,12 @@ data class EnrollmentDetailsDB( } data class CourseAccessDetailsDb( + @ColumnInfo("hasUnmetPrerequisites") + val hasUnmetPrerequisites: Boolean, + @ColumnInfo("isTooEarly") + val isTooEarly: Boolean, + @ColumnInfo("isStaff") + val isStaff: Boolean, @ColumnInfo("auditAccessExpires") var auditAccessExpires: String?, @Embedded @@ -274,8 +280,11 @@ data class CourseAccessDetailsDb( ) { fun mapToDomain(): CourseAccessDetails { return CourseAccessDetails( - TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), - coursewareAccess?.mapToDomain() + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain() ) } } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt index d7246d2e1..fac674e66 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -6,6 +6,9 @@ import java.util.Date @Parcelize data class CourseAccessDetails( + val hasUnmetPrerequisites: Boolean, + val isTooEarly: Boolean, + val isStaff: Boolean, val auditAccessExpires: Date?, val coursewareAccess: CoursewareAccess?, ) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt new file mode 100644 index 000000000..e60a22f87 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.isNotNull +import java.util.Date + +@Parcelize +data class CourseEnrollmentDetails( + val id: String, + val courseUpdates: String, + val courseHandouts: String, + val discussionUrl: String, + val courseAccessDetails: CourseAccessDetails, + val certificate: Certificate?, + val enrollmentDetails: EnrollmentDetails, + val courseInfoOverview: CourseInfoOverview, +) : Parcelable { + + val hasAccess: Boolean + get() = courseAccessDetails.coursewareAccess?.hasAccess ?: false + + val isAuditAccessExpired: Boolean + get() = courseAccessDetails.auditAccessExpires.isNotNull() && + Date().after(courseAccessDetails.auditAccessExpires) + + val isUpgradeable: Boolean + get() = enrollmentDetails.isAuditMode && + courseInfoOverview.isStarted && + enrollmentDetails.isUpgradeDeadlinePassed.not() && + courseInfoOverview.productInfo.isNotNull() +} + +enum class CourseAccessError { + NONE, AUDIT_EXPIRED_NOT_UPGRADABLE, AUDIT_EXPIRED_UPGRADABLE, NOT_YET_STARTED, UNKNOWN +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt new file mode 100644 index 000000000..4bbfc10d1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -0,0 +1,26 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.domain.model.iap.ProductInfo +import java.util.Date + +@Parcelize +data class CourseInfoOverview( + val name: String, + val number: String, + val org: String, + val start: Date?, + val startDisplay: String, + val startType: String, + val end: Date?, + val isSelfPaced: Boolean, + var media: Media?, + val courseSharingUtmParameters: CourseSharingUtmParameters, + val courseAbout: String, + val courseModes: List?, + val productInfo: ProductInfo? +) : Parcelable { + val isStarted: Boolean + get() = start?.before(Date()) ?: false +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt index 8803c4ce2..544739e27 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt @@ -6,6 +6,9 @@ import kotlinx.parcelize.Parcelize @Parcelize data class CourseMode( val slug: String?, + val sku: String?, val androidSku: String?, + val iosSku: String?, + val minPrice: Double?, var storeSku: String?, ) : Parcelable diff --git a/core/src/main/java/org/openedx/core/extension/BooleanExt.kt b/core/src/main/java/org/openedx/core/extension/BooleanExt.kt new file mode 100644 index 000000000..4e9f69a0c --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/BooleanExt.kt @@ -0,0 +1,9 @@ +package org.openedx.core.extension + +fun Boolean?.isTrue(): Boolean { + return this == true +} + +fun Boolean?.isFalse(): Boolean { + return this == false +} diff --git a/core/src/main/java/org/openedx/core/extension/DateExt.kt b/core/src/main/java/org/openedx/core/extension/DateExt.kt new file mode 100644 index 000000000..eb2e041a9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/DateExt.kt @@ -0,0 +1,7 @@ +package org.openedx.core.extension + +import java.util.Date + +fun Date?.isNotNull(): Boolean { + return this != null +} diff --git a/core/src/main/java/org/openedx/core/extension/ObjectExt.kt b/core/src/main/java/org/openedx/core/extension/ObjectExt.kt new file mode 100644 index 000000000..c7a6c4db5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/ObjectExt.kt @@ -0,0 +1,9 @@ +package org.openedx.core.extension + +fun T?.isNotNull(): Boolean { + return this != null +} + +fun T?.isNull(): Boolean { + return this == null +} diff --git a/core/src/main/java/org/openedx/core/extension/StringExt.kt b/core/src/main/java/org/openedx/core/extension/StringExt.kt index 45032c809..583789827 100644 --- a/core/src/main/java/org/openedx/core/extension/StringExt.kt +++ b/core/src/main/java/org/openedx/core/extension/StringExt.kt @@ -46,3 +46,7 @@ fun String?.equalsHost(host: String?): Boolean { false } } + +fun String?.isNotNullOrEmpty(): Boolean { + return this.isNullOrEmpty().not() +} diff --git a/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt b/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt index 4a97d76aa..fe9962a21 100644 --- a/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt +++ b/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt @@ -65,7 +65,9 @@ fun UpgradeToAccessView( } } - UpgradeToAccessViewType.COURSE -> { + UpgradeToAccessViewType.COURSE, + UpgradeToAccessViewType.AUDIT_EXPIRED, + -> { shape = MaterialTheme.appShapes.buttonShape } @@ -117,12 +119,13 @@ enum class UpgradeToAccessViewType { GALLERY, DASHBOARD, COURSE, + AUDIT_EXPIRED, } @Preview(backgroundColor = 0xFFFFFFFF, showBackground = true) @Composable private fun UpgradeToAccessViewPreview( - @PreviewParameter(UpgradeToAccessViewTypeParameterProvider::class) type: UpgradeToAccessViewType + @PreviewParameter(UpgradeToAccessViewTypeParameterProvider::class) type: UpgradeToAccessViewType, ) { OpenEdXTheme { UpgradeToAccessView(type = type) {} @@ -135,5 +138,6 @@ private class UpgradeToAccessViewTypeParameterProvider : UpgradeToAccessViewType.DASHBOARD, UpgradeToAccessViewType.COURSE, UpgradeToAccessViewType.GALLERY, + UpgradeToAccessViewType.AUDIT_EXPIRED, ) } 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 109e60ef2..a3cd4cfc6 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -282,6 +282,14 @@ object TimeUtils { } } + /** + * Returns a formatted date string for the given date using context. + */ + fun getCourseAccessFormattedDate(context: Context, date: Date): String { + val resourceManager = ResourceManager(context) + return dateToCourseDate(resourceManager, date) + } + /** * Returns the number of days difference between the given date and the current date. */ 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 c32397a48..e4faa480e 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 @@ -6,6 +6,7 @@ import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao @@ -58,6 +59,10 @@ class CourseRepository( return courseStructure[courseId]!! } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return api.getEnrollmentDetails(courseId = courseId).mapToDomain() + } + suspend fun getCourseStatus(courseId: String): CourseComponentStatus { val username = preferencesManager.user?.username ?: "" return api.getCourseStatus(username, courseId).mapToDomain() 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 5bc859120..f9d7d3fc0 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 @@ -2,6 +2,7 @@ package org.openedx.course.domain.interactor import org.openedx.core.BlockType import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.course.data.repository.CourseRepository @@ -16,6 +17,10 @@ class CourseInteractor( return repository.getCourseStructure(courseId, isNeedRefresh) } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return repository.getEnrollmentDetails(courseId = courseId) + } + suspend fun getCourseStructureForVideos( courseId: String, isNeedRefresh: Boolean = false @@ -71,5 +76,4 @@ class CourseInteractor( suspend fun removeDownloadModel(id: String) = repository.removeDownloadModel(id) fun getDownloadModels() = repository.getDownloadModels() - } diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 3b59be61d..65ce5f012 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -62,4 +62,6 @@ interface CourseRouter { fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) + + fun navigateToDiscover(fm: FragmentManager) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt index 52c87456f..cf63f715c 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -82,6 +82,7 @@ internal fun CollapsingLayout( modifier: Modifier = Modifier, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, upgradeButton: @Composable BoxScope.() -> Unit, @@ -170,10 +171,15 @@ internal fun CollapsingLayout( } } + val collapsingModifier = if (isEnabled) { + modifier + .nestedScroll(nestedScrollConnection) + } else { + modifier + } Box( - modifier = modifier + modifier = collapsingModifier .fillMaxSize() - .nestedScroll(nestedScrollConnection) .pointerInput(Unit) { var yStart = 0f coroutineScope { @@ -225,6 +231,7 @@ internal fun CollapsingLayout( backBtnStartPadding = backBtnStartPadding, courseImage = courseImage, imageHeight = imageHeight, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, upgradeButton = upgradeButton, @@ -249,6 +256,7 @@ internal fun CollapsingLayout( courseImage = courseImage, imageHeight = imageHeight, toolbarBackgroundOffset = toolbarBackgroundOffset, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, collapsedTop = collapsedTop, @@ -271,6 +279,7 @@ private fun CollapsingLayoutTablet( backBtnStartPadding: Dp, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, upgradeButton: @Composable BoxScope.() -> Unit, @@ -414,15 +423,22 @@ private fun CollapsingLayoutTablet( Box(content = navigation) } - Box( - modifier = Modifier + val bodyPadding = expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (expandedTopHeight.value + navigationHeight.value + backgroundImageHeight.value).toDp() }), + .padding(bottom = with(localDensity) { bodyPadding.toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -445,6 +461,7 @@ private fun CollapsingLayoutMobile( courseImage: Bitmap, imageHeight: Int, toolbarBackgroundOffset: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, @@ -719,15 +736,23 @@ private fun CollapsingLayoutMobile( Box(content = navigation) } - Box( - modifier = Modifier + val bodyPadding = + expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor + val bodyModifier = if (isEnabled) { + Modifier .offset { IntOffset( x = 0, - y = (expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor).roundToInt() + y = bodyPadding.roundToInt() ) } - .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }) + } else { + Modifier + .padding(top = with(localDensity) { if (bodyPadding < 0) 0.toDp() else bodyPadding.toDp() }) + } + Box( + modifier = bodyModifier, content = bodyContent, ) } @@ -783,6 +808,7 @@ private fun CollapsingLayoutPreview() { pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) ) }, + isEnabled = true, onBackClick = {}, bodyContent = {} ) 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 c6b482556..dba9043d6 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 @@ -1,23 +1,29 @@ package org.openedx.course.presentation.container +import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.util.Log import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -25,6 +31,7 @@ import androidx.compose.material.SnackbarData import androidx.compose.material.SnackbarDuration import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -41,12 +48,20 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource 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.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar @@ -55,6 +70,8 @@ import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.domain.model.CourseAccessError +import org.openedx.core.extension.isTrue import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.IAPAnalyticsScreen import org.openedx.core.presentation.dialog.IAPDialogFragment @@ -64,13 +81,18 @@ import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialog import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.UpgradeToAccessView import org.openedx.core.ui.UpgradeToAccessViewType import org.openedx.core.ui.WindowSize 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.appTypography +import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding @@ -81,6 +103,7 @@ import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen +import java.util.Date class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -90,7 +113,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), requireArguments().getString(ARG_TITLE, ""), - requireArguments().getString(ARG_ENROLLMENT_MODE, ""), requireArguments().getString(ARG_RESUME_BLOCK, "") ) } @@ -112,7 +134,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.preloadCourseStructure() + viewModel.fetchCourseDetails() } private var snackBar: Snackbar? = null @@ -135,12 +157,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == false) { - viewModel.courseRouter.navigateToNoAccess( - requireActivity().supportFragmentManager, - viewModel.courseName - ) - } else { + if (isReady.isTrue()) { if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { setUpCourseCalendar() } @@ -155,7 +172,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { viewModel.errorMessage.observe(viewLifecycleOwner) { snackBar = Snackbar.make(binding.root, it, Snackbar.LENGTH_INDEFINITE) .setAction(org.openedx.core.R.string.core_error_try_again) { - viewModel.preloadCourseStructure() + viewModel.fetchCourseDetails() } snackBar?.show() @@ -174,12 +191,13 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun initCourseView() { binding.composeCollapsingLayout.setContent { val isNavigationEnabled by viewModel.isNavigationEnabled.collectAsState() + val fm = requireActivity().supportFragmentManager CourseDashboard( viewModel = viewModel, isNavigationEnabled = isNavigationEnabled, isResumed = isResumed, - fragmentManager = requireActivity().supportFragmentManager, - bundle = requireArguments(), + openTab = requireArguments().getString(ARG_OPEN_TAB, CourseContainerTab.HOME.name), + fragmentManager = fm, onRefresh = { page -> onRefresh(page) } @@ -280,21 +298,18 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { companion object { const val ARG_COURSE_ID = "courseId" const val ARG_TITLE = "title" - const val ARG_ENROLLMENT_MODE = "enrollmentMode" const val ARG_OPEN_TAB = "open_tab" const val ARG_RESUME_BLOCK = "resume_block" fun newInstance( courseId: String, courseTitle: String, - enrollmentMode: String, openTab: String = CourseContainerTab.HOME.name, - resumeBlockId: String = "" + resumeBlockId: String = "", ): CourseContainerFragment { val fragment = CourseContainerFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, ARG_TITLE to courseTitle, - ARG_ENROLLMENT_MODE to enrollmentMode, ARG_OPEN_TAB to openTab, ARG_RESUME_BLOCK to resumeBlockId ) @@ -307,11 +322,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @Composable fun CourseDashboard( viewModel: CourseContainerViewModel, - onRefresh: (page: Int) -> Unit, isNavigationEnabled: Boolean, isResumed: Boolean, + openTab: String, fragmentManager: FragmentManager, - bundle: Bundle, + onRefresh: (page: Int) -> Unit, ) { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -327,8 +342,6 @@ fun CourseDashboard( val refreshing by viewModel.refreshing.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - val openTab = - bundle.getString(CourseContainerFragment.ARG_OPEN_TAB, CourseContainerTab.HOME.name) val requiredTab = when (openTab.uppercase()) { CourseContainerTab.HOME.name -> CourseContainerTab.HOME CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS @@ -343,6 +356,7 @@ fun CourseDashboard( pageCount = { CourseContainerTab.entries.size } ) val dataReady = viewModel.dataReady.observeAsState() + val accessStatus = viewModel.courseAccessStatus.observeAsState() val canShowUpgradeButton by viewModel.canShowUpgradeButton.collectAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } @@ -365,139 +379,158 @@ fun CourseDashboard( tabState.animateScrollToItem(pagerState.currentPage) } - Box { - CollapsingLayout( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .pullRefresh(pullRefreshState), - courseImage = courseImage, - imageHeight = 200, - expandedTop = { - if (dataReady.value == true) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier.weight(1f) + ) { + CollapsingLayout( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .pullRefresh(pullRefreshState), + courseImage = courseImage, + imageHeight = 200, + expandedTop = { ExpandedHeaderContent( courseTitle = viewModel.courseName, - org = viewModel.courseStructure?.org!! + org = viewModel.courseDetails?.courseInfoOverview?.org ?: "" ) - } - }, - collapsedTop = { - CollapsedHeaderContent( - courseTitle = viewModel.courseName - ) - }, - upgradeButton = { - if (dataReady.value == true && canShowUpgradeButton) { - val horizontalPadding = if (!windowSize.isTablet) 16.dp else 98.dp - UpgradeToAccessView( - modifier = Modifier.padding( - start = horizontalPadding, - end = 16.dp, - top = 16.dp - ), - type = UpgradeToAccessViewType.COURSE, - ) { - IAPDialogFragment.newInstance( - iapFlow = IAPFlow.USER_INITIATED, - screenName = IAPAnalyticsScreen.COURSE_DASHBOARD.screenName, - courseId = viewModel.courseId, - courseName = viewModel.courseName, - isSelfPaced = viewModel.courseStructure?.isSelfPaced!!, - productInfo = viewModel.courseStructure?.productInfo!! - ).show( - fragmentManager, - IAPDialogFragment.TAG + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = viewModel.courseName + ) + }, + upgradeButton = { + if (dataReady.value.isTrue() && canShowUpgradeButton) { + val horizontalPadding = if (!windowSize.isTablet) 16.dp else 98.dp + UpgradeToAccessView( + modifier = Modifier.padding( + start = horizontalPadding, + end = 16.dp, + top = 16.dp + ), + type = UpgradeToAccessViewType.COURSE, + ) { + IAPDialogFragment.newInstance( + iapFlow = IAPFlow.USER_INITIATED, + screenName = IAPAnalyticsScreen.COURSE_DASHBOARD.screenName, + courseId = viewModel.courseId, + courseName = viewModel.courseName, + isSelfPaced = viewModel.courseDetails?.courseInfoOverview?.isSelfPaced.isTrue(), + productInfo = viewModel.courseDetails?.courseInfoOverview?.productInfo!! + ).show( + fragmentManager, + IAPDialogFragment.TAG + ) + } + } + }, + navigation = { + if (isNavigationEnabled) { + RoundTabsBar( + items = CourseContainerTab.entries, + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 16.dp + ), + rowState = tabState, + pagerState = pagerState, + withPager = true, + onTabClicked = viewModel::courseContainerTabClickedEvent ) } - } - }, - navigation = { - if (isNavigationEnabled) { - RoundTabsBar( - items = CourseContainerTab.entries, - contentPadding = PaddingValues( - horizontal = 12.dp, - vertical = 16.dp - ), - rowState = tabState, - pagerState = pagerState, - withPager = true, - onTabClicked = viewModel::courseContainerTabClickedEvent - ) - } else { - Spacer(modifier = Modifier.height(52.dp)) - } - }, - onBackClick = { - fragmentManager.popBackStack() - }, - bodyContent = { - if (dataReady.value == true) { - DashboardPager( - windowSize = windowSize, - viewModel = viewModel, - pagerState = pagerState, - isNavigationEnabled = isNavigationEnabled, - isResumed = isResumed, - fragmentManager = fragmentManager, - bundle = bundle - ) - } - } - ) - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true }, - onReloadClick = { - isInternetConnectionShown = true - onRefresh(pagerState.currentPage) - } - ) - } - - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar( - showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, - onViewDates = { - scrollToDates(scope, pagerState) + isEnabled = CourseAccessError.NONE == accessStatus.value, + onBackClick = { + fragmentManager.popBackStack() }, - onClose = { - snackbarData.dismiss() + bodyContent = { + when (accessStatus.value) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + CourseAccessError.AUDIT_EXPIRED_UPGRADABLE, + CourseAccessError.NOT_YET_STARTED, + CourseAccessError.UNKNOWN, + -> { + CourseAccessErrorView( + viewModel = viewModel, + accessError = accessStatus.value, + fragmentManager = fragmentManager, + ) + } + + CourseAccessError.NONE -> { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + ) + } + + else -> { + } + } } ) + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onRefresh(pagerState.currentPage) + } + ) + } + + SnackbarHost( + modifier = Modifier.align(Alignment.BottomStart), + hostState = snackState + ) { snackbarData: SnackbarData -> + DatesShiftedSnackBar( + showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, + onViewDates = { + scrollToDates(scope, pagerState) + }, + onClose = { + snackbarData.dismiss() + } + ) + } } } } } + } @OptIn(ExperimentalFoundationApi::class) @Composable -fun DashboardPager( +private fun DashboardPager( windowSize: WindowSize, viewModel: CourseContainerViewModel, pagerState: PagerState, isNavigationEnabled: Boolean, isResumed: Boolean, fragmentManager: FragmentManager, - bundle: Bundle ) { HorizontalPager( state = pagerState, @@ -509,12 +542,7 @@ fun DashboardPager( CourseOutlineScreen( windowSize = windowSize, viewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, "") - ) - } + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), fragmentManager = fragmentManager, onResetDatesClick = { @@ -527,12 +555,7 @@ fun DashboardPager( CourseVideosScreen( windowSize = windowSize, viewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, "") - ) - } + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), fragmentManager = fragmentManager ) @@ -543,9 +566,9 @@ fun DashboardPager( viewModel = koinViewModel( parameters = { parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, ""), - bundle.getString(CourseContainerFragment.ARG_ENROLLMENT_MODE, "") + viewModel.courseId, + viewModel.courseName, + viewModel.courseDetails?.enrollmentDetails?.mode ?: "" ) } ), @@ -561,12 +584,7 @@ fun DashboardPager( CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( discussionTopicsViewModel = koinViewModel( - parameters = { - parametersOf( - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), - bundle.getString(CourseContainerFragment.ARG_TITLE, ""), - ) - } + parameters = { parametersOf(viewModel.courseId, viewModel.courseName) } ), windowSize = windowSize, fragmentManager = fragmentManager @@ -579,14 +597,14 @@ fun DashboardPager( onHandoutsClick = { viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + viewModel.courseId, HandoutsType.Handouts ) }, onAnnouncementsClick = { viewModel.courseRouter.navigateToHandoutsWebView( fragmentManager, - bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + viewModel.courseId, HandoutsType.Announcements ) }) @@ -595,9 +613,169 @@ fun DashboardPager( } } +@Composable +private fun CourseAccessErrorView( + viewModel: CourseContainerViewModel?, + accessError: CourseAccessError?, + fragmentManager: FragmentManager, +) { + var icon: Painter = painterResource(id = R.drawable.course_ic_circled_arrow_up) + var message = "" + when (accessError) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE -> { + message = stringResource( + R.string.course_error_expired_not_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate( + LocalContext.current, + viewModel?.courseDetails?.courseAccessDetails?.auditAccessExpires ?: Date() + ) + ) + } + + CourseAccessError.AUDIT_EXPIRED_UPGRADABLE -> { + message = stringResource( + R.string.course_error_expired_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate( + LocalContext.current, + viewModel?.courseDetails?.courseAccessDetails?.auditAccessExpires ?: Date() + ) + ) + } + + CourseAccessError.NOT_YET_STARTED -> { + icon = painterResource(id = R.drawable.course_ic_calendar) + message = stringResource( + R.string.course_error_not_started_title, + viewModel?.courseDetails?.courseInfoOverview?.startDisplay ?: "" + ) + } + + CourseAccessError.UNKNOWN -> { + icon = painterResource(id = R.drawable.course_ic_not_supported_block) + message = stringResource(R.string.course_an_error_occurred) + } + + else -> {} + } + + + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsInset() + .background(MaterialTheme.appColors.background), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { + Image( + modifier = Modifier + .size(96.dp) + .padding(bottom = 12.dp), + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.appColors.progressBarBackgroundColor), + ) + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + textAlign = TextAlign.Center, + text = message, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + SetupCourseAccessErrorButtons( + viewModel = viewModel, + accessError = accessError, + fragmentManager = fragmentManager, + ) + } + } +} + +@Composable +private fun SetupCourseAccessErrorButtons( + viewModel: CourseContainerViewModel?, + accessError: CourseAccessError?, + fragmentManager: FragmentManager, +) { + when (accessError) { + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE, + CourseAccessError.NOT_YET_STARTED, + CourseAccessError.UNKNOWN, + -> { + OpenEdXButton( + text = stringResource(R.string.course_label_back), + onClick = { fragmentManager.popBackStack() }, + ) + } + + CourseAccessError.AUDIT_EXPIRED_UPGRADABLE -> { + OpenEdXOutlinedButton( + text = stringResource(R.string.course_find_new_course_button), + backgroundColor = MaterialTheme.appColors.background, + textColor = MaterialTheme.appColors.primary, + borderColor = MaterialTheme.appColors.primary, + onClick = { + viewModel?.courseRouter?.navigateToDiscover(fragmentManager) + } + ) + UpgradeToAccessView( + modifier = Modifier + .fillMaxWidth(), + type = UpgradeToAccessViewType.AUDIT_EXPIRED, + ) { + IAPDialogFragment.newInstance( + iapFlow = IAPFlow.USER_INITIATED, + screenName = IAPAnalyticsScreen.COURSE_DASHBOARD.screenName, + courseId = viewModel?.courseId ?: "", + courseName = viewModel?.courseName ?: "", + isSelfPaced = viewModel?.courseDetails?.courseInfoOverview?.isSelfPaced.isTrue(), + productInfo = viewModel?.courseDetails?.courseInfoOverview?.productInfo!! + ).show( + fragmentManager, + IAPDialogFragment.TAG + ) + } + } + + else -> {} + } +} + @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseAccessErrorViewPreview() { + val context = LocalContext.current + OpenEdXTheme { + CourseAccessErrorView( + viewModel = null, + accessError = CourseAccessError.AUDIT_EXPIRED_UPGRADABLE, + fragmentManager = (context as? FragmentActivity)?.supportFragmentManager!! + ) + } +} 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 b4a846b09..8e41222b6 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 @@ -26,9 +26,14 @@ import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseAccessError +import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.extension.isFalse import org.openedx.core.extension.isInternetError +import org.openedx.core.extension.isNull +import org.openedx.core.extension.isTrue import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState @@ -58,7 +63,6 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter -import java.util.Date import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -66,7 +70,6 @@ class CourseContainerViewModel( val courseId: String, var courseName: String, private var resumeBlockId: String, - private val enrollmentMode: String, private val appData: AppData, private val config: Config, private val interactor: CourseInteractor, @@ -86,6 +89,10 @@ class CourseContainerViewModel( val dataReady: LiveData get() = _dataReady + private val _courseAccessStatus = MutableLiveData() + val courseAccessStatus: LiveData + get() = _courseAccessStatus + private val _errorMessage = SingleEventLiveData() val errorMessage: LiveData get() = _errorMessage @@ -116,10 +123,17 @@ class CourseContainerViewModel( get() = iapConfig.isEnabled && iapConfig.disableVersions.contains(appData.versionName).not() + private val isPaymentEnabled + get() = isIAPEnabled && isValuePropEnabled + private var _canShowUpgradeButton = MutableStateFlow(false) val canShowUpgradeButton: StateFlow get() = _canShowUpgradeButton.asStateFlow() + private var _courseDetails: CourseEnrollmentDetails? = null + val courseDetails: CourseEnrollmentDetails? + get() = _courseDetails + private var _courseStructure: CourseStructure? = null val courseStructure: CourseStructure? get() = _courseStructure @@ -192,9 +206,56 @@ class CourseContainerViewModel( }.distinctUntilChanged().launchIn(viewModelScope) } - fun preloadCourseStructure() { + fun fetchCourseDetails() { + _showProgress.value = true + viewModelScope.launch { + try { + _courseDetails = interactor.getEnrollmentDetails(courseId) + _courseDetails?.let { courseDetails -> + courseName = courseDetails.courseInfoOverview.name + _canShowUpgradeButton.value = isPaymentEnabled && courseDetails.isUpgradeable + loadCourseImage(courseDetails.courseInfoOverview.media?.image?.large) + if (courseDetails.hasAccess.isFalse()) { + _showProgress.value = false + _dataReady.value = false + if (courseDetails.isAuditAccessExpired) { + if (_canShowUpgradeButton.value) { + _courseAccessStatus.value = + CourseAccessError.AUDIT_EXPIRED_UPGRADABLE + } else { + _courseAccessStatus.value = + CourseAccessError.AUDIT_EXPIRED_NOT_UPGRADABLE + } + } else if (courseDetails.courseInfoOverview.isStarted.not()) { + _courseAccessStatus.value = CourseAccessError.NOT_YET_STARTED + } else { + _courseAccessStatus.value = CourseAccessError.UNKNOWN + } + } else { + _courseAccessStatus.value = CourseAccessError.NONE + _isNavigationEnabled.value = true + preloadCourseStructure() + } + } ?: run { + _courseAccessStatus.value = CourseAccessError.UNKNOWN + } + } catch (e: Exception) { + e.printStackTrace() + if (e.isInternetError() || e is NoCachedDataException) { + _errorMessage.value = + resourceManager.getString(CoreR.string.core_error_no_connection) + } else { + _courseAccessStatus.value = CourseAccessError.UNKNOWN + } + } + } + } + + private fun preloadCourseStructure() { courseDashboardViewed() - if (_dataReady.value != null) { + if (_courseAccessStatus.value != CourseAccessError.NONE) { + _isNavigationEnabled.value = false + _showProgress.value = false return } _showProgress.value = true @@ -202,33 +263,26 @@ class CourseContainerViewModel( try { _courseStructure = interactor.getCourseStructure(courseId, true) _courseStructure?.let { - courseName = it.name - loadCourseImage(courseStructure?.media?.image?.large) + _showProgress.value = false _calendarSyncUIState.update { state -> state.copy(isCalendarSyncEnabled = isCalendarSyncEnabled()) } - _dataReady.value = courseStructure?.start?.let { start -> - val isReady = start < Date() - if (isReady) { - _isNavigationEnabled.value = true - } - isReady + if (resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) } - _canShowUpgradeButton.value = isIAPEnabled && - isValuePropEnabled && - courseStructure?.isUpgradeable == true - } - if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { - delay(500L) - courseNotifier.send(CourseOpenBlock(resumeBlockId)) + _dataReady.value = true + } ?: run { + _dataReady.value = false + _courseAccessStatus.value = CourseAccessError.UNKNOWN } } catch (e: Exception) { - if (e.isInternetError() || e is NoCachedDataException) { + e.printStackTrace() + if (e.isInternetError()) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_no_connection) } else { - _errorMessage.value = - resourceManager.getString(CoreR.string.core_error_unknown_error) + _courseAccessStatus.value = CourseAccessError.UNKNOWN } } } @@ -281,13 +335,14 @@ class CourseContainerViewModel( } fun updateData(isIAPFlow: Boolean = false) { + if (_courseDetails.isNull() || _courseAccessStatus.value != CourseAccessError.NONE) { + return + } viewModelScope.launch { try { _courseStructure = interactor.getCourseStructure(courseId, isNeedRefresh = true) - _canShowUpgradeButton.value = isIAPEnabled && - isValuePropEnabled && - courseStructure?.productInfo != null && - courseStructure?.isUpgradeable == true + _canShowUpgradeButton.value = + isPaymentEnabled && courseStructure?.isUpgradeable.isTrue() } catch (e: Exception) { if (e.isInternetError()) { _errorMessage.value = @@ -521,7 +576,10 @@ class CourseContainerViewModel( params = buildMap { put(CourseAnalyticsKey.NAME.key, event.biValue) put(CourseAnalyticsKey.COURSE_ID.key, courseId) - put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) + put( + CourseAnalyticsKey.ENROLLMENT_MODE.key, + _courseDetails?.enrollmentDetails?.mode ?: "" + ) put( CourseAnalyticsKey.PACING.key, if (_courseStructure?.isSelfPaced == true) CourseAnalyticsKey.SELF_PACED.key diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 85cb8ba95..2fea02a34 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -627,7 +627,10 @@ private val mockEnrollmentDetails = EnrollmentDetails(created = Date(), mode = "audit", isActive = false, upgradeDeadline = Date()) private val mockCourseAccessDetails = CourseAccessDetails( - Date(), + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), coursewareAccess = CoursewareAccess( true, "", diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index ad1338696..32a2c31ed 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -735,7 +735,10 @@ private val mockEnrollmentDetails = EnrollmentDetails(created = Date(), mode = "audit", isActive = false, upgradeDeadline = Date()) private val mockCourseAccessDetails = CourseAccessDetails( - Date(), + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), coursewareAccess = CoursewareAccess( true, "", diff --git a/course/src/main/res/drawable/course_ic_calendar.xml b/course/src/main/res/drawable/course_ic_calendar.xml new file mode 100644 index 000000000..c8f12ef7a --- /dev/null +++ b/course/src/main/res/drawable/course_ic_calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/course_ic_circled_arrow_up.xml b/course/src/main/res/drawable/course_ic_circled_arrow_up.xml new file mode 100644 index 000000000..aab47473e --- /dev/null +++ b/course/src/main/res/drawable/course_ic_circled_arrow_up.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 4848ac402..db61a466d 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -68,4 +68,10 @@ %1$s of %2$s assignments complete + Back + Your free audit access to this course expired on %s. + Your free audit access to this course expired on %s. Please upgrade to continue learning and receive a verified certificate. + Find a new course + This course will begin on %s. Come back then to start learning! + An error occurred while loading your course diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 158fb5440..9946b7307 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -32,6 +32,9 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview +import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrollmentDetails @@ -106,7 +109,11 @@ class CourseContainerViewModelTest { end = null, media = null, courseAccessDetails = CourseAccessDetails( - Date(), coursewareAccess = CoursewareAccess( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), + coursewareAccess = CoursewareAccess( true, "", "", @@ -139,17 +146,47 @@ class CourseContainerViewModelTest { startType = "", end = null, courseAccessDetails = org.openedx.core.data.model.CourseAccessDetails( - "", + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = "", coursewareAccess = null ), media = null, certificate = null, isSelfPaced = false, progress = null, - enrollmentDetails = org.openedx.core.data.model.EnrollmentDetails("", "", false, ""), + enrollmentDetails = org.openedx.core.data.model.EnrollmentDetails("", "", "", false, ""), courseModes = arrayListOf() ) + private val enrollmentDetails = CourseEnrollmentDetails( + id = "", + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + courseAccessDetails = CourseAccessDetails( + false, + false, + false, + null, + CoursewareAccess( + false, "", "", "", + "", "" + ) + ), + certificate = null, + enrollmentDetails = EnrollmentDetails( + null, "", false, null + ), + courseInfoOverview = CourseInfoOverview( + "Open edX Demo Course", "", "OpenedX", null, + "", "", null, false, null, + CourseSharingUtmParameters("", ""), + "", listOf() + ) + ) + @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -193,6 +230,7 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() @@ -203,7 +241,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(noInternet, message) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.accessStatus.value == null) } @Test @@ -229,6 +267,7 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() @@ -239,7 +278,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.accessStatus.value == null) } @Test @@ -265,6 +304,7 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit viewModel.preloadCourseStructure() advanceUntilIdle() @@ -274,7 +314,7 @@ class CourseContainerViewModelTest { assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.accessStatus.value?.accessError == null) } @Test @@ -300,6 +340,7 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails every { analytics.logScreenEvent(any(), any()) } returns Unit coEvery { courseApi.getCourseStructure(any(), any(), any(), any()) @@ -312,7 +353,7 @@ class CourseContainerViewModelTest { assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.accessStatus.value?.accessError == null) } @Test 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 index c4fc89f6a..dd672c893 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -128,7 +128,12 @@ class CourseDatesViewModelTest { startType = "", end = null, media = null, - courseAccessDetails = CourseAccessDetails(Date(), CoursewareAccess( + courseAccessDetails = CourseAccessDetails( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), + coursewareAccess = CoursewareAccess( true, "", "", diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index 4fe06e1a7..221295a5e 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -163,7 +163,11 @@ class CourseOutlineViewModelTest { end = Date(), media = null, courseAccessDetails = CourseAccessDetails( - Date(), CoursewareAccess( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), + coursewareAccess = CoursewareAccess( true, "", "", @@ -610,4 +614,4 @@ class CourseOutlineViewModelTest { assert(message.await()?.message.isNullOrEmpty()) } -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 1b0d01fd0..f326fdb2e 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -148,7 +148,11 @@ class CourseSectionViewModelTest { end = Date(), media = null, courseAccessDetails = CourseAccessDetails( - Date(), CoursewareAccess( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), + coursewareAccess = CoursewareAccess( true, "", "", diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 577c3d41d..f5c4c6d2e 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -142,7 +142,11 @@ class CourseUnitContainerViewModelTest { end = Date(), media = null, courseAccessDetails = CourseAccessDetails( - Date(), CoursewareAccess( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), + coursewareAccess = CoursewareAccess( true, "", "", @@ -336,4 +340,4 @@ class CourseUnitContainerViewModelTest { coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index 4bba62ba7..325c51732 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -157,7 +157,11 @@ class CourseVideoViewModelTest { end = Date(), media = null, courseAccessDetails = CourseAccessDetails( - Date(), CoursewareAccess( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = Date(), + coursewareAccess = CoursewareAccess( true, "", "", @@ -437,4 +441,4 @@ class CourseVideoViewModelTest { } -} \ No newline at end of file +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 2599cc12b..2bcef01d0 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -66,7 +66,6 @@ import androidx.fragment.app.FragmentManager import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.androidx.compose.koinViewModel -import org.openedx.Lock import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.Certificate @@ -131,7 +130,6 @@ fun AllEnrolledCoursesView( fragmentManager, course.id, course.name, - mode ) } } 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 bcae70f7c..26a9e4f03 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -169,14 +169,12 @@ class AllEnrolledCoursesViewModel( fragmentManager: FragmentManager, courseId: String, courseName: String, - mode: String ) { dashboardCourseClickedEvent(courseId, courseName) dashboardRouter.navigateToCourseOutline( - fragmentManager, - courseId, - courseName, - mode + fm = fragmentManager, + courseId = courseId, + courseTitle = courseName ) } } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index 615d16619..82f356b7b 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -164,7 +164,6 @@ class DashboardGalleryViewModel( fm = fragmentManager, courseId = enrolledCourse.course.id, courseTitle = enrolledCourse.course.name, - enrollmentMode = enrolledCourse.mode, openTab = if (openDates) CourseTab.DATES.name else CourseTab.HOME.name, resumeBlockId = 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 1a38fad14..8cbd32ae2 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -150,10 +150,9 @@ class DashboardListFragment : Fragment() { onItemClick = { viewModel.dashboardCourseClickedEvent(it.course.id, it.course.name) router.navigateToCourseOutline( - requireActivity().supportFragmentManager, - it.course.id, - it.course.name, - it.mode + fm = requireActivity().supportFragmentManager, + courseId = it.course.id, + courseTitle = it.course.name, ) }, 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 5d427cddf..9ea98274b 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -9,7 +9,6 @@ interface DashboardRouter { fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, openTab: String = "", resumeBlockId: String = "" ) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt index e1c4baa74..2e67af44a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -8,7 +8,6 @@ interface DiscoveryRouter { fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String ) fun navigateToLogistration(fm: FragmentManager, courseId: String?) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 4c7eb1da6..a1cef9677 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -164,7 +164,6 @@ class CourseDetailsFragment : Fragment() { requireActivity().supportFragmentManager, currentState.course.courseId, currentState.course.name, - enrollmentMode = "" ) } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 83ed16a71..88e5a8f71 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt @@ -129,7 +129,6 @@ class CourseInfoViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "" ) } } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index 59a26cba5..040896c3b 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -90,7 +90,6 @@ class ProgramViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "" ) } viewModelScope.launch {