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..351f56174 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 @@ -1,22 +1,33 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb 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() = CourseAccessDetails( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain(), + ) fun mapToRoomEntity(): CourseAccessDetailsDb = - CourseAccessDetailsDb(auditAccessExpires, coursewareAccess?.mapToRoomEntity()) + CourseAccessDetailsDb( + hasUnmetPrerequisites, isTooEarly, isStaff, + auditAccessExpires, 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..6cfd6f166 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseEnrollmentDetails + +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(): CourseEnrollmentDetails { + return CourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain(), + ) + } +} 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..3720d9604 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt @@ -0,0 +1,47 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseInfoOverview +import org.openedx.core.utils.TimeUtils + +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() = CourseInfoOverview( + 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() }, + ) +} 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..8b249ca49 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,6 +1,7 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseMode import kotlin.math.ceil /** @@ -10,20 +11,26 @@ 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() = CourseMode( + slug = slug, + sku = sku, + androidSku = androidSku, + iosSku = iosSku, + minPrice = minPrice, + storeSku = storeSku + ) 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/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt index e1172d713..92011870c 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..b08475ad1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -0,0 +1,30 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +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 { + fun isUpgradable(): Boolean { + val start = courseInfoOverview.start ?: return false + val upgradeDeadline = enrollmentDetails.upgradeDeadline ?: return false + if (enrollmentDetails.mode != "audit") return false + + return start < Date() && getCourseMode() != null && upgradeDeadline > Date() + } + + fun getCourseMode(): CourseMode? { + return courseInfoOverview.courseModes + .firstOrNull { it.slug == "verified" && !it.androidSku.isNullOrEmpty() } + } +} 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..338fc3a2b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -0,0 +1,21 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +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, +) : Parcelable 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/domain/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt index 1bd89d4ef..127ec1ecc 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -10,7 +10,7 @@ data class EnrollmentDetails( var created: Date?, var mode: String?, var isActive: Boolean, - var upgradeDeadline: Date?, + var upgradeDeadline: Date? ) : Parcelable { val isUpgradeDeadlinePassed: Boolean get() = TimeUtils.isDatePassed(Date(), upgradeDeadline) 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..2ffd31204 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,11 @@ object TimeUtils { } } + 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..4182ca2c1 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 navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?, openTab: String) } 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/CourseAccessStatus.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseAccessStatus.kt new file mode 100644 index 000000000..a841bb01b --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseAccessStatus.kt @@ -0,0 +1,14 @@ +package org.openedx.course.presentation.container + +import java.util.Date + +data class CourseAccessStatus( + val accessError: CourseAccessError? = null, + val date: Date? = null, + val sku: String? = null +) + +enum class CourseAccessError { + COURSE_EXPIRED_NOT_UPGRADABLE, COURSE_EXPIRED_UPGRADABLE, COURSE_NOT_STARTED, COURSE_NO_ACCESS +} + 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..1fcebbfcf 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,13 +1,20 @@ 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.ColumnScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -18,6 +25,8 @@ 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 +34,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,8 +51,14 @@ 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.painter.Painter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.core.view.isVisible @@ -52,9 +68,11 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.extension.tagId import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.IAPAnalyticsScreen import org.openedx.core.presentation.dialog.IAPDialogFragment @@ -64,16 +82,23 @@ 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.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.appTypography +import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding +import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType @@ -81,6 +106,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) { @@ -94,6 +120,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { requireArguments().getString(ARG_RESUME_BLOCK, "") ) } + private val courseRouter by inject() private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -134,8 +161,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun observe() { - viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == false) { + viewModel.accessStatus.observe(viewLifecycleOwner) { accessStatus -> + if (accessStatus?.accessError == CourseAccessError.COURSE_NO_ACCESS) { viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName @@ -174,14 +201,23 @@ 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, + fragmentManager = fm, bundle = requireArguments(), onRefresh = { page -> onRefresh(page) + }, + findNewCourseClick = { + courseRouter.navigateToMain( + fm = fm, + courseId = null, + infoType = null, + openTab = "DISCOVER" + ) } ) } @@ -307,11 +343,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @Composable fun CourseDashboard( viewModel: CourseContainerViewModel, - onRefresh: (page: Int) -> Unit, isNavigationEnabled: Boolean, isResumed: Boolean, fragmentManager: FragmentManager, bundle: Bundle, + onRefresh: (page: Int) -> Unit, + findNewCourseClick: () -> Unit ) { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -342,7 +379,7 @@ fun CourseDashboard( initialPage = CourseContainerTab.entries.indexOf(requiredTab), pageCount = { CourseContainerTab.entries.size } ) - val dataReady = viewModel.dataReady.observeAsState() + val accessStatus = viewModel.accessStatus.observeAsState() val canShowUpgradeButton by viewModel.canShowUpgradeButton.collectAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } @@ -365,123 +402,181 @@ 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.organization ) - } - }, - 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 (accessStatus.value?.accessError == null && 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 + ) + } + } + }, + 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 { + accessStatus.value?.let { + if (it.accessError == null) { + Spacer(modifier = Modifier.height(52.dp)) + } + } + } + }, + isEnabled = accessStatus.value?.accessError == null, + onBackClick = { + fragmentManager.popBackStack() + }, + bodyContent = { + accessStatus.value?.let { accessStatus -> + when (accessStatus.accessError) { + CourseAccessError.COURSE_EXPIRED_NOT_UPGRADABLE -> { + CourseExpiredNotUpgradeableMessage( + date = accessStatus.date ?: Date() + ) + } + + CourseAccessError.COURSE_EXPIRED_UPGRADABLE -> { + CourseExpiredUpgradeableMessage( + date = accessStatus.date ?: Date() + ) + } + + CourseAccessError.COURSE_NOT_STARTED -> { + CourseNotStartedMessage( + date = accessStatus.date ?: Date() + ) + } + + CourseAccessError.COURSE_NO_ACCESS -> { + + } + + null -> { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + bundle = bundle + ) + } + } } } - }, - 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)) + ) + 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() + } + ) + } + } + + accessStatus.value?.let { accessStatus -> + when (accessStatus.accessError) { + CourseAccessError.COURSE_EXPIRED_NOT_UPGRADABLE -> { + CourseExpiredNotUpgradeableButtons(onBackClick = { fragmentManager.popBackStack() }) } - }, - onBackClick = { - fragmentManager.popBackStack() - }, - bodyContent = { - if (dataReady.value == true) { - DashboardPager( - windowSize = windowSize, - viewModel = viewModel, - pagerState = pagerState, - isNavigationEnabled = isNavigationEnabled, - isResumed = isResumed, - fragmentManager = fragmentManager, - bundle = bundle + + CourseAccessError.COURSE_EXPIRED_UPGRADABLE -> { + CourseExpiredUpgradeableButtons( + sku = accessStatus.sku, + findNewCourseClick = findNewCourseClick ) } - } - ) - 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) + CourseAccessError.COURSE_NOT_STARTED -> { + CourseNotStartedButtons(onBackClick = { fragmentManager.popBackStack() }) } - ) - } - 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() + CourseAccessError.COURSE_NO_ACCESS, null -> { + } - ) + } } } } @@ -490,7 +585,7 @@ fun CourseDashboard( @OptIn(ExperimentalFoundationApi::class) @Composable -fun DashboardPager( +private fun DashboardPager( windowSize: WindowSize, viewModel: CourseContainerViewModel, pagerState: PagerState, @@ -595,6 +690,238 @@ fun DashboardPager( } } +@Composable +private fun CourseExpiredNotUpgradeableMessage(date: Date) { + CourseErrorMessagePlaceholder( + iconPainter = painterResource(id = R.drawable.ic_course_update), + textContent = { + Text( + textAlign = TextAlign.Center, + text = stringResource( + R.string.course_error_expired_not_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate(LocalContext.current, date) + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + ) +} + +@Composable +private fun CourseExpiredNotUpgradeableButtons(onBackClick: () -> Unit) { + CourseErrorButtonsPlaceholder { + OpenEdXButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.course_error_back), + onClick = { + onBackClick() + }, + ) + } +} + +@Composable +private fun CourseExpiredUpgradeableMessage(date: Date) { + CourseErrorMessagePlaceholder( + iconPainter = painterResource(id = R.drawable.ic_course_update), + textContent = { + Text( + textAlign = TextAlign.Center, + text = stringResource( + R.string.course_error_expired_upgradeable_title, + TimeUtils.getCourseAccessFormattedDate(LocalContext.current, date) + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_check), + contentDescription = null + ) + Text( + textAlign = TextAlign.Left, + text = stringResource(R.string.course_error_expired_upgradeable_option_1), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_check), + contentDescription = null + ) + Text( + textAlign = TextAlign.Left, + text = stringResource(R.string.course_error_expired_upgradeable_option_2), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_check), + contentDescription = null + ) + Text( + textAlign = TextAlign.Left, + text = stringResource(R.string.course_error_expired_upgradeable_option_3), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + } + } + ) +} + +@Composable +private fun CourseExpiredUpgradeableButtons( + sku: String?, + findNewCourseClick: () -> Unit +) { + CourseErrorButtonsPlaceholder { + OpenEdXOutlinedButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.course_error_expired_upgradeable_find_new_course_button), + backgroundColor = MaterialTheme.appColors.background, + textColor = MaterialTheme.appColors.primary, + borderColor = MaterialTheme.appColors.primary, + onClick = { + findNewCourseClick() + } + ) + if (!sku.isNullOrEmpty()) { + OpenEdXButton( + modifier = Modifier + .fillMaxWidth(), + onClick = { + + }, + content = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_course_arrow_circle_up), + contentDescription = null + ) + val buttonText = + stringResource( + R.string.course_error_expired_upgradeable_upgrade_now_button, sku + ) + Text( + modifier = Modifier.testTag("txt_${buttonText.tagId()}"), + text = buttonText, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + } + ) + } + } +} + +@Composable +private fun CourseNotStartedMessage(date: Date) { + CourseErrorMessagePlaceholder( + iconPainter = painterResource(id = R.drawable.ic_course_dates), + textContent = { + Text( + textAlign = TextAlign.Center, + text = stringResource( + R.string.course_error_not_started_title, + TimeUtils.getCourseAccessFormattedDate(LocalContext.current, date) + ), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + ) +} + +@Composable +private fun CourseNotStartedButtons(onBackClick: () -> Unit) { + CourseErrorButtonsPlaceholder { + OpenEdXButton( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.course_error_back), + onClick = { + onBackClick() + }, + ) + } +} + +@Composable +private fun CourseErrorMessagePlaceholder( + iconPainter: Painter, + textContent: @Composable ColumnScope.() -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .statusBarsInset() + .displayCutoutForLandscape() + .background(MaterialTheme.appColors.background), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp) + ) { + Row( + modifier = Modifier + .fillMaxSize() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + if (LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT) { + Image( + painter = iconPainter, + contentDescription = null + ) + } + textContent() + } + } + } + } +} + +@Composable +private fun CourseErrorButtonsPlaceholder( + buttonContent: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 27.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + buttonContent() + } +} + @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { 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..8a4af4857 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,6 +26,7 @@ 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.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError @@ -82,9 +83,9 @@ class CourseContainerViewModel( val courseRouter: CourseRouter, ) : BaseViewModel() { - private val _dataReady = MutableLiveData() - val dataReady: LiveData - get() = _dataReady + private val _accessStatus = MutableLiveData() + val accessStatus: LiveData + get() = _accessStatus private val _errorMessage = SingleEventLiveData() val errorMessage: LiveData @@ -146,6 +147,8 @@ class CourseContainerViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + var organization = "" + init { viewModelScope.launch { courseNotifier.notifier.collect { event -> @@ -194,35 +197,50 @@ class CourseContainerViewModel( fun preloadCourseStructure() { courseDashboardViewed() - if (_dataReady.value != null) { + if (_accessStatus.value != null) { return } _showProgress.value = true viewModelScope.launch { try { - _courseStructure = interactor.getCourseStructure(courseId, true) - _courseStructure?.let { - courseName = it.name - loadCourseImage(courseStructure?.media?.image?.large) - _calendarSyncUIState.update { state -> - state.copy(isCalendarSyncEnabled = isCalendarSyncEnabled()) - } - _dataReady.value = courseStructure?.start?.let { start -> - val isReady = start < Date() - if (isReady) { - _isNavigationEnabled.value = true + val enrollmentDetails = interactor.getEnrollmentDetails(courseId) + val accessStatus = getCourseAccessStatus(enrollmentDetails) + val courseInfoOverview = enrollmentDetails.courseInfoOverview + courseName = courseInfoOverview.name + organization = courseInfoOverview.org + loadCourseImage(courseInfoOverview.media?.image?.large) + _calendarSyncUIState.update { state -> + state.copy(isCalendarSyncEnabled = isCalendarSyncEnabled()) + } + + if (accessStatus.accessError != null) { + _isNavigationEnabled.value = false + _showProgress.value = false + _accessStatus.value = accessStatus + return@launch + } + + interactor.getCourseStructure(courseId, true) + courseInfoOverview.start?.let { start -> + val isReady = start < Date() + if (isReady) { + _isNavigationEnabled.value = true + _accessStatus.value = accessStatus + _canShowUpgradeButton.value = isIAPEnabled && + isValuePropEnabled && + courseStructure?.isUpgradeable == true + if (resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) } - isReady + } else { + _accessStatus.value = + CourseAccessStatus(accessError = CourseAccessError.COURSE_NO_ACCESS) } - _canShowUpgradeButton.value = isIAPEnabled && - isValuePropEnabled && - courseStructure?.isUpgradeable == true - } - if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { - delay(500L) - courseNotifier.send(CourseOpenBlock(resumeBlockId)) } + } catch (e: Exception) { + e.printStackTrace() if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = resourceManager.getString(CoreR.string.core_error_no_connection) @@ -234,6 +252,52 @@ class CourseContainerViewModel( } } + private fun getCourseAccessStatus(enrollmentDetails: CourseEnrollmentDetails): CourseAccessStatus { + enrollmentDetails.courseAccessDetails.let { accessDetails -> + if (accessDetails.coursewareAccess?.hasAccess != false) { + return CourseAccessStatus() + } + + if ((enrollmentDetails.courseInfoOverview.end ?: Date()) < Date()) { + if (enrollmentDetails.isUpgradable()) { + return CourseAccessStatus( + accessError = CourseAccessError.COURSE_EXPIRED_UPGRADABLE, + date = enrollmentDetails.courseInfoOverview.end, + sku = enrollmentDetails.getCourseMode()?.androidSku + ) + } else { + return CourseAccessStatus( + accessError = CourseAccessError.COURSE_EXPIRED_NOT_UPGRADABLE, + date = enrollmentDetails.courseInfoOverview.end + ) + } + } else { + val errorCode = accessDetails.coursewareAccess?.errorCode + ?: return CourseAccessStatus() + return when (errorCode) { + "notStarted" -> { + CourseAccessStatus( + accessError = CourseAccessError.COURSE_NOT_STARTED, + date = enrollmentDetails.courseInfoOverview.start + ) + } + + "auditExpired" -> { + CourseAccessStatus( + accessError = CourseAccessError.COURSE_EXPIRED_UPGRADABLE, + date = enrollmentDetails.courseAccessDetails.auditAccessExpires, + sku = enrollmentDetails.getCourseMode()?.androidSku + ) + } + + else -> { + CourseAccessStatus() + } + } + } + } + } + private fun loadCourseImage(imageUrl: String?) { imageProcessor.loadImage( imageUrl = config.getApiHostURL() + imageUrl, 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 69e930d9c..e89d907e9 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 @@ -609,7 +609,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 f7c0f85b6..24a0f225c 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 @@ -747,7 +747,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/ic_course_arrow_circle_up.xml b/course/src/main/res/drawable/ic_course_arrow_circle_up.xml new file mode 100644 index 000000000..b2fab59ac --- /dev/null +++ b/course/src/main/res/drawable/ic_course_arrow_circle_up.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_check.xml b/course/src/main/res/drawable/ic_course_check.xml new file mode 100644 index 000000000..5cd73ffc9 --- /dev/null +++ b/course/src/main/res/drawable/ic_course_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_dates.xml b/course/src/main/res/drawable/ic_course_dates.xml new file mode 100644 index 000000000..d0674de0f --- /dev/null +++ b/course/src/main/res/drawable/ic_course_dates.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/res/drawable/ic_course_update.xml b/course/src/main/res/drawable/ic_course_update.xml new file mode 100644 index 000000000..062c76c28 --- /dev/null +++ b/course/src/main/res/drawable/ic_course_update.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 51ac39e95..438f4db4c 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -69,4 +69,13 @@ %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. + Earn a verified certificate of completion to showcase on your resumé + Unlock access to all course activities, including graded assignments + Full access to course content and material, even after the course ends + Find a new course + Upgrade now for %s + This course will begin on %s. Come back then to start learning! 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..167114d89 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,11 +32,15 @@ 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 import org.openedx.core.presentation.global.AppData import org.openedx.core.system.CalendarManager +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -150,6 +154,33 @@ class CourseContainerViewModelTest { 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 +224,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 +235,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 +261,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 +272,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 +298,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 +308,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 +334,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 +347,7 @@ class CourseContainerViewModelTest { assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.accessStatus.value?.accessError == null) } @Test