diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 09903f99e..4d4d38182 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -3,6 +3,7 @@ package org.openedx.app import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction +import org.openedx.app.deeplink.HomeTab import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment @@ -163,11 +164,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 @@ -178,7 +178,6 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di fm: FragmentManager, courseId: String, courseTitle: String, - enrollmentMode: String, openTab: String, resumeBlockId: String, ) { @@ -187,7 +186,6 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di CourseContainerFragment.newInstance( courseId, courseTitle, - enrollmentMode, openTab, resumeBlockId ) @@ -411,6 +409,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("", "", HomeTab.DISCOVER.name)) + .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 79d70208f..05d68cc49 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -30,6 +30,7 @@ import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.CalendarRouter import org.openedx.core.R 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.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences 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 86d9b3dfe..31ebf741e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -242,12 +242,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(), @@ -257,7 +256,7 @@ val screenModule = module { get(), get(), get(), - get() + get(), ) } viewModel { (courseId: String, courseTitle: String) -> 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 4822a3762..32d401f7b 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -6,6 +6,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.EnrollmentStatus @@ -93,4 +94,9 @@ interface CourseApi { suspend fun getEnrollmentsStatus( @Path("username") username: String ): List + + @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 new file mode 100644 index 000000000..c69b092ed --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +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( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess = coursewareAccess?.mapToDomain(), + ) + + fun mapToRoomEntity(): CourseAccessDetailsDb = + 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..b27057eac --- /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 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(), + ) + } +} 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..57faedd2a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseInfoOverview.kt @@ -0,0 +1,44 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +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, +) { + 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, + ) +} 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 d09411d14..a21492dc7 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 @@ -33,8 +33,12 @@ data class CourseStructureModel( var coursewareAccess: CoursewareAccess?, @SerializedName("media") var media: Media?, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, @SerializedName("certificate") val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, @SerializedName("is_self_paced") var isSelfPaced: Boolean?, @SerializedName("course_progress") @@ -58,7 +62,7 @@ data class CourseStructureModel( media = media?.mapToDomain(), certificate = certificate?.mapToDomain(), isSelfPaced = isSelfPaced ?: false, - progress = progress?.mapToDomain() + progress = progress?.mapToDomain(), ) } @@ -78,7 +82,7 @@ data class CourseStructureModel( media = MediaDb.createFrom(media), certificate = certificate?.mapToRoomEntity(), isSelfPaced = isSelfPaced ?: false, - progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, ) } } 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 new file mode 100644 index 000000000..668e97f07 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -0,0 +1,36 @@ +package org.openedx.core.data.model + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.utils.TimeUtils + +import org.openedx.core.domain.model.EnrollmentDetails as DomainEnrollmentDetails + +data class EnrollmentDetails( + @SerializedName("created") + var created: String?, + @SerializedName("date") + val date: String?, + @SerializedName("mode") + val mode: String?, + @SerializedName("is_active") + val isActive: Boolean = false, + @SerializedName("upgrade_deadline") + val upgradeDeadline: String?, +) { + fun mapToDomain() = DomainEnrollmentDetails( + created = TimeUtils.iso8601ToDate(date ?: ""), + mode = mode, + isActive = isActive, + upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), + ) + + fun mapToRoomEntity() = EnrollmentDetailsDB( + created = created, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline, + ) +} 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 e019f6300..59de42e53 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 @@ -7,6 +7,7 @@ import androidx.room.PrimaryKey import org.openedx.core.data.model.DateType import org.openedx.core.data.model.room.MediaDb import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseSharingUtmParameters @@ -14,6 +15,7 @@ import org.openedx.core.domain.model.CourseStatus import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils import java.util.Date @@ -244,3 +246,44 @@ data class CourseDateBlockDb( assignmentType = assignmentType ) } + +data class EnrollmentDetailsDB( + @ColumnInfo("created") + var created: String?, + @ColumnInfo("mode") + var mode: String?, + @ColumnInfo("isActive") + var isActive: Boolean, + @ColumnInfo("upgradeDeadline") + var upgradeDeadline: String?, +) { + fun mapToDomain() = EnrollmentDetails( + TimeUtils.iso8601ToDate(created ?: ""), + mode, + isActive, + TimeUtils.iso8601ToDate(upgradeDeadline ?: "") + ) +} + +data class CourseAccessDetailsDb( + @ColumnInfo("hasUnmetPrerequisites") + val hasUnmetPrerequisites: Boolean, + @ColumnInfo("isTooEarly") + val isTooEarly: Boolean, + @ColumnInfo("isStaff") + val isStaff: Boolean, + @ColumnInfo("auditAccessExpires") + var auditAccessExpires: String?, + @Embedded + val coursewareAccess: CoursewareAccessDb?, +) { + fun mapToDomain(): CourseAccessDetails { + return CourseAccessDetails( + 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 new file mode 100644 index 000000000..fac674e66 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +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..5c61fee60 --- /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 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) +} + +enum class CourseAccessError { + NONE, AUDIT_EXPIRED_NOT_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..4d02f10b9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -0,0 +1,23 @@ +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, +) : Parcelable { + val isStarted: Boolean + get() = start?.before(Date()) ?: false +} 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 new file mode 100644 index 000000000..01882167b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -0,0 +1,17 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import androidx.room.ColumnInfo +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.EnrollmentDetails +import org.openedx.core.extension.isNotNull +import java.util.Date + +@Parcelize +data class EnrollmentDetails( + val created: Date?, + val mode: String?, + val isActive: Boolean, + val upgradeDeadline: Date?, +) : 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/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/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index f39b9369a..a2fb3cfc7 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -13,6 +13,7 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import java.util.concurrent.TimeUnit object TimeUtils { @@ -224,6 +225,14 @@ object TimeUtils { } return formattedDate } + + /** + * 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) + } } /** 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 8eaafe721..f79e46066 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 @@ -9,6 +9,7 @@ import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.XBlockProgressData 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 @@ -70,6 +71,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 22248d57d..e91b309c3 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 @@ -20,6 +21,10 @@ class CourseInteractor( return repository.getCourseStructureFromCache(courseId) } + suspend fun getEnrollmentDetails(courseId: String): CourseEnrollmentDetails { + return repository.getEnrollmentDetails(courseId = courseId) + } + suspend fun getCourseStructureForVideos( courseId: String, isNeedRefresh: Boolean = false 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 b40387266..c4d1bd844 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 @@ -79,6 +79,7 @@ internal fun CollapsingLayout( modifier: Modifier = Modifier, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -166,10 +167,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 { @@ -221,6 +227,7 @@ internal fun CollapsingLayout( backBtnStartPadding = backBtnStartPadding, courseImage = courseImage, imageHeight = imageHeight, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, navigation = navigation, @@ -244,6 +251,7 @@ internal fun CollapsingLayout( courseImage = courseImage, imageHeight = imageHeight, toolbarBackgroundOffset = toolbarBackgroundOffset, + isEnabled = isEnabled, onBackClick = onBackClick, expandedTop = expandedTop, collapsedTop = collapsedTop, @@ -265,6 +273,7 @@ private fun CollapsingLayoutTablet( backBtnStartPadding: Dp, courseImage: Bitmap, imageHeight: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -408,15 +417,22 @@ private fun CollapsingLayoutTablet( 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, ) } @@ -439,6 +455,7 @@ private fun CollapsingLayoutMobile( courseImage: Bitmap, imageHeight: Int, toolbarBackgroundOffset: Int, + isEnabled: Boolean, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, @@ -712,15 +729,23 @@ private fun CollapsingLayoutMobile( 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, ) } @@ -764,6 +789,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 856b40c4f..c6f452c10 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,12 +70,18 @@ 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.isFalse import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.RoundTabsBar +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 @@ -75,6 +96,7 @@ import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize +import java.util.Date class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -84,7 +106,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, "") ) } @@ -97,7 +118,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 @@ -113,7 +134,9 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onResume() { super.onResume() - viewModel.updateData() + if (viewModel.courseAccessStatus.value == CourseAccessError.NONE) { + viewModel.updateData() + } } override fun onDestroyView() { @@ -123,12 +146,16 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == false) { + if (isReady.isFalse()) { viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName ) } else { + if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { + setUpCourseCalendar() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pushNotificationPermissionLauncher.launch( android.Manifest.permission.POST_NOTIFICATIONS @@ -139,7 +166,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() @@ -152,18 +179,21 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun onRefresh(currentPage: Int) { - viewModel.onRefresh(CourseContainerTab.entries[currentPage]) + if (viewModel.courseAccessStatus.value == CourseAccessError.NONE) { + viewModel.onRefresh(CourseContainerTab.entries[currentPage]) + } } 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) } @@ -192,21 +222,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 ) @@ -219,11 +246,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() @@ -239,7 +266,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 @@ -253,7 +279,7 @@ fun CourseDashboard( initialPage = CourseContainerTab.entries.indexOf(requiredTab), pageCount = { CourseContainerTab.entries.size } ) - val dataReady = viewModel.dataReady.observeAsState() + val accessStatus = viewModel.courseAccessStatus.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -275,108 +301,131 @@ fun CourseDashboard( tabState.animateScrollToItem(pagerState.currentPage) } - Box { - CollapsingLayout( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - .pullRefresh(pullRefreshState), - courseImage = courseImage, - imageHeight = 200, - expandedTop = { - ExpandedHeaderContent( - courseTitle = viewModel.courseName, - org = viewModel.organization - ) - }, - collapsedTop = { - CollapsedHeaderContent( - courseTitle = viewModel.courseName - ) - }, - navigation = { - if (isNavigationEnabled) { - RoundTabsBar( - items = CourseContainerTab.entries, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), - rowState = tabState, - pagerState = pagerState, - withPager = true, - onTabClicked = viewModel::courseContainerTabClickedEvent + 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.courseDetails?.courseInfoOverview?.org ?: "" ) - } 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 + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = viewModel.courseName ) - } - } - ) - 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) + navigation = { + if (isNavigationEnabled) { + RoundTabsBar( + items = CourseContainerTab.entries, + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 16.dp + ), + rowState = tabState, + pagerState = pagerState, + withPager = true, + onTabClicked = viewModel::courseContainerTabClickedEvent + ) + } + }, + isEnabled = CourseAccessError.NONE == accessStatus.value, + onBackClick = { + fragmentManager.popBackStack() }, - onClose = { - snackbarData.dismiss() + bodyContent = { + when (accessStatus.value) { + CourseAccessError.AUDIT_EXPIRED_NOT_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, @@ -388,12 +437,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 = { @@ -406,12 +450,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 ) @@ -422,9 +461,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 ?: "" ) } ), @@ -441,12 +480,7 @@ fun DashboardPager( CourseOfflineScreen( 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, ) @@ -455,12 +489,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 @@ -473,14 +502,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 ) }) @@ -489,9 +518,128 @@ 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.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( + accessError = accessError, + fragmentManager = fragmentManager, + ) + } + } +} + +@Composable +private fun SetupCourseAccessErrorButtons( + 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() }, + ) + } + + 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_NOT_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 f27227b8f..a743730ec 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 @@ -6,6 +6,9 @@ import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,7 +20,11 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch 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.exception.NoCachedDataException +import org.openedx.core.extension.isFalse +import org.openedx.core.extension.isTrue import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.connection.NetworkConnection @@ -45,7 +52,6 @@ import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.SingleEventLiveData import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager -import java.util.Date import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -53,7 +59,6 @@ class CourseContainerViewModel( val courseId: String, var courseName: String, private var resumeBlockId: String, - private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -70,6 +75,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 @@ -90,13 +99,9 @@ class CourseContainerViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private var _isSelfPaced: Boolean = true - val isSelfPaced: Boolean - get() = _isSelfPaced - - private var _organization: String = "" - val organization: String - get() = _organization + private var _courseDetails: CourseEnrollmentDetails? = null + val courseDetails: CourseEnrollmentDetails? + get() = _courseDetails private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( @@ -155,7 +160,7 @@ class CourseContainerViewModel( } } - fun preloadCourseStructure() { + fun fetchCourseDetails() { courseDashboardViewed() if (_dataReady.value != null) { return @@ -164,30 +169,51 @@ class CourseContainerViewModel( _showProgress.value = true viewModelScope.launch { try { - val courseStructure = interactor.getCourseStructure(courseId, true) - courseName = courseStructure.name - _organization = courseStructure.org - _isSelfPaced = courseStructure.isSelfPaced - loadCourseImage(courseStructure.media?.image?.large) - _dataReady.value = courseStructure.start?.let { start -> - val isReady = start < Date() - if (isReady) { + val deferredCourse = async(SupervisorJob()) { + interactor.getCourseStructure(courseId, isNeedRefresh = true) + } + val deferredEnrollment = async(SupervisorJob()) { + interactor.getEnrollmentDetails(courseId) + } + val (_, enrollment) = awaitAll(deferredCourse, deferredEnrollment) + _courseDetails = enrollment as? CourseEnrollmentDetails + _showProgress.value = false + _courseDetails?.let { courseDetails -> + courseName = courseDetails.courseInfoOverview.name + loadCourseImage(courseDetails.courseInfoOverview.media?.image?.large) + if (courseDetails.hasAccess.isFalse()) { + _dataReady.value = false + if (courseDetails.isAuditAccessExpired) { + _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 + _calendarSyncUIState.update { state -> + state.copy(isCalendarSyncEnabled = isCalendarSyncEnabled()) + } + if (resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } } - isReady - } - if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { - delay(500L) - courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } ?: 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 { - _errorMessage.value = - resourceManager.getString(CoreR.string.core_error_unknown_error) + _courseAccessStatus.value = CourseAccessError.UNKNOWN } + _showProgress.value = false } } } @@ -280,8 +306,8 @@ class CourseContainerViewModel( private fun isCalendarSyncEnabled(): Boolean { val calendarSync = corePreferences.appConfig.courseDatesCalendarSync - return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || - (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && _courseDetails?.courseInfoOverview?.isSelfPaced.isTrue()) || + (calendarSync.isInstructorPacedEnabled && _courseDetails?.courseInfoOverview?.isSelfPaced.isFalse())) } private fun courseDashboardViewed() { @@ -335,10 +361,13 @@ 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 (isSelfPaced) CourseAnalyticsKey.SELF_PACED.key + if (_courseDetails?.courseInfoOverview?.isSelfPaced.isTrue()) CourseAnalyticsKey.SELF_PACED.key else CourseAnalyticsKey.INSTRUCTOR_PACED.key ) putAll(param) 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 6d6b10af7..f1b9119ff 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 @@ -143,7 +143,7 @@ fun CourseOutlineScreen( onDownloadClick = { blocksIds -> viewModel.downloadBlocks( blocksIds = blocksIds, - fragmentManager = fragmentManager + fragmentManager = fragmentManager, ) }, onResetDatesClick = { @@ -630,7 +630,7 @@ private val mockSequentialBlock = Block( containsGatedContent = false, assignmentProgress = mockAssignmentProgress, due = Date(), - offlineDownload = OfflineDownload("fileUrl", "", 1) + offlineDownload = OfflineDownload("fileUrl", "", 1), ) private val mockCourseStructure = CourseStructure( @@ -655,5 +655,5 @@ private val mockCourseStructure = CourseStructure( media = null, certificate = null, isSelfPaced = false, - progress = Progress(1, 3) + progress = Progress(1, 3), ) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index b613bea49..9e997ed5f 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -40,6 +40,7 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter +import org.openedx.course.R as courseR import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage @@ -102,7 +103,7 @@ class CourseOutlineViewModel( when (event) { is CourseStructureUpdated -> { if (event.courseId == courseId) { - updateCourseData() + getCourseData() } } @@ -135,14 +136,21 @@ class CourseOutlineViewModel( getCourseData() } - fun updateCourseData() { - getCourseDataInternal() + override fun saveDownloadModels(folder: String, id: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly) { + if (networkConnection.isWifiConnected()) { + super.saveDownloadModels(folder, id) + } else { + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi))) + } + } + } else { + super.saveDownloadModels(folder, id) + } } fun getCourseData() { - viewModelScope.launch { - courseNotifier.send(CourseLoading(true)) - } getCourseDataInternal() } @@ -222,7 +230,6 @@ class CourseOutlineViewModel( datesBannerInfo = datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled ) - courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { _uiState.value = CourseOutlineUIState.Error if (e.isInternetError()) { @@ -272,7 +279,7 @@ class CourseOutlineViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - updateCourseData() + getCourseData() courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { 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 f8bcd7355..72e37ee5b 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 @@ -764,5 +764,5 @@ private val mockCourseStructure = CourseStructure( media = null, certificate = null, isSelfPaced = false, - progress = Progress(1, 3) + progress = Progress(1, 3), ) 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 c0b03e756..eefe590d8 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -99,4 +99,9 @@ %1$s of %2$s assignments complete + Back + Your free audit access to this course expired on %s. + 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 5f9f19756..98cf58a8b 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 @@ -25,13 +25,18 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.User 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.CourseAccessError 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.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated @@ -57,7 +62,7 @@ class CourseContainerViewModelTest { private val config = mockk() private val interactor = mockk() private val networkConnection = mockk() - private val notifier = spyk() + private val courseNotifier = spyk() private val analytics = mockk() private val corePreferences = mockk() private val mockBitmap = mockk() @@ -84,6 +89,35 @@ class CourseContainerViewModelTest { isDeepLinkEnabled = false, ) ) + private val courseDetails = CourseEnrollmentDetails( + id = "id", + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + courseAccessDetails = CourseAccessDetails( + false, + false, + false, + null, + coursewareAccess = CoursewareAccess( + false, "", "", "", + "", "" + + ) + ), + certificate = null, + enrollmentDetails = EnrollmentDetails( + null, "audit", false, Date() + ), + courseInfoOverview = CourseInfoOverview( + "Open edX Demo Course", "", "OpenedX", Date(), + "", "", null, false, null, + CourseSharingUtmParameters("", ""), + "", + ) + + ) + private val courseStructure = CourseStructure( root = "", blockData = listOf(), @@ -109,22 +143,31 @@ class CourseContainerViewModelTest { progress = null ) - private val courseStructureModel = CourseStructureModel( - root = "", - blockData = mapOf(), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = "", - startDisplay = "", - startType = "", - end = null, - coursewareAccess = null, - media = null, + private val enrollmentDetails = CourseEnrollmentDetails( + id = "", + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + courseAccessDetails = CourseAccessDetails( + false, + false, + false, + null, + CoursewareAccess( + false, "", "", "", + "", "" + ) + ), certificate = null, - isSelfPaced = false, - progress = null + enrollmentDetails = EnrollmentDetails( + null, "", false, null + ), + courseInfoOverview = CourseInfoOverview( + "Open edX Demo Course", "", "OpenedX", null, + "", "", null, false, null, + CourseSharingUtmParameters("", ""), + "", + ) ) @Before @@ -135,8 +178,9 @@ class CourseContainerViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns emptyFlow() + every { courseNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "baseUrl" + coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails every { imageProcessor.loadImage(any(), any(), any()) } returns Unit every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap } @@ -147,16 +191,15 @@ class CourseContainerViewModelTest { } @Test - fun `getCourseStructure internet connection exception`() = runTest { + fun `getCourseEnrollmentDetails internet connection exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -165,31 +208,41 @@ class CourseContainerViewModelTest { courseRouter, ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } throws UnknownHostException() - every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } throws UnknownHostException() + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } val message = viewModel.errorMessage.value assertEquals(noInternet, message) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.courseAccessStatus.value == null) } @Test - fun `getCourseStructure unknown exception`() = runTest { + fun `getCourseEnrollmentDetails unknown exception`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -198,31 +251,38 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() - every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } throws Exception() + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } - - val message = viewModel.errorMessage.value - assertEquals(somethingWrong, message) + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value == null) + assert(viewModel.courseAccessStatus.value == CourseAccessError.UNKNOWN) } @Test - fun `getCourseStructure success with internet`() = runTest { + fun `getCourseEnrollmentDetails success with internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -232,29 +292,38 @@ class CourseContainerViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - every { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } returns Unit - viewModel.preloadCourseStructure() + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseStructure(any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } - + coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.courseAccessStatus.value != null) } @Test - fun `getCourseStructure success without internet`() = runTest { + fun `getCourseEnrollmentDetails success without internet`() = runTest { val viewModel = CourseContainerViewModel( "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -263,20 +332,26 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - every { analytics.logScreenEvent(any(), any()) } returns Unit - coEvery { - courseApi.getCourseStructure(any(), any(), any(), any()) - } returns courseStructureModel - viewModel.preloadCourseStructure() + coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + every { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } returns Unit + viewModel.fetchCourseDetails() advanceUntilIdle() - - coVerify(exactly = 0) { courseApi.getCourseStructure(any(), any(), any(), any()) } - verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } + coVerify(exactly = 0) { courseApi.getEnrollmentDetails(any()) } + verify(exactly = 1) { + analytics.logScreenEvent( + CourseAnalyticsEvent.DASHBOARD.eventName, + any() + ) + } assert(viewModel.errorMessage.value == null) assert(!viewModel.refreshing.value) - assert(viewModel.dataReady.value != null) + assert(viewModel.courseAccessStatus.value != null) } @Test @@ -285,11 +360,10 @@ class CourseContainerViewModelTest { "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -298,7 +372,7 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws UnknownHostException() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() @@ -315,11 +389,10 @@ class CourseContainerViewModelTest { "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -328,7 +401,7 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws Exception() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() @@ -345,11 +418,10 @@ class CourseContainerViewModelTest { "", "", "", - "", config, interactor, resourceManager, - notifier, + courseNotifier, networkConnection, corePreferences, analytics, @@ -357,8 +429,9 @@ class CourseContainerViewModelTest { calendarSyncScheduler, courseRouter ) + coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() 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 f9392df33..a8d4466dd 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 @@ -27,11 +27,14 @@ import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.model.DateType +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseCalendarState import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess @@ -60,6 +63,7 @@ class CourseDatesViewModelTest { private val resourceManager = mockk() private val notifier = mockk() private val interactor = mockk() + private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() private val courseRouter = mockk() @@ -72,6 +76,20 @@ class CourseDatesViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val user = User( + id = 0, + username = "", + email = "", + name = "", + ) + private val appConfig = AppConfig( + CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) private val dateBlock = CourseDateBlock( complete = false, date = Date(), @@ -135,6 +153,8 @@ class CourseDatesViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong coEvery { interactor.getCourseStructure(any()) } returns courseStructure + every { corePreferences.user } returns user + every { corePreferences.appConfig } returns appConfig every { notifier.notifier } returns flowOf(CourseLoading(false)) coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit 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 a9ea6c5e9..58574b5bd 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 @@ -464,11 +464,10 @@ class CourseOutlineViewModelTest { } } viewModel.getCourseData() - viewModel.updateCourseData() advanceUntilIdle() - coVerify(exactly = 3) { interactor.getCourseStructure(any()) } - coVerify(exactly = 3) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructure(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assert(message.await() == null) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) 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 a63cbddf7..ffb1d124d 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 @@ -331,4 +331,4 @@ class CourseUnitContainerViewModelTest { coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } } -} \ No newline at end of file +} diff --git a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt index f3b6a5aee..910605415 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -17,6 +17,7 @@ import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import java.util.Date @@ -61,7 +62,10 @@ class MyCoursesScreenTest { discussionUrl = "", videoOutline = "", isSelfPaced = false - ) + ), + progress = Progress(0, 0), + courseStatus = null, + courseAssignments = null, ) //endregion @@ -81,7 +85,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -114,7 +117,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -140,7 +142,6 @@ class MyCoursesScreenTest { paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} ) } @@ -162,5 +163,4 @@ class MyCoursesScreenTest { ) } } - } 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 e6ca810a1..10fefe8f1 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -141,7 +141,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 9c6129623..ccba20242 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -168,14 +168,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 cf36699f1..b40e662f3 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -134,7 +134,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 3ab2d7555..2e7669bb1 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -143,10 +143,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 2c712bad6..d96744ff1 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/default_config/dev/config.yaml b/default_config/dev/config.yaml index df9d1f401..19e53ef73 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -81,4 +81,4 @@ REGISTRATION_ENABLED: true UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false - COURSE_DOWNLOAD_QUEUE_SCREEN: false \ No newline at end of file + COURSE_DOWNLOAD_QUEUE_SCREEN: false 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 4bf51b23b..056ce8bae 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 @@ -165,7 +165,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 d5a935df3..fd88591ca 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 @@ -131,7 +131,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 2263861bf..bacc9b3a1 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 {