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 ae4f748ff..64365ea52 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -207,7 +207,15 @@ val screenModule = module { get() ) } - viewModel { (courseId: String) -> CourseDatesViewModel(courseId, get(), get(), get()) } + viewModel { (courseId: String, isSelfPaced: Boolean) -> + CourseDatesViewModel( + courseId, + isSelfPaced, + get(), + get(), + get() + ) + } viewModel { (courseId: String, handoutsType: String) -> HandoutsViewModel( courseId, diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 091a09f42..04a056153 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -23,4 +23,6 @@ object ApiConstants { const val AUTH_TYPE_GOOGLE = "google-oauth2" const val AUTH_TYPE_FB = "facebook" const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" + + const val COURSE_KEY = "course_key" } 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 e7f97b2e6..365bfbc6e 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 @@ -68,6 +68,9 @@ interface CourseApi { @GET("/api/course_home/v1/dates/{course_id}") suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + @POST("/api/course_experience/v1/reset_course_deadlines") + suspend fun resetCourseDates(@Body courseBody: Map): ResetCourseDates + @GET("/api/mobile/v1/course_info/{course_id}/handouts") suspend fun getHandouts(@Path("course_id") courseId: String): HandoutsModel diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt index 2b01aa43a..3b31ccd77 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDates.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDates.kt @@ -1,6 +1,8 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.DatesSection import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.addDays @@ -10,22 +12,27 @@ import java.util.Date import org.openedx.core.domain.model.CourseDateBlock as DomainCourseDateBlock data class CourseDates( - @SerializedName("dates_banner_info") - val datesBannerInfo: CourseDatesBannerInfo?, @SerializedName("course_date_blocks") val courseDateBlocks: List, - @SerializedName("missed_deadlines") - val missedDeadlines: Boolean = false, - @SerializedName("missed_gated_content") - val missedGatedContent: Boolean = false, - @SerializedName("learner_is_full_access") - val learnerIsFullAccess: Boolean = false, - @SerializedName("user_timezone") - val userTimezone: String? = "", - @SerializedName("verified_upgrade_link") - val verifiedUpgradeLink: String? = "", + @SerializedName("dates_banner_info") + val datesBannerInfo: DatesBannerInfo?, + @SerializedName("has_ended") + val hasEnded: Boolean, ) { - fun getStructuredCourseDates(): LinkedHashMap> { + fun getCourseDatesResult(): CourseDatesResult { + return CourseDatesResult( + datesSection = getStructuredCourseDates(), + courseBanner = CourseDatesBannerInfo( + missedDeadlines = datesBannerInfo?.missedDeadlines ?: false, + missedGatedContent = datesBannerInfo?.missedGatedContent ?: false, + verifiedUpgradeLink = datesBannerInfo?.verifiedUpgradeLink ?: "", + contentTypeGatingEnabled = datesBannerInfo?.contentTypeGatingEnabled ?: false, + hasEnded = hasEnded, + ) + ) + } + + private fun getStructuredCourseDates(): LinkedHashMap> { val currentDate = Date() val courseDatesResponse: LinkedHashMap> = LinkedHashMap() diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt deleted file mode 100644 index f3363dfed..000000000 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.openedx.core.data.model - -import com.google.gson.annotations.SerializedName - -data class CourseDatesBannerInfo( - @SerializedName("missed_deadlines") - val missedDeadlines: Boolean = false, - @SerializedName("missed_gated_content") - val missedGatedContent: Boolean = false, - @SerializedName("verified_upgrade_link") - val verifiedUpgradeLink: String = "", - @SerializedName("content_type_gating_enabled") - val contentTypeGatingEnabled: Boolean = false, -) { - fun getCourseBannerType(): CourseBannerType = when { - upgradeToGraded() -> CourseBannerType.UPGRADE_TO_GRADED - upgradeToReset() -> CourseBannerType.UPGRADE_TO_RESET - resetDates() -> CourseBannerType.RESET_DATES - showBannerInfo() -> CourseBannerType.INFO_BANNER - else -> CourseBannerType.BLANK - } - - private fun showBannerInfo(): Boolean = missedDeadlines.not() - - private fun upgradeToGraded(): Boolean = contentTypeGatingEnabled && missedDeadlines.not() - - private fun upgradeToReset(): Boolean = - upgradeToGraded().not() && missedDeadlines && missedGatedContent - - private fun resetDates(): Boolean = - upgradeToGraded().not() && missedDeadlines && missedGatedContent.not() -} - -enum class CourseBannerType { - BLANK, INFO_BANNER, UPGRADE_TO_GRADED, UPGRADE_TO_RESET, RESET_DATES; -} diff --git a/core/src/main/java/org/openedx/core/data/model/DatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/DatesBannerInfo.kt new file mode 100644 index 000000000..639c5313b --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DatesBannerInfo.kt @@ -0,0 +1,14 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class DatesBannerInfo( + @SerializedName("missed_deadlines") + val missedDeadlines: Boolean = false, + @SerializedName("missed_gated_content") + val missedGatedContent: Boolean = false, + @SerializedName("verified_upgrade_link") + val verifiedUpgradeLink: String? = "", + @SerializedName("content_type_gating_enabled") + val contentTypeGatingEnabled: Boolean = false, +) diff --git a/core/src/main/java/org/openedx/core/data/model/ResetCourseDates.kt b/core/src/main/java/org/openedx/core/data/model/ResetCourseDates.kt new file mode 100644 index 000000000..5cf00b0d3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/ResetCourseDates.kt @@ -0,0 +1,27 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.ResetCourseDates + +data class ResetCourseDates( + @SerializedName("message") + val message: String = "", + @SerializedName("body") + val body: String = "", + @SerializedName("header") + val header: String = "", + @SerializedName("link") + val link: String = "", + @SerializedName("link_text") + val linkText: String = "", +) { + fun mapToDomain(): ResetCourseDates { + return ResetCourseDates( + message = message, + body = body, + header = header, + link = link, + linkText = linkText, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt new file mode 100644 index 000000000..ffcecd02b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt @@ -0,0 +1,61 @@ +package org.openedx.core.domain.model + +import org.openedx.core.R +import org.openedx.core.domain.model.CourseBannerType.BLANK +import org.openedx.core.domain.model.CourseBannerType.INFO_BANNER +import org.openedx.core.domain.model.CourseBannerType.RESET_DATES +import org.openedx.core.domain.model.CourseBannerType.UPGRADE_TO_GRADED +import org.openedx.core.domain.model.CourseBannerType.UPGRADE_TO_RESET + +data class CourseDatesBannerInfo( + private val missedDeadlines: Boolean, + private val missedGatedContent: Boolean, + private val verifiedUpgradeLink: String, + private val contentTypeGatingEnabled: Boolean, + private val hasEnded: Boolean +) { + val bannerType by lazy { getCourseBannerType() } + + fun isBannerAvailableForUserType(isSelfPaced: Boolean): Boolean { + if (hasEnded) return false + + val selfPacedAvailable = isSelfPaced && bannerType != BLANK + val instructorPacedAvailable = !isSelfPaced && bannerType == UPGRADE_TO_GRADED + + return selfPacedAvailable || instructorPacedAvailable + } + + private fun getCourseBannerType(): CourseBannerType = when { + canUpgradeToGraded() -> UPGRADE_TO_GRADED + canUpgradeToReset() -> UPGRADE_TO_RESET + canResetDates() -> RESET_DATES + infoBanner() -> INFO_BANNER + else -> BLANK + } + + private fun infoBanner(): Boolean = !missedDeadlines + + private fun canUpgradeToGraded(): Boolean = contentTypeGatingEnabled && !missedDeadlines + + private fun canUpgradeToReset(): Boolean = + !canUpgradeToGraded() && missedDeadlines && missedGatedContent + + private fun canResetDates(): Boolean = + !canUpgradeToGraded() && missedDeadlines && !missedGatedContent +} + +enum class CourseBannerType( + val headerResId: Int = 0, + val bodyResId: Int = 0, + val buttonResId: Int = 0 +) { + BLANK, + INFO_BANNER(bodyResId = R.string.core_dates_info_banner_body), + UPGRADE_TO_GRADED(bodyResId = R.string.core_dates_upgrade_to_graded_banner_body), + UPGRADE_TO_RESET(bodyResId = R.string.core_dates_upgrade_to_reset_banner_body), + RESET_DATES( + headerResId = R.string.core_dates_reset_dates_banner_header, + bodyResId = R.string.core_dates_reset_dates_banner_body, + buttonResId = R.string.core_dates_reset_dates_banner_button + ); +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResult.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResult.kt new file mode 100644 index 000000000..2431bc7db --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResult.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +data class CourseDatesResult( + val datesSection: LinkedHashMap>, + val courseBanner: CourseDatesBannerInfo, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/ResetCourseDates.kt b/core/src/main/java/org/openedx/core/domain/model/ResetCourseDates.kt new file mode 100644 index 000000000..60eab790c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/ResetCourseDates.kt @@ -0,0 +1,9 @@ +package org.openedx.core.domain.model + +data class ResetCourseDates( + val message: String, + val body: String, + val header: String, + val link: String, + val linkText: String, +) diff --git a/core/src/main/java/org/openedx/core/extension/IntExt.kt b/core/src/main/java/org/openedx/core/extension/IntExt.kt new file mode 100644 index 000000000..5739007f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/IntExt.kt @@ -0,0 +1,5 @@ +package org.openedx.core.extension + +fun Int.nonZero(): Int? { + return if (this != 0) this else null +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/Shape.kt b/core/src/main/java/org/openedx/core/ui/theme/Shape.kt index 0cb58604f..592a5a4fd 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Shape.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Shape.kt @@ -8,9 +8,6 @@ import androidx.compose.material.Shapes import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.geometry.* -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp data class AppShapes( @@ -22,7 +19,7 @@ data class AppShapes( val cardShape: CornerBasedShape, val screenBackgroundShapeFull: CornerBasedShape, val courseImageShape: CornerBasedShape, - val dialogShape: CornerBasedShape + val dialogShape: CornerBasedShape, ) internal val LocalShapes = staticCompositionLocalOf { @@ -39,7 +36,7 @@ internal val LocalShapes = staticCompositionLocalOf { cardShape = RoundedCornerShape(12.dp), screenBackgroundShapeFull = RoundedCornerShape(24.dp), courseImageShape = RoundedCornerShape(8.dp), - dialogShape = RoundedCornerShape(24.dp) + dialogShape = RoundedCornerShape(24.dp), ) } diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c116cbc48..4592272ff 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -93,6 +93,14 @@ %d Items Hidden + + Shift due dates + Missed some deadlines? + Don\'t worry - shift our suggested schedule to complete past due assignments without losing any progress. + We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track. + To complete graded assignments as part of this course, you can upgrade today. + You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today. + Register Sign in 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 fcad0a72f..a3157fc1e 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 @@ -2,6 +2,7 @@ package org.openedx.course.data.repository import kotlinx.coroutines.flow.map import okhttp3.ResponseBody +import org.openedx.core.ApiConstants import org.openedx.core.data.api.CourseApi import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.EnrollBody @@ -100,7 +101,10 @@ class CourseRepository( } suspend fun getCourseDates(courseId: String) = - api.getCourseDates(courseId).getStructuredCourseDates() + api.getCourseDates(courseId).getCourseDatesResult() + + suspend fun resetCourseDates(courseId: String) = + api.resetCourseDates(mapOf(ApiConstants.COURSE_KEY to courseId)).mapToDomain() suspend fun getHandouts(courseId: String) = api.getHandouts(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 e7fdc48d7..d3c640831 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 @@ -74,6 +74,8 @@ class CourseInteractor( suspend fun getCourseDates(courseId: String) = repository.getCourseDates(courseId) + suspend fun resetCourseDates(courseId: String) = repository.resetCourseDates(courseId) + suspend fun getHandouts(courseId: String) = repository.getHandouts(courseId) suspend fun getAnnouncements(courseId: String) = repository.getAnnouncements(courseId) diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 37e061722..d681de59d 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 @@ -96,7 +96,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { addFragment(CourseOutlineFragment.newInstance(viewModel.courseId, viewModel.courseName)) addFragment(CourseVideosFragment.newInstance(viewModel.courseId, viewModel.courseName)) addFragment(DiscussionTopicsFragment.newInstance(viewModel.courseId, viewModel.courseName)) - addFragment(CourseDatesFragment.newInstance(viewModel.courseId, viewModel.courseName)) + addFragment(CourseDatesFragment.newInstance(viewModel.courseId, viewModel.isSelfPaced)) addFragment(HandoutsFragment.newInstance(viewModel.courseId)) } binding.viewPager.offscreenPageLimit = adapter?.itemCount ?: 1 @@ -170,4 +170,4 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { return fragment } } -} \ No newline at end of file +} 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 393237fd2..e9256799f 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 @@ -44,6 +44,10 @@ class CourseContainerViewModel( val showProgress: LiveData get() = _showProgress + private var _isSelfPaced: Boolean = true + val isSelfPaced: Boolean + get() = _isSelfPaced + init { viewModelScope.launch { notifier.notifier.collect { event -> @@ -69,6 +73,7 @@ class CourseContainerViewModel( } val courseStructure = interactor.getCourseStructureFromCache() courseName = courseStructure.name + _isSelfPaced = courseStructure.isSelfPaced _dataReady.value = courseStructure.start?.let { start -> start < Date() } @@ -123,5 +128,4 @@ class CourseContainerViewModel( fun handoutsTabClickedEvent() { analytics.handoutsTabClickedEvent(courseId, courseName) } - -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index 53a959a9e..0f1bfd324 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt @@ -80,6 +80,8 @@ import org.koin.core.parameter.parametersOf import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.DatesSection import org.openedx.core.extension.isNotEmptyThenLet import org.openedx.core.presentation.course.CourseViewMode @@ -92,30 +94,25 @@ import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.R import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.core.R as coreR class CourseDatesFragment : Fragment() { private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getBoolean(ARG_IS_SELF_PACED, true), + ) } private val router by inject() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycle.addObserver(viewModel) - with(requireArguments()) { - viewModel.courseTitle = getString(ARG_TITLE, "") - } - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -129,16 +126,18 @@ class CourseDatesFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) - CourseDatesScreen(windowSize = windowSize, + CourseDatesScreen( + windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, refreshing = refreshing, + isSelfPaced = viewModel.isSelfPaced, hasInternetConnection = viewModel.hasInternetConnection, onReloadClick = { viewModel.getCourseDates() }, onSwipeRefresh = { - viewModel.getCourseDates() + viewModel.getCourseDates(swipeToRefresh = true) }, onItemClick = { blockId -> if (blockId.isNotEmpty()) { @@ -155,17 +154,28 @@ class CourseDatesFragment : Fragment() { } } } - }) + }, + onSyncDates = { + viewModel.resetCourseDatesBanner() + }, + ) } } } companion object { private const val ARG_COURSE_ID = "courseId" - private const val ARG_TITLE = "title" - fun newInstance(courseId: String, title: String): CourseDatesFragment { + private const val ARG_IS_SELF_PACED = "selfPaced" + fun newInstance( + courseId: String, + isSelfPaced: Boolean, + ): CourseDatesFragment { val fragment = CourseDatesFragment() - fragment.arguments = bundleOf(ARG_COURSE_ID to courseId, ARG_TITLE to title) + fragment.arguments = + bundleOf( + ARG_COURSE_ID to courseId, + ARG_IS_SELF_PACED to isSelfPaced, + ) return fragment } } @@ -178,10 +188,12 @@ internal fun CourseDatesScreen( uiState: DatesUIState?, uiMessage: UIMessage?, refreshing: Boolean, + isSelfPaced: Boolean, hasInternetConnection: Boolean, onReloadClick: () -> Unit, onSwipeRefresh: () -> Unit, onItemClick: (String) -> Unit, + onSyncDates: () -> Unit, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -226,7 +238,6 @@ internal fun CourseDatesScreen( Surface( modifier = modifierScreenWidth, color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.screenBackgroundShape ) { Box( Modifier @@ -251,8 +262,21 @@ internal fun CourseDatesScreen( .padding(horizontal = 16.dp), contentPadding = listBottomPadding ) { + val courseBanner = uiState.courseDatesResult.courseBanner + val datesSection = uiState.courseDatesResult.datesSection + + if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { + item { + CourseDatesBanner( + modifier = Modifier.padding(bottom = 16.dp), + banner = courseBanner, + resetDates = onSyncDates + ) + } + } + // Handle DatesSection.COMPLETED separately - uiState.courseDates[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> + datesSection[DatesSection.COMPLETED]?.isNotEmptyThenLet { section -> item { ExpandableView( sectionKey = DatesSection.COMPLETED, @@ -261,12 +285,12 @@ internal fun CourseDatesScreen( ) } } + // Handle other sections val sectionsKey = - uiState.courseDates.keys.minus(DatesSection.COMPLETED) - .toList() + datesSection.keys.minus(DatesSection.COMPLETED).toList() sectionsKey.forEach { sectionKey -> - uiState.courseDates[sectionKey]?.isNotEmptyThenLet { section -> + datesSection[sectionKey]?.isNotEmptyThenLet { section -> item { CourseDateBlockSection( sectionKey = sectionKey, @@ -605,14 +629,18 @@ private fun CourseDateItem( @Composable private fun CourseDatesScreenPreview() { OpenEdXTheme { - CourseDatesScreen(windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DatesUIState.Dates(mockedResponse), + CourseDatesScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, + isSelfPaced = true, hasInternetConnection = true, refreshing = false, onSwipeRefresh = {}, onReloadClick = {}, - onItemClick = {}) + onItemClick = {}, + onSyncDates = {}, + ) } } @@ -621,17 +649,29 @@ private fun CourseDatesScreenPreview() { @Composable private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { - CourseDatesScreen(windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DatesUIState.Dates(mockedResponse), + CourseDatesScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, + isSelfPaced = true, hasInternetConnection = true, refreshing = false, onSwipeRefresh = {}, onReloadClick = {}, - onItemClick = {}) + onItemClick = {}, + onSyncDates = {}, + ) } } +val mockedCourseBannerInfo = CourseDatesBannerInfo( + missedDeadlines = true, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false, +) + private val mockedResponse: LinkedHashMap> = linkedMapOf( Pair( diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index 4e6d2b6c4..d66b2366c 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -18,6 +18,7 @@ import org.openedx.course.domain.interactor.CourseInteractor class CourseDatesViewModel( val courseId: String, + val isSelfPaced: Boolean, private val interactor: CourseInteractor, private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, @@ -35,8 +36,6 @@ class CourseDatesViewModel( val updating: LiveData get() = _updating - var courseTitle = "" - val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -44,17 +43,19 @@ class CourseDatesViewModel( getCourseDates() } - fun getCourseDates() { - _uiState.value = DatesUIState.Loading + fun getCourseDates(swipeToRefresh: Boolean = false) { + if (!swipeToRefresh) { + _uiState.value = DatesUIState.Loading + } + _updating.value = swipeToRefresh loadingCourseDatesInternal() } private fun loadingCourseDatesInternal() { viewModelScope.launch { try { - _updating.value = true val datesResponse = interactor.getCourseDates(courseId = courseId) - if (datesResponse.isEmpty()) { + if (datesResponse.datesSection.isEmpty()) { _uiState.value = DatesUIState.Empty } else { _uiState.value = DatesUIState.Dates(datesResponse) @@ -72,6 +73,23 @@ class CourseDatesViewModel( } } + fun resetCourseDatesBanner() { + viewModelScope.launch { + try { + interactor.resetCourseDates(courseId = courseId) + getCourseDates() + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + } else { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + } + } + } + } + fun getVerticalBlock(blockId: String): Block? { return try { val courseStructure = interactor.getCourseStructureFromCache() diff --git a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt index a84ab2f3b..8ff75239f 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/DashboardUIState.kt @@ -1,11 +1,11 @@ package org.openedx.course.presentation.dates -import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.domain.model.DatesSection +import org.openedx.core.domain.model.CourseDatesResult sealed class DatesUIState { - data class Dates(val courseDates: LinkedHashMap>) : - DatesUIState() + data class Dates( + val courseDatesResult: CourseDatesResult, + ) : DatesUIState() object Empty : DatesUIState() object Loading : DatesUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index a791da2d7..98b463ed5 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -7,16 +7,51 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.* +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -39,15 +74,31 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.jsoup.Jsoup import org.openedx.core.BlockType -import org.openedx.core.domain.model.* +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseDatesBannerInfo +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.extension.isLinkValid +import org.openedx.core.extension.nonZero import org.openedx.core.module.db.DownloadedState -import org.openedx.core.ui.* +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R +import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.CourseOutlineFragment import subtitleFile.Caption import subtitleFile.TimedTextObject @@ -915,6 +966,64 @@ fun SubSectionUnitsList( } } +@Composable +fun CourseDatesBanner( + modifier: Modifier, + banner: CourseDatesBannerInfo, + resetDates: () -> Unit, +) { + val cardModifier = modifier + .background( + MaterialTheme.appColors.cardViewBackground, + MaterialTheme.appShapes.material.medium + ) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.material.medium + ) + .padding(16.dp) + + Column(modifier = cardModifier) { + banner.bannerType.headerResId.nonZero()?.let { + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = stringResource(id = it), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + } + + banner.bannerType.bodyResId.nonZero()?.let { + Text( + text = stringResource(id = it), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) + } + + banner.bannerType.buttonResId.nonZero()?.let { + Button( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(36.dp), + shape = MaterialTheme.appShapes.buttonShape, + onClick = resetDates, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.buttonBackground + ), + ) { + Text( + text = stringResource(id = it), + color = MaterialTheme.appColors.buttonText, + style = MaterialTheme.appTypography.labelLarge + ) + } + } + } +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -1022,6 +1131,19 @@ private fun CourseHeaderPreview() { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseDatesBannerPreview() { + OpenEdXTheme { + CourseDatesBanner( + modifier = Modifier, + banner = mockedCourseBannerInfo, + resetDates = {} + ) + } +} + private val mockCourse = EnrolledCourse( auditAccessExpires = Date(), created = "created", 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 9b918f20d..6419ed2cd 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 @@ -22,6 +22,10 @@ import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -63,12 +67,47 @@ class CourseDatesViewModelTest { listOf(dateBlock, dateBlock) ) ) + private val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = true, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = true, + ) + private val mockedCourseDatesResult = CourseDatesResult( + datesSection = mockDateBlocks, + courseBanner = mockCourseDatesBannerInfo, + ) + private val courseStructure = CourseStructure( + root = "", + blockData = listOf(), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(0), + startDisplay = "", + startType = "", + end = null, + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = true, + ) @Before fun setUp() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { interactor.getCourseStructureFromCache() } returns courseStructure } @After @@ -78,7 +117,7 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates no internet connection exception`() = runTest { - val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() advanceUntilIdle() @@ -94,7 +133,7 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates unknown exception`() = runTest { - val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws Exception() advanceUntilIdle() @@ -110,9 +149,9 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with internet`() = runTest { - val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseDates(any()) } returns mockDateBlocks + coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult advanceUntilIdle() @@ -125,9 +164,12 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with EmptyList`() = runTest { - val viewModel = CourseDatesViewModel("", interactor, networkConnection, resourceManager) + val viewModel = CourseDatesViewModel("", true, interactor, networkConnection, resourceManager) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseDates(any()) } returns linkedMapOf() + coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = mockCourseDatesBannerInfo, + ) advanceUntilIdle()