From a4854e398b66942c3ee1db9af2afaff8a1746724 Mon Sep 17 00:00:00 2001 From: Omer Habib <30689349+omerhabib26@users.noreply.github.com> Date: Wed, 7 Feb 2024 17:43:01 +0500 Subject: [PATCH] feat: added PLS banner for shift dates on Course Dashboard (#211) feat: added PLS banner for shift dates on Course Dashboard - Banner added on course dashboard if its type is RESET_DATES and course is still available. - fix failed test cases - optimise code - tab position replaced with ordinal --- .../org/openedx/core/data/api/CourseApi.kt | 3 + .../openedx/core/data/model/CourseDates.kt | 20 +-- .../core/data/model/CourseDatesBannerInfo.kt | 21 +++ .../domain/model/CourseDatesBannerInfo.kt | 6 +- core/src/main/res/values/strings.xml | 3 + .../data/repository/CourseRepository.kt | 3 + .../domain/interactor/CourseInteractor.kt | 2 + .../container/CourseContainerAdapter.kt | 24 +++- .../container/CourseContainerFragment.kt | 78 ++++++----- .../container/CourseContainerViewModel.kt | 20 ++- .../presentation/dates/CourseDatesFragment.kt | 31 ++++- .../dates/CourseDatesViewModel.kt | 4 +- .../outline/CourseOutlineFragment.kt | 130 ++++++++++++++++-- .../outline/CourseOutlineUIState.kt | 4 +- .../outline/CourseOutlineViewModel.kt | 43 +++++- .../course/presentation/ui/CourseUI.kt | 82 +++++++++-- .../res/menu/bottom_course_container_menu.xml | 2 +- .../outline/CourseOutlineViewModelTest.kt | 12 ++ 18 files changed, 395 insertions(+), 93 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt 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 365bfbc6e..64e749a26 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 @@ -71,6 +71,9 @@ interface CourseApi { @POST("/api/course_experience/v1/reset_course_deadlines") suspend fun resetCourseDates(@Body courseBody: Map): ResetCourseDates + @GET("/api/course_experience/v1/course_deadlines_info/{course_id}") + suspend fun getDatesBannerInfo(@Path("course_id") courseId: String): CourseDatesBannerInfo + @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 3b31ccd77..97fc3180f 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 @@ -17,18 +17,22 @@ data class CourseDates( @SerializedName("dates_banner_info") val datesBannerInfo: DatesBannerInfo?, @SerializedName("has_ended") - val hasEnded: Boolean, + val hasEnded: Boolean?, ) { 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, - ) + courseBanner = getDatesBannerInfo(), + ) + } + + private fun getDatesBannerInfo(): CourseDatesBannerInfo { + return CourseDatesBannerInfo( + missedDeadlines = datesBannerInfo?.missedDeadlines ?: false, + missedGatedContent = datesBannerInfo?.missedGatedContent ?: false, + verifiedUpgradeLink = datesBannerInfo?.verifiedUpgradeLink ?: "", + contentTypeGatingEnabled = datesBannerInfo?.contentTypeGatingEnabled ?: false, + hasEnded = hasEnded ?: false, ) } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt new file mode 100644 index 000000000..09e2d39cb --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesBannerInfo.kt @@ -0,0 +1,21 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseDatesBannerInfo + +data class CourseDatesBannerInfo( + @SerializedName("dates_banner_info") + val datesBannerInfo: DatesBannerInfo?, + @SerializedName("has_ended") + val hasEnded: Boolean?, +) { + fun mapToDomain(): CourseDatesBannerInfo { + return CourseDatesBannerInfo( + missedDeadlines = datesBannerInfo?.missedDeadlines ?: false, + missedGatedContent = datesBannerInfo?.missedGatedContent ?: false, + verifiedUpgradeLink = datesBannerInfo?.verifiedUpgradeLink ?: "", + contentTypeGatingEnabled = datesBannerInfo?.contentTypeGatingEnabled ?: false, + hasEnded = hasEnded ?: false, + ) + } +} 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 index ffcecd02b..3281ca045 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesBannerInfo.kt @@ -12,7 +12,7 @@ data class CourseDatesBannerInfo( private val missedGatedContent: Boolean, private val verifiedUpgradeLink: String, private val contentTypeGatingEnabled: Boolean, - private val hasEnded: Boolean + private val hasEnded: Boolean, ) { val bannerType by lazy { getCourseBannerType() } @@ -25,6 +25,10 @@ data class CourseDatesBannerInfo( return selfPacedAvailable || instructorPacedAvailable } + fun isBannerAvailableForDashboard(): Boolean { + return hasEnded.not() && bannerType == RESET_DATES + } + private fun getCourseBannerType(): CourseBannerType = when { canUpgradeToGraded() -> UPGRADE_TO_GRADED canUpgradeToReset() -> UPGRADE_TO_RESET diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index af853daf0..6b77b5b11 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -100,6 +100,9 @@ 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. + Your due dates have been successfully shifted to help you stay on track. + Your dates could not be shifted. Please try again. + View all dates 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 a3157fc1e..5ddec50c0 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 @@ -106,6 +106,9 @@ class CourseRepository( suspend fun resetCourseDates(courseId: String) = api.resetCourseDates(mapOf(ApiConstants.COURSE_KEY to courseId)).mapToDomain() + suspend fun getDatesBannerInfo(courseId: String) = + api.getDatesBannerInfo(courseId).mapToDomain() + suspend fun getHandouts(courseId: String) = api.getHandouts(courseId).mapToDomain() suspend fun getAnnouncements(courseId: String) = 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 d3c640831..8b0fb0f03 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 @@ -76,6 +76,8 @@ class CourseInteractor( suspend fun resetCourseDates(courseId: String) = repository.resetCourseDates(courseId) + suspend fun getDatesBannerInfo(courseId: String) = repository.getDatesBannerInfo(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/CourseContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt index 80a383143..cb9ca5930 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt @@ -2,16 +2,30 @@ package org.openedx.course.presentation.container import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter +import org.openedx.course.R class CourseContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - private val fragments = ArrayList() + private val fragments = HashMap() override fun getItemCount(): Int = fragments.size - override fun createFragment(position: Int): Fragment = fragments[position] + override fun createFragment(position: Int): Fragment { + val tab = CourseContainerTab.values().find { it.ordinal == position } + return fragments[tab] ?: throw IllegalStateException("Fragment not found for tab $tab") + } - fun addFragment(fragment: Fragment) { - fragments.add(fragment) + fun addFragment(tab: CourseContainerTab, fragment: Fragment) { + fragments[tab] = fragment } -} \ No newline at end of file + + fun getFragment(tab: CourseContainerTab): Fragment? = fragments[tab] +} + +enum class CourseContainerTab(val itemId: Int, val titleResId: Int) { + COURSE(itemId = R.id.course, titleResId = R.string.course_navigation_course), + VIDEOS(itemId = R.id.videos, titleResId = R.string.course_navigation_video), + DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussion), + DATES(itemId = R.id.dates, titleResId = R.string.course_navigation_dates), + HANDOUTS(itemId = R.id.resources, titleResId = R.string.course_navigation_handouts), +} 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 d681de59d..f2a27d510 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 @@ -15,12 +15,14 @@ import org.openedx.core.presentation.global.viewBinding import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.container.CourseContainerTab import org.openedx.course.presentation.dates.CourseDatesFragment import org.openedx.course.presentation.handouts.HandoutsFragment import org.openedx.course.presentation.outline.CourseOutlineFragment import org.openedx.course.presentation.ui.CourseToolbar import org.openedx.course.presentation.videos.CourseVideosFragment import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment +import org.openedx.course.presentation.container.CourseContainerTab as Tabs class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -93,11 +95,26 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { binding.viewPager.isVisible = true binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL adapter = CourseContainerAdapter(this).apply { - 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.isSelfPaced)) - addFragment(HandoutsFragment.newInstance(viewModel.courseId)) + addFragment( + Tabs.COURSE, + CourseOutlineFragment.newInstance(viewModel.courseId, viewModel.courseName) + ) + addFragment( + Tabs.VIDEOS, + CourseVideosFragment.newInstance(viewModel.courseId, viewModel.courseName) + ) + addFragment( + Tabs.DISCUSSION, + DiscussionTopicsFragment.newInstance(viewModel.courseId, viewModel.courseName) + ) + addFragment( + Tabs.DATES, + CourseDatesFragment.newInstance(viewModel.courseId, viewModel.isSelfPaced) + ) + addFragment( + Tabs.HANDOUTS, + HandoutsFragment.newInstance(viewModel.courseId) + ) } binding.viewPager.offscreenPageLimit = adapter?.itemCount ?: 1 binding.viewPager.adapter = adapter @@ -105,45 +122,18 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { if (viewModel.isCourseTopTabBarEnabled) { TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> tab.text = getString( - when (position) { - 0 -> R.string.course_navigation_course - 1 -> R.string.course_navigation_video - 2 -> R.string.course_navigation_discussion - 3 -> R.string.course_navigation_dates - else -> R.string.course_navigation_handouts - } + Tabs.values().find { it.ordinal == position }?.titleResId + ?: R.string.course_navigation_course ) }.attach() binding.tabLayout.isVisible = true } else { binding.viewPager.isUserInputEnabled = false - binding.bottomNavView.setOnItemSelectedListener { - when (it.itemId) { - R.id.outline -> { - viewModel.courseTabClickedEvent() - binding.viewPager.setCurrentItem(0, false) - } - - R.id.videos -> { - viewModel.videoTabClickedEvent() - binding.viewPager.setCurrentItem(1, false) - } - - R.id.discussions -> { - viewModel.discussionTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } - - R.id.dates -> { - viewModel.datesTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) - } - - R.id.resources -> { - viewModel.handoutsTabClickedEvent() - binding.viewPager.setCurrentItem(4, false) - } + binding.bottomNavView.setOnItemSelectedListener { menuItem -> + Tabs.values().find { menuItem.itemId == it.itemId }?.let { tab -> + viewModel.courseContainerTabClickedEvent(tab) + binding.viewPager.setCurrentItem(tab.ordinal, false) } true } @@ -155,6 +145,18 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { viewModel.updateData(withSwipeRefresh) } + fun updateCourseDates() { + adapter?.getFragment(Tabs.DATES)?.let { + (it as CourseDatesFragment).updateData() + } + } + + fun navigateToTab(tab: CourseContainerTab) { + adapter?.getFragment(tab)?.let { + binding.viewPager.setCurrentItem(tab.ordinal, true) + } + } + companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_TITLE = "title" 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 e9256799f..f0d9a9507 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 @@ -109,23 +109,33 @@ class CourseContainerViewModel( } } - fun courseTabClickedEvent() { + fun courseContainerTabClickedEvent(tab: CourseContainerTab) { + when (tab) { + CourseContainerTab.COURSE -> courseTabClickedEvent() + CourseContainerTab.VIDEOS -> videoTabClickedEvent() + CourseContainerTab.DISCUSSION -> discussionTabClickedEvent() + CourseContainerTab.DATES -> datesTabClickedEvent() + CourseContainerTab.HANDOUTS -> handoutsTabClickedEvent() + } + } + + private fun courseTabClickedEvent() { analytics.courseTabClickedEvent(courseId, courseName) } - fun videoTabClickedEvent() { + private fun videoTabClickedEvent() { analytics.videoTabClickedEvent(courseId, courseName) } - fun discussionTabClickedEvent() { + private fun discussionTabClickedEvent() { analytics.discussionTabClickedEvent(courseId, courseName) } - fun datesTabClickedEvent() { + private fun datesTabClickedEvent() { analytics.datesTabClickedEvent(courseId, courseName) } - fun handoutsTabClickedEvent() { + private fun handoutsTabClickedEvent() { analytics.handoutsTabClickedEvent(courseId, courseName) } } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt index 6fd2efc9d..98d4b4d62 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 @@ -97,7 +97,9 @@ 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.container.CourseContainerFragment import org.openedx.course.presentation.ui.CourseDatesBanner +import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.core.R as coreR class CourseDatesFragment : Fragment() { @@ -153,13 +155,22 @@ class CourseDatesFragment : Fragment() { } }, onSyncDates = { - viewModel.resetCourseDatesBanner() + viewModel.resetCourseDatesBanner { + if (it) { + (parentFragment as CourseContainerFragment) + .updateCourseStructure(false) + } + } }, ) } } } + fun updateData() { + viewModel.getCourseDates() + } + companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_IS_SELF_PACED = "selfPaced" @@ -264,11 +275,19 @@ internal fun CourseDatesScreen( if (courseBanner.isBannerAvailableForUserType(isSelfPaced)) { item { - CourseDatesBanner( - modifier = Modifier.padding(bottom = 16.dp), - banner = courseBanner, - resetDates = onSyncDates - ) + if (windowSize.isTablet) { + CourseDatesBannerTablet( + modifier = Modifier.padding(bottom = 16.dp), + banner = courseBanner, + resetDates = onSyncDates, + ) + } else { + CourseDatesBanner( + modifier = Modifier.padding(bottom = 16.dp), + banner = courseBanner, + resetDates = onSyncDates + ) + } } } 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 d66b2366c..8d643de9d 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 @@ -73,11 +73,12 @@ class CourseDatesViewModel( } } - fun resetCourseDatesBanner() { + fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) getCourseDates() + onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.value = @@ -86,6 +87,7 @@ class CourseDatesViewModel( _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) } + onResetDates(false) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index e025f083f..e760400e9 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -6,16 +6,41 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.animation.AnimatedVisibility -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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.* +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView @@ -29,6 +54,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -37,16 +63,28 @@ import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.ui.* +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment +import org.openedx.course.presentation.container.CourseContainerTab import org.openedx.course.presentation.outline.CourseOutlineFragment.Companion.getUnitBlockIcon +import org.openedx.course.presentation.ui.CourseDatesBanner +import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseExpandableChapterCard import org.openedx.course.presentation.ui.CourseImageHeader import org.openedx.course.presentation.ui.CourseSectionCard @@ -61,6 +99,8 @@ class CourseOutlineFragment : Fragment() { } private val router by inject() + private var snackBar: Snackbar? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) @@ -153,12 +193,41 @@ class CourseOutlineFragment : Fragment() { .replace(Regex("\\s"), "_"), it.id ) } + }, + onResetDatesClick = { + viewModel.resetCourseDatesBanner(onResetDates = { + (parentFragment as CourseContainerFragment).updateCourseDates() + showDatesUpdateSnackbar(it) + }) } ) } } } + override fun onDestroyView() { + snackBar?.dismiss() + super.onDestroyView() + } + + private fun showDatesUpdateSnackbar(isSuccess: Boolean) { + val message = if (isSuccess) { + getString(R.string.core_dates_shift_dates_successfully_msg) + } else { + getString(R.string.core_dates_shift_dates_unsuccessful_msg) + } + snackBar = view?.let { + Snackbar.make(it, message, Snackbar.LENGTH_LONG).apply { + if (isSuccess) { + setAction(R.string.core_dates_view_all_dates) { + (parentFragment as CourseContainerFragment).navigateToTab(CourseContainerTab.DATES) + } + } + } + } + snackBar?.show() + } + companion object { private const val ARG_COURSE_ID = "courseId" @@ -204,7 +273,8 @@ internal fun CourseOutlineScreen( onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, - onDownloadClick: (Block) -> Unit + onDownloadClick: (Block) -> Unit, + onResetDatesClick: () -> Unit, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -291,9 +361,31 @@ internal fun CourseOutlineScreen( ) } } + item { Spacer(Modifier.height(28.dp)) } + if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { + item { + Box( + modifier = Modifier + .padding(all = 8.dp) + ) { + if (windowSize.isTablet) { + CourseDatesBannerTablet( + modifier = Modifier.padding(bottom = 16.dp), + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } else { + CourseDatesBanner( + modifier = Modifier.padding(bottom = 16.dp), + banner = uiState.datesBannerInfo, + resetDates = onResetDatesClick, + ) + } + } + } + } if (uiState.resumeComponent != null) { item { - Spacer(Modifier.height(28.dp)) Box(listPadding) { if (windowSize.isTablet) { ResumeCourseTablet( @@ -503,7 +595,7 @@ private fun ResumeCourseTablet( } } OpenEdXButton( - width = Modifier.width(194.dp), + width = Modifier.width(210.dp), text = stringResource(id = org.openedx.course.R.string.course_resume), onClick = { onResumeClick(block.id) @@ -533,7 +625,14 @@ private fun CourseOutlineScreenPreview() { mockChapterBlock, mapOf(), mapOf(), - mapOf() + mapOf(), + CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ) ), apiHostUrl = "", isCourseNestedListEnabled = true, @@ -547,7 +646,8 @@ private fun CourseOutlineScreenPreview() { onSubSectionClick = {}, onResumeClick = {}, onReloadClick = {}, - onDownloadClick = {} + onDownloadClick = {}, + onResetDatesClick = {}, ) } } @@ -565,7 +665,14 @@ private fun CourseOutlineScreenTabletPreview() { mockChapterBlock, mapOf(), mapOf(), - mapOf() + mapOf(), + CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ) ), apiHostUrl = "", isCourseNestedListEnabled = true, @@ -579,7 +686,8 @@ private fun CourseOutlineScreenTabletPreview() { onSubSectionClick = {}, onResumeClick = {}, onReloadClick = {}, - onDownloadClick = {} + onDownloadClick = {}, + onResetDatesClick = {}, ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt index ac35057bb..8b29be31e 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineUIState.kt @@ -1,6 +1,7 @@ package org.openedx.course.presentation.outline import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState @@ -11,7 +12,8 @@ sealed class CourseOutlineUIState { val resumeComponent: Block?, val courseSubSections: Map>, val courseSectionsState: Map, - val subSectionsDownloadsCount: Map + val subSectionsDownloadsCount: Map, + val datesBannerInfo: CourseDatesBannerInfo, ) : CourseOutlineUIState() object Loading : CourseOutlineUIState() 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 ee3105968..361e04be0 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 @@ -13,6 +13,7 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError @@ -94,7 +95,8 @@ class CourseOutlineViewModel( resumeComponent = state.resumeComponent, courseSubSections = courseSubSections, courseSectionsState = state.courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = state.datesBannerInfo, ) } } @@ -127,7 +129,7 @@ class CourseOutlineViewModel( getCourseDataInternal() } - fun getCourseData() { + private fun getCourseData() { _uiState.value = CourseOutlineUIState.Loading getCourseDataInternal() } @@ -144,7 +146,8 @@ class CourseOutlineViewModel( resumeComponent = state.resumeComponent, courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = state.datesBannerInfo, ) courseSectionsState[blockId] ?: false @@ -165,6 +168,18 @@ class CourseOutlineViewModel( } else { CourseComponentStatus("") } + + val datesBannerInfo = if (networkConnection.isOnline()) { + interactor.getDatesBannerInfo(courseId) + } else { + CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = false + ) + } setBlocks(blocks) courseSubSections.clear() courseSubSectionUnit.clear() @@ -180,7 +195,8 @@ class CourseOutlineViewModel( resumeComponent = getResumeBlock(blocks, courseStatus.lastVisitedBlockId), courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount + subSectionsDownloadsCount = subSectionsDownloadsCount, + datesBannerInfo = datesBannerInfo, ) } catch (e: Exception) { if (e.isInternetError()) { @@ -236,6 +252,25 @@ class CourseOutlineViewModel( return resumeBlock } + fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { + viewModelScope.launch { + try { + interactor.resetCourseDates(courseId = courseId) + updateCourseData(false) + onResetDates(true) + } 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)) + } + onResetDates(false) + } + } + } + fun resumeCourseTappedEvent(blockId: String) { val currentState = uiState.value if (currentState is CourseOutlineUIState.CourseData) { 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 98b463ed5..1e6f81361 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 @@ -66,6 +66,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -87,6 +88,7 @@ import org.openedx.core.extension.nonZero import org.openedx.core.module.db.DownloadedState import org.openedx.core.ui.BackBtn import org.openedx.core.ui.IconText +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -996,6 +998,7 @@ fun CourseDatesBanner( banner.bannerType.bodyResId.nonZero()?.let { Text( + modifier = Modifier.padding(bottom = 8.dp), text = stringResource(id = it), style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textDark @@ -1003,24 +1006,66 @@ fun CourseDatesBanner( } banner.bannerType.buttonResId.nonZero()?.let { - Button( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .height(36.dp), - shape = MaterialTheme.appShapes.buttonShape, + OpenEdXButton( + text = stringResource(id = it), onClick = resetDates, - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.buttonBackground - ), - ) { + ) + } + } +} + +@Composable +fun CourseDatesBannerTablet( + 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) + + Row( + modifier = cardModifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + banner.bannerType.headerResId.nonZero()?.let { Text( + modifier = Modifier.padding(bottom = 8.dp), text = stringResource(id = it), - color = MaterialTheme.appColors.buttonText, - style = MaterialTheme.appTypography.labelLarge + 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 { + OpenEdXButton( + width = Modifier.width(210.dp), + text = stringResource(id = it), + onClick = resetDates, + ) + } } } @@ -1144,6 +1189,19 @@ private fun CourseDatesBannerPreview() { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseDatesBannerTabletPreview() { + OpenEdXTheme { + CourseDatesBannerTablet( + modifier = Modifier, + banner = mockedCourseBannerInfo, + resetDates = {} + ) + } +} + private val mockCourse = EnrolledCourse( auditAccessExpires = Date(), created = "created", diff --git a/course/src/main/res/menu/bottom_course_container_menu.xml b/course/src/main/res/menu/bottom_course_container_menu.xml index e65c6ea5e..da9eee1b9 100644 --- a/course/src/main/res/menu/bottom_course_container_menu.xml +++ b/course/src/main/res/menu/bottom_course_container_menu.xml @@ -2,7 +2,7 @@ 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 c98524c3d..e26cb019f 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 @@ -130,6 +130,14 @@ class CourseOutlineViewModelTest { isSelfPaced = false ) + private val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = true, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = true, + ) + private val downloadModel = DownloadModel( "id", "title", @@ -229,6 +237,7 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -307,6 +316,7 @@ class CourseOutlineViewModelTest { } coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -379,6 +389,7 @@ class CourseOutlineViewModelTest { coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "", @@ -410,6 +421,7 @@ class CourseOutlineViewModelTest { coEvery { workerController.saveModels(*anyVararg()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false + coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo val viewModel = CourseOutlineViewModel( "",