From daeab512837575e6535698e7748b6e05ff2c7e47 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 28 Mar 2024 23:13:21 +0200 Subject: [PATCH 01/18] feat: New header and navigation on CourseContainerFragment --- .../java/org/openedx/app/di/ScreenModule.kt | 10 +- .../org/openedx/core/ui/theme/AppColors.kt | 6 + .../java/org/openedx/core/ui/theme/Theme.kt | 12 + .../org/openedx/core/ui/theme/Colors.kt | 10 + .../container/CollapsingLayout.kt | 254 ++++++++ .../container/CourseContainerAdapter.kt | 31 - .../container/CourseContainerFragment.kt | 557 +++++++++++++++--- .../container/CourseContainerViewModel.kt | 24 +- .../presentation/container/HeaderContent.kt | 79 +++ .../presentation/container/TabContent.kt | 106 ++++ ...eDatesFragment.kt => CourseDatesScreen.kt} | 158 ----- .../dates/CourseDatesViewModel.kt | 29 +- ...{HandoutsFragment.kt => HandoutsScreen.kt} | 84 +-- ...lineFragment.kt => CourseOutlineScreen.kt} | 181 +----- .../outline/CourseOutlineViewModel.kt | 34 +- .../course/presentation/ui/CourseUI.kt | 4 +- .../container/CourseUnitContainerAdapter.kt | 4 +- .../videos/CourseVideoViewModel.kt | 29 +- .../videos/CourseVideosFragment.kt | 163 ----- .../res/layout/fragment_course_container.xml | 22 - .../res/menu/bottom_course_container_menu.xml | 34 -- .../threads/DiscussionThreadsViewModel.kt | 16 +- .../topics/DiscussionTopicsFragment.kt | 100 +--- .../topics/DiscussionTopicsViewModel.kt | 41 +- 24 files changed, 1059 insertions(+), 929 deletions(-) create mode 100644 course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt delete mode 100644 course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/container/TabContent.kt rename course/src/main/java/org/openedx/course/presentation/dates/{CourseDatesFragment.kt => CourseDatesScreen.kt} (80%) rename course/src/main/java/org/openedx/course/presentation/handouts/{HandoutsFragment.kt => HandoutsScreen.kt} (69%) rename course/src/main/java/org/openedx/course/presentation/outline/{CourseOutlineFragment.kt => CourseOutlineScreen.kt} (75%) delete mode 100644 course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt delete mode 100644 course/src/main/res/menu/bottom_course_container_menu.xml 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 4b5bf341f..495440f18 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -197,9 +197,10 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, courseTitle: String) -> CourseOutlineViewModel( courseId, + courseTitle, get(), get(), get(), @@ -236,9 +237,10 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, courseTitle: String) -> CourseVideoViewModel( courseId, + courseTitle, get(), get(), get(), @@ -277,11 +279,9 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean, enrollmentMode: String) -> + viewModel { (courseId: String, enrollmentMode: String) -> CourseDatesViewModel( courseId, - courseName, - isSelfPaced, enrollmentMode, get(), get(), diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 9db8faa60..f82b33782 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -51,6 +51,12 @@ data class AppColors( val componentHorizontalProgressCompleted: Color, val componentHorizontalProgressSelected: Color, val componentHorizontalProgressDefault: Color, + + val navigationBtnBackground: Color, + val navigationBtnUnselectedContent: Color, + val courseHomeHeaderShadePrimary: Color, + val courseHomeHeaderShadeSecondary: Color, + val courseHomeBackBtnBackground: Color ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 01ae95110..878579817 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -71,6 +71,12 @@ private val DarkColorPalette = AppColors( componentHorizontalProgressCompleted = dark_component_horizontal_progress_completed, componentHorizontalProgressSelected = dark_component_horizontal_progress_selected, componentHorizontalProgressDefault = dark_component_horizontal_progress_default, + + navigationBtnBackground = dark_navigation_btn_background, + navigationBtnUnselectedContent = dark_navigation_btn_unselected_content, + courseHomeHeaderShadePrimary = dark_course_home_header_shade_primary, + courseHomeHeaderShadeSecondary = dark_course_home_header_shade_secondary, + courseHomeBackBtnBackground = dark_course_home_back_btn_background ) private val LightColorPalette = AppColors( @@ -134,6 +140,12 @@ private val LightColorPalette = AppColors( componentHorizontalProgressCompleted = light_component_horizontal_progress_completed, componentHorizontalProgressSelected = light_component_horizontal_progress_selected, componentHorizontalProgressDefault = light_component_horizontal_progress_default, + + navigationBtnBackground = light_navigation_btn_background, + navigationBtnUnselectedContent = light_navigation_btn_unselected_content, + courseHomeHeaderShadePrimary = light_course_home_header_shade_primary, + courseHomeHeaderShadeSecondary = light_course_home_header_shade_secondary, + courseHomeBackBtnBackground = light_course_home_back_btn_background ) val MaterialTheme.appColors: AppColors diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 35d695cc1..6d04abb87 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -50,6 +50,11 @@ val light_component_horizontal_progress_completed_and_selected = Color(0xFF30a17 val light_component_horizontal_progress_completed = Color(0xFFbbe6d7) val light_component_horizontal_progress_selected = Color(0xFFF0CB00) val light_component_horizontal_progress_default = Color(0xFFD6D3D1) +val light_navigation_btn_background = Color.White +val light_navigation_btn_unselected_content = light_primary +val light_course_home_header_shade_primary = Color.White +val light_course_home_header_shade_secondary = Color(0xFFBABABA) +val light_course_home_back_btn_background = Color.Black val dark_primary = Color(0xFF5478F9) @@ -100,3 +105,8 @@ val dark_component_horizontal_progress_completed_and_selected = Color(0xFF30a171 val dark_component_horizontal_progress_completed = Color(0xFFbbe6d7) val dark_component_horizontal_progress_selected = Color(0xFFF0CB00) val dark_component_horizontal_progress_default = Color(0xFFD6D3D1) +val dark_navigation_btn_background = Color(0xFF273346) +val dark_navigation_btn_unselected_content = Color.White +val dark_course_home_header_shade_primary = Color(0xFF273346) +val dark_course_home_header_shade_secondary = Color(0xFF999999) +val dark_course_home_back_btn_background = Color.White \ No newline at end of file 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 new file mode 100644 index 000000000..3d0a1e4c0 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -0,0 +1,254 @@ +package org.openedx.course.presentation.container + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.ui.theme.appColors +import kotlin.math.roundToInt + +@Composable +internal fun CollapsingLayout( + imageUrl: String, + expandedTop: @Composable BoxScope.() -> Unit, + collapsedTop: @Composable BoxScope.() -> Unit, + navigation: @Composable BoxScope.() -> Unit, + bodyContent: @Composable BoxScope.() -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val localDensity = LocalDensity.current + var expandedTopHeight by remember { + mutableStateOf(0f) + } + var collapsedTopHeight by remember { + mutableStateOf(0f) + } + var navigationHeight by remember { + mutableStateOf(0f) + } + var offset by remember { + mutableStateOf(0f) + } + var backgroundImageHeight by remember { + mutableStateOf(0f) + } + val factor = ((-expandedTopHeight - offset - navigationHeight) / (-expandedTopHeight - navigationHeight)) + val alpha = if (factor.isNaN() || factor < 0) 0f else factor + val blurImagePadding = 40.dp + val toolbarOffset = with(localDensity) { (offset + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() } + val imageStartY = with(localDensity) { backgroundImageHeight - blurImagePadding.toPx() } * 0.5f + val imageOffsetY = -(offset + imageStartY) + val toolbarBackgroundOffset = if (toolbarOffset >= 0) { + toolbarOffset + } else { + 0 + } + val blurImageAlignment = if (toolbarOffset >= 0f) { + imageOffsetY + } else { + imageStartY + } + + fun calculateOffset(delta: Float): Offset { + val oldOffset = offset + val newOffset = + (oldOffset + delta).coerceIn(-expandedTopHeight - backgroundImageHeight + collapsedTopHeight, 0f) + offset = newOffset + return Offset(0f, newOffset - oldOffset) + } + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = + when { + available.y >= 0 -> Offset.Zero + offset == -expandedTopHeight -> Offset.Zero + else -> calculateOffset(available.y) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + when { + available.y <= 0 -> Offset.Zero + offset == 0f -> Offset.Zero + else -> calculateOffset(available.y) + } + } + } + + Box( + modifier = modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection), + ) { + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .onSizeChanged { size -> + backgroundImageHeight = size.height.toFloat() + }, + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShadePrimary) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = PixelAlignment(0f, blurImageAlignment), + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShadeSecondary) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + + Box( + modifier = Modifier + .onSizeChanged { size -> + expandedTopHeight = size.height.toFloat() + } + .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight).roundToInt()) } + .alpha(alpha), + content = expandedTop, + ) + + Row( + modifier = Modifier + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight = size.height.toFloat() + } + ) { + Icon( + modifier = Modifier + .padding(top = 12.dp) + .clip(CircleShape) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(alpha / 2)) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .padding(top = 12.dp) + .alpha(1 - alpha), + content = collapsedTop, + ) + } + + + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight + expandedTopHeight).roundToInt()) } + .onSizeChanged { size -> + navigationHeight = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier.offset { + IntOffset( + x = 0, + y = (expandedTopHeight + offset + backgroundImageHeight + navigationHeight).roundToInt() + ) + }, + content = bodyContent, + ) + } +} + +@Immutable +data class PixelAlignment( + val offsetX: Float, + val offsetY: Float +) : Alignment { + + override fun align(size: IntSize, space: IntSize, layoutDirection: LayoutDirection): IntOffset { + val centerX = (space.width - size.width).toFloat() / 2f + val centerY = (space.height - size.height).toFloat() / 2f + + val x = centerX + offsetX + val y = centerY + offsetY + + return IntOffset(x.roundToInt(), y.roundToInt()) + } +} 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 deleted file mode 100644 index defa6b8a7..000000000 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt +++ /dev/null @@ -1,31 +0,0 @@ -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 = HashMap() - - override fun getItemCount(): Int = fragments.size - - 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(tab: CourseContainerTab, fragment: Fragment) { - fragments[tab] = fragment - } - - 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_videos), - DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussions), - DATES(itemId = R.id.dates, titleResId = R.string.course_navigation_dates), - HANDOUTS(itemId = R.id.resources, titleResId = R.string.course_navigation_more), -} 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 ee4cea674..86265c662 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 @@ -3,40 +3,66 @@ package org.openedx.course.presentation.container import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator +import kotlinx.coroutines.runBlocking import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.FragmentViewType import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.presentation.CoreAnalyticsScreen +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncDialog import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType -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 +import org.openedx.course.presentation.dates.CourseDatesScreen +import org.openedx.course.presentation.dates.CourseDatesViewModel +import org.openedx.course.presentation.dates.DatesUIState +import org.openedx.course.presentation.handouts.HandoutsScreen +import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.outline.CourseOutlineScreen +import org.openedx.course.presentation.outline.CourseOutlineUIState +import org.openedx.course.presentation.outline.CourseOutlineViewModel +import org.openedx.course.presentation.ui.CourseVideosScreen +import org.openedx.course.presentation.videos.CourseVideoViewModel +import org.openedx.course.presentation.videos.CourseVideosUIState +import org.openedx.discussion.presentation.DiscussionRouter +import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen +import org.openedx.discussion.presentation.topics.DiscussionTopicsUIState +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import java.io.File class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val binding by viewBinding(FragmentCourseContainerBinding::bind) + + private val courseRouter by inject() + private val discussionRouter by inject() + private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), @@ -44,9 +70,31 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { requireArguments().getString(ARG_ENROLLMENT_MODE, "") ) } - private val router by inject() - private var adapter: CourseContainerAdapter? = null + private val discussionTopicsViewModel by viewModel { + parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + } + + private val courseDatesViewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_ENROLLMENT_MODE, "") + ) + } + + private val courseVideoViewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_TITLE, "") + ) + } + + private val courseOutlineViewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_TITLE, "") + ) + } private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -66,7 +114,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupToolbar(viewModel.courseName) + setupToolbar() if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { setUpCourseCalendar() } @@ -81,10 +129,16 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> if (isReady == true) { - setupToolbar(viewModel.courseName) - initViewPager() + setupToolbar() + + discussionTopicsViewModel.courseName = viewModel.courseName + discussionTopicsViewModel.getCourseTopics() + + courseDatesViewModel.courseName = viewModel.courseName + courseDatesViewModel.isSelfPaced = viewModel.isSelfPaced + courseDatesViewModel.updateAndFetchCalendarSyncState() } else { - router.navigateToNoAccess( + courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName ) @@ -103,81 +157,398 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } - private fun setupToolbar(courseName: String) { + @OptIn(ExperimentalFoundationApi::class) + private fun setupToolbar() { binding.toolbar.setContent { - CourseToolbar( - title = courseName, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - } - ) - } - } + OpenEdXTheme { + val pagerState = rememberPagerState(pageCount = { 5 }) + val windowSize = rememberWindowSize() + CollapsingLayout( + modifier = Modifier + .statusBarsInset() + .fillMaxWidth(), + imageUrl = viewModel.image, + expandedTop = { + ExpandedHeaderContent( + courseTitle = viewModel.courseName, + org = viewModel.org + ) + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = viewModel.courseName + ) + }, + navigation = { + Tabs(pagerState = pagerState) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + bodyContent = { + HorizontalPager( + modifier = Modifier.background(Color.White), + state = pagerState + ) { page -> + when (page) { + 0 -> { + val uiState by courseOutlineViewModel.uiState.observeAsState(CourseOutlineUIState.Loading) + val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + val refreshing by courseOutlineViewModel.isUpdating.observeAsState(false) + + CourseOutlineScreen( + windowSize = windowSize, + uiState = uiState, + apiHostUrl = courseOutlineViewModel.apiHostUrl, + isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, + isCourseBannerEnabled = courseOutlineViewModel.isCourseBannerEnabled, + uiMessage = uiMessage, + refreshing = refreshing, + onSwipeRefresh = { + courseOutlineViewModel.setIsUpdating() + updateCourseStructure(true) + }, + hasInternetConnection = courseOutlineViewModel.hasInternetConnection, + onReloadClick = { + updateCourseStructure(false) + }, + onItemClick = { block -> + courseOutlineViewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + courseRouter.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + courseId = courseOutlineViewModel.courseId, + subSectionId = block.id, + mode = CourseViewMode.FULL + ) + }, + onExpandClick = { block -> + if (courseOutlineViewModel.switchCourseSections(block.id)) { + courseOutlineViewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + } + }, + onSubSectionClick = { subSectionBlock -> + courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseOutlineViewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + requireActivity().supportFragmentManager, + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + }, + onResumeClick = { componentId -> + courseOutlineViewModel.resumeSectionBlock?.let { subSection -> + courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) + courseOutlineViewModel.resumeVerticalBlock?.let { unit -> + if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) + } else { + courseRouter.navigateToCourseSubsections( + requireActivity().supportFragmentManager, + courseId = courseOutlineViewModel.courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = componentId + ) + } + } + } + }, + onDownloadClick = { + if (courseOutlineViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + courseOutlineViewModel.getDownloadableChildren(it.id) + ?: arrayListOf() + ) + } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { + courseOutlineViewModel.removeDownloadModels(it.id) + } else { + courseOutlineViewModel.saveDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_"), it.id + ) + } + }, + onResetDatesClick = { + courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { + updateCourseDates() + }) + }, + onViewDates = { + runBlocking { + pagerState.animateScrollToPage(4) + } + }, + onCertificateClick = { + courseOutlineViewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(requireContext()).openUri(url) } + } + ) + } - private fun initViewPager() { - binding.viewPager.isVisible = true - binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - adapter = CourseContainerAdapter(this).apply { - 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.courseName, - viewModel.isSelfPaced, - viewModel.enrollmentMode, - ) - ) - addFragment( - Tabs.HANDOUTS, - HandoutsFragment.newInstance(viewModel.courseId) - ) - } - binding.viewPager.offscreenPageLimit = adapter?.itemCount ?: 1 - binding.viewPager.adapter = adapter - - if (viewModel.isCourseTopTabBarEnabled) { - TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> - tab.text = getString( - Tabs.entries.find { it.ordinal == position }?.titleResId - ?: R.string.course_navigation_course - ) - }.attach() - binding.tabLayout.isVisible = true - binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { - override fun onTabSelected(tab: TabLayout.Tab?) { - tab?.let { - viewModel.courseContainerTabClickedEvent(Tabs.entries[it.position]) - } - } + 1 -> { + val uiState by courseVideoViewModel.uiState.observeAsState(CourseVideosUIState.Loading) + val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) + val isUpdating by courseVideoViewModel.isUpdating.observeAsState(false) + val videoSettings by courseVideoViewModel.videoSettings.collectAsState() + + CourseVideosScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + courseTitle = courseVideoViewModel.courseTitle, + apiHostUrl = courseVideoViewModel.apiHostUrl, + isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, + isCourseBannerEnabled = courseVideoViewModel.isCourseBannerEnabled, + hasInternetConnection = courseVideoViewModel.hasInternetConnection, + isUpdating = isUpdating, + videoSettings = videoSettings, + onSwipeRefresh = { + courseVideoViewModel.setIsUpdating() + updateCourseStructure(true) + }, + onReloadClick = { + updateCourseStructure(false) + }, + onItemClick = { block -> + courseRouter.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + courseId = courseVideoViewModel.courseId, + subSectionId = block.id, + mode = CourseViewMode.VIDEOS + ) + }, + onExpandClick = { block -> + courseVideoViewModel.switchCourseSections(block.id) + }, + onSubSectionClick = { subSectionBlock -> + courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseVideoViewModel.sequentialClickedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = courseVideoViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.VIDEOS + ) + } + }, + onDownloadClick = { + if (courseVideoViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + courseVideoViewModel.getDownloadableChildren(it.id) ?: arrayListOf() + ) + } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { + courseVideoViewModel.removeDownloadModels(it.id) + } else { + courseVideoViewModel.saveDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_"), it.id + ) + } + }, + onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> + courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + if (isAllBlocksDownloadedOrDownloading) { + courseVideoViewModel.removeAllDownloadModels() + } else { + courseVideoViewModel.saveAllDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_") + ) + } + }, + onDownloadQueueClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseRouter.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) + } + }, + onVideoDownloadQualityClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseVideoViewModel.onChangingVideoQualityWhileDownloading() + } else { + courseRouter.navigateToVideoQuality( + requireActivity().supportFragmentManager, VideoQualityType.Download + ) + } + } + ) + } - override fun onTabUnselected(p0: TabLayout.Tab?) {} + 2 -> { + val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) + val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) + val refreshing by courseDatesViewModel.updating.observeAsState(false) + val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() + + CourseDatesScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + refreshing = refreshing, + isSelfPaced = courseDatesViewModel.isSelfPaced, + hasInternetConnection = courseDatesViewModel.hasInternetConnection, + calendarSyncUIState = calendarSyncUIState, + onReloadClick = { + courseDatesViewModel.getCourseDates() + }, + onSwipeRefresh = { + courseDatesViewModel.getCourseDates(swipeToRefresh = true) + }, + onItemClick = { block -> + if (block.blockId.isNotEmpty()) { + courseDatesViewModel.getVerticalBlock(block.blockId) + ?.let { verticalBlock -> + courseDatesViewModel.logCourseComponentTapped(true, block) + if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = courseDatesViewModel.courseId, + unitId = verticalBlock.id, + componentId = "", + mode = CourseViewMode.FULL + ) + } else { + courseDatesViewModel.getSequentialBlock(verticalBlock.id) + ?.let { sequentialBlock -> + courseRouter.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + subSectionId = sequentialBlock.id, + courseId = courseDatesViewModel.courseId, + unitId = verticalBlock.id, + mode = CourseViewMode.FULL + ) + } + } + } ?: { + courseDatesViewModel.logCourseComponentTapped(false, block) + ActionDialogFragment.newInstance( + title = getString(org.openedx.core.R.string.core_leaving_the_app), + message = getString( + org.openedx.core.R.string.core_leaving_the_app_message, + getString(org.openedx.core.R.string.platform_name) + ), + url = block.link, + source = CoreAnalyticsScreen.COURSE_DATES.screenName + ).show( + requireActivity().supportFragmentManager, + ActionDialogFragment::class.simpleName + ) + + } + } + }, + onPLSBannerViewed = { + if (isResumed) { + courseDatesViewModel.logPlsBannerViewed() + } + }, + onSyncDates = { + courseDatesViewModel.logPlsShiftButtonClicked() + courseDatesViewModel.resetCourseDatesBanner { + courseDatesViewModel.logPlsShiftDates(it) + if (it) { + updateCourseStructure(false) + } + } + }, + onCalendarSyncSwitch = { isChecked -> + courseDatesViewModel.handleCalendarSyncState(isChecked) + }, + ) + } - override fun onTabReselected(p0: TabLayout.Tab?) {} - }) - } else { - binding.viewPager.isUserInputEnabled = false - binding.bottomNavView.setOnItemSelectedListener { menuItem -> - Tabs.entries.find { menuItem.itemId == it.itemId }?.let { tab -> - viewModel.courseContainerTabClickedEvent(tab) - binding.viewPager.setCurrentItem(tab.ordinal, false) - } - true + 3 -> { + val uiState by discussionTopicsViewModel.uiState.observeAsState( + DiscussionTopicsUIState.Loading + ) + val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) + val refreshing by discussionTopicsViewModel.isUpdating.collectAsState(false) + DiscussionTopicsScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + refreshing = refreshing, + onSwipeRefresh = { + discussionTopicsViewModel.updateCourseTopics() + }, + onItemClick = { action, data, title -> + discussionTopicsViewModel.discussionClickedEvent(action, data, title) + discussionRouter.navigateToDiscussionThread( + requireActivity().supportFragmentManager, + action, + discussionTopicsViewModel.courseId, + data, + title, + FragmentViewType.FULL_CONTENT + ) + }, + onSearchClick = { + discussionRouter.navigateToSearchThread( + requireActivity().supportFragmentManager, + discussionTopicsViewModel.courseId + ) + } + ) + } + + 4 -> { + HandoutsScreen( + windowSize = windowSize, + onHandoutsClick = { + courseRouter.navigateToHandoutsWebView( + requireActivity().supportFragmentManager, + requireArguments().getString(ARG_COURSE_ID, ""), + getString(R.string.course_handouts), + HandoutsType.Handouts + ) + }, + onAnnouncementsClick = { + courseRouter.navigateToHandoutsWebView( + requireActivity().supportFragmentManager, + requireArguments().getString(ARG_COURSE_ID, ""), + getString(R.string.course_announcements), + HandoutsType.Announcements + ) + }) + } + } + } + } + ) } - binding.bottomNavView.isVisible = true } - viewModel.courseContainerTabClickedEvent(Tabs.entries[binding.viewPager.currentItem]) } private fun setUpCourseCalendar() { @@ -270,20 +641,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } - fun updateCourseStructure(withSwipeRefresh: Boolean) { + private fun updateCourseStructure(withSwipeRefresh: Boolean) { 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) - } + private fun updateCourseDates() { + courseDatesViewModel.getCourseDates() } companion object { 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 cf78bb207..19e181953 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 @@ -55,6 +55,8 @@ class CourseContainerViewModel( private val courseAnalytics: CourseAnalytics, ) : BaseViewModel() { + var org: String = "" + var image: String = "" val isCourseTopTabBarEnabled get() = config.isCourseTopTabBarEnabled() private val _dataReady = MutableLiveData() @@ -126,6 +128,8 @@ class CourseContainerViewModel( } val courseStructure = interactor.getCourseStructureFromCache() courseName = courseStructure.name + org = courseStructure.org + image = courseStructure.media?.image?.large ?: "" _isSelfPaced = courseStructure.isSelfPaced _dataReady.value = courseStructure.start?.let { start -> start < Date() @@ -162,15 +166,17 @@ class CourseContainerViewModel( } } - fun courseContainerTabClickedEvent(tab: CourseContainerTab) { - when (tab) { - CourseContainerTab.COURSE -> courseTabClickedEvent() - CourseContainerTab.VIDEOS -> videoTabClickedEvent() - CourseContainerTab.DISCUSSION -> discussionTabClickedEvent() - CourseContainerTab.DATES -> datesTabClickedEvent() - CourseContainerTab.HANDOUTS -> handoutsTabClickedEvent() - } - } + //TODO +// fun courseContainerTabClickedEvent(tab: CourseContainerTab) { +// when (tab) { +// CourseContainerTab.COURSE -> courseTabClickedEvent() +// CourseContainerTab.VIDEOS -> videoTabClickedEvent() +// CourseContainerTab.DISCUSSION -> discussionTabClickedEvent() +// CourseContainerTab.DATES -> datesTabClickedEvent() +// CourseContainerTab.HANDOUTS -> handoutsTabClickedEvent() +// } +// } + fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { val currentState = _calendarSyncUIState.value diff --git a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt new file mode 100644 index 000000000..e0cd97066 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -0,0 +1,79 @@ +package org.openedx.course.presentation.container + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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 org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography + +@Composable +internal fun ExpandedHeaderContent( + modifier: Modifier = Modifier, + org: String, + courseTitle: String +) { + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.appColors.textDark, + text = org, + style = MaterialTheme.appTypography.labelLarge + ) + Text( + modifier = Modifier + .fillMaxWidth(), + color = MaterialTheme.appColors.textDark, + text = courseTitle, + style = MaterialTheme.appTypography.titleLarge + ) + } +} + +@Composable +internal fun CollapsedHeaderContent( + modifier: Modifier = Modifier, + courseTitle: String +) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = courseTitle, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.appTypography.titleSmall, + maxLines = 1 + ) +} + +@Preview(showBackground = true, device = Devices.PIXEL) +@Composable +fun ExpandedHeaderContentPreview() { + ExpandedHeaderContent( + modifier = Modifier.fillMaxWidth(), + org = "organization", + courseTitle = "Course title" + ) +} + +@Preview(showBackground = true, device = Devices.PIXEL) +@Composable +fun CollapsedHeaderContentPreview() { + CollapsedHeaderContent( + modifier = Modifier.fillMaxWidth(), + courseTitle = "Course title" + ) +} \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt b/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt new file mode 100644 index 000000000..6dfb4ea59 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt @@ -0,0 +1,106 @@ +package org.openedx.course.presentation.container + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.TextSnippet +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.openedx.core.ui.theme.appColors +import org.openedx.course.R + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Tabs(pagerState: PagerState) { + val list = listOf( + stringResource(id = R.string.course_navigation_course) to rememberVectorPainter(Icons.Default.Home), + stringResource(id = R.string.course_navigation_videos) to rememberVectorPainter(Icons.Rounded.PlayCircleFilled), + stringResource(id = R.string.course_navigation_dates) to rememberVectorPainter(Icons.Default.CalendarMonth), + stringResource(id = R.string.course_navigation_discussions) to rememberVectorPainter(Icons.AutoMirrored.Filled.Chat), + stringResource(id = R.string.course_handouts) to rememberVectorPainter(Icons.AutoMirrored.Filled.TextSnippet) + ) + + val scope = rememberCoroutineScope() + LazyRow( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(12.dp), + ) { + itemsIndexed(list) { index, item -> + val backgroundColor = if (pagerState.currentPage == index) { + MaterialTheme.appColors.primary + } else { + MaterialTheme.appColors.navigationBtnBackground + } + val contentColor = if (pagerState.currentPage == index) { + Color.White + } else { + MaterialTheme.appColors.navigationBtnUnselectedContent + } + val border = if (isSystemInDarkTheme()) { + Modifier + } else { + Modifier.border( + 1.dp, + MaterialTheme.appColors.primary, + CircleShape + ) + } + Row( + modifier = Modifier + .height(40.dp) + .clip(CircleShape) + .background(backgroundColor) + .clickable { + scope.launch { + pagerState.animateScrollToPage(index) + } + } + then (border) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = item.second, + tint = contentColor, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = item.first, + color = contentColor + ) + } + } + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt similarity index 80% rename from course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt rename to course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 05a90b136..cf530ef22 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -1,9 +1,6 @@ package org.openedx.course.presentation.dates import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween @@ -54,9 +51,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState 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 @@ -66,9 +61,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -78,11 +71,6 @@ 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 -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -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 @@ -90,15 +78,11 @@ 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.CoreAnalyticsScreen -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog 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.appShapes @@ -108,155 +92,13 @@ import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncUIState -import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as coreR -class CourseDatesFragment : Fragment() { - - val viewModel by viewModel { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_COURSE_NAME, ""), - requireArguments().getBoolean(ARG_IS_SELF_PACED, true), - requireArguments().getString(ARG_ENROLLMENT_MODE, "") - ) - } - private val router by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - viewModel.updateAndFetchCalendarSyncState() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val refreshing by viewModel.updating.observeAsState(false) - val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() - - CourseDatesScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - refreshing = refreshing, - isSelfPaced = viewModel.isSelfPaced, - hasInternetConnection = viewModel.hasInternetConnection, - calendarSyncUIState = calendarSyncUIState, - onReloadClick = { - viewModel.getCourseDates() - }, - onSwipeRefresh = { - viewModel.getCourseDates(swipeToRefresh = true) - }, - onItemClick = { block -> - if (block.blockId.isNotEmpty()) { - viewModel.getVerticalBlock(block.blockId)?.let { verticalBlock -> - viewModel.logCourseComponentTapped(true, block) - if (viewModel.isCourseExpandableSectionsEnabled) { - router.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = verticalBlock.id, - componentId = "", - mode = CourseViewMode.FULL - ) - } else { - viewModel.getSequentialBlock(verticalBlock.id) - ?.let { sequentialBlock -> - router.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - subSectionId = sequentialBlock.id, - courseId = viewModel.courseId, - unitId = verticalBlock.id, - mode = CourseViewMode.FULL - ) - } - } - } ?: { - viewModel.logCourseComponentTapped(false, block) - ActionDialogFragment.newInstance( - title = getString(coreR.string.core_leaving_the_app), - message = getString( - coreR.string.core_leaving_the_app_message, - getString(coreR.string.platform_name) - ), - url = block.link, - source = CoreAnalyticsScreen.COURSE_DATES.screenName - ).show( - requireActivity().supportFragmentManager, - ActionDialogFragment::class.simpleName - ) - - } - } - }, - onPLSBannerViewed = { - if (isResumed) { - viewModel.logPlsBannerViewed() - } - }, - onSyncDates = { - viewModel.logPlsShiftButtonClicked() - viewModel.resetCourseDatesBanner { - viewModel.logPlsShiftDates(it) - if (it) { - (parentFragment as CourseContainerFragment) - .updateCourseStructure(false) - } - } - }, - onCalendarSyncSwitch = { isChecked -> - viewModel.handleCalendarSyncState(isChecked) - }, - ) - } - } - } - - fun updateData() { - viewModel.getCourseDates() - } - - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_COURSE_NAME = "courseName" - private const val ARG_IS_SELF_PACED = "selfPaced" - private const val ARG_ENROLLMENT_MODE = "enrollmentMode" - - fun newInstance( - courseId: String, - courseName: String, - isSelfPaced: Boolean, - enrollmentMode: String, - ): CourseDatesFragment { - val fragment = CourseDatesFragment() - fragment.arguments = - bundleOf( - ARG_COURSE_ID to courseId, - ARG_COURSE_NAME to courseName, - ARG_IS_SELF_PACED to isSelfPaced, - ARG_ENROLLMENT_MODE to enrollmentMode, - ) - return fragment - } - } -} - @OptIn(ExperimentalMaterialApi::class) @Composable internal fun CourseDatesScreen( 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 8fa8ad43e..5a9968910 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 @@ -3,14 +3,16 @@ package org.openedx.course.presentation.dates import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -37,8 +39,6 @@ import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, - val courseName: String, - val isSelfPaced: Boolean, private val enrollmentMode: String, private val notifier: CourseNotifier, private val interactor: CourseInteractor, @@ -50,13 +50,16 @@ class CourseDatesViewModel( private val config: Config, ) : BaseViewModel() { + var courseName = "" + var isSelfPaced = true + private val _uiState = MutableLiveData(DatesUIState.Loading) val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( @@ -111,11 +114,9 @@ class CourseDatesViewModel( } } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } } _updating.value = false @@ -127,15 +128,13 @@ class CourseDatesViewModel( try { interactor.resetCourseDates(courseId = courseId) getCourseDates() - _uiMessage.value = DatesShiftedSnackBar() + _uiMessage.emit(DatesShiftedSnackBar()) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg))) } onResetDates(false) } diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsFragment.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt similarity index 69% rename from course/src/main/java/org/openedx/course/presentation/handouts/HandoutsFragment.kt rename to course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt index 8f49c86c0..184031091 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsScreen.kt @@ -1,13 +1,26 @@ package org.openedx.course.presentation.handouts import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup 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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.* +import androidx.compose.material.Divider +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.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -15,75 +28,24 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource 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 -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject -import org.openedx.core.ui.* +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.presentation.CourseRouter +import org.openedx.core.ui.windowSizeValue import org.openedx.course.presentation.ui.CardArrow import org.openedx.course.R as courseR -class HandoutsFragment : Fragment() { - - private val router by inject() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - HandoutsScreen( - windowSize = windowSize, - onHandoutsClick = { - router.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(courseR.string.course_handouts), - HandoutsType.Handouts - ) - }, - onAnnouncementsClick = { - router.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(courseR.string.course_announcements), - HandoutsType.Announcements - ) - }) - } - } - } - - companion object { - private const val ARG_COURSE_ID = "argCourseId" - fun newInstance(courseId: String): HandoutsFragment { - val fragment = HandoutsFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId - ) - return fragment - } - } - -} - @Composable -private fun HandoutsScreen( +fun HandoutsScreen( windowSize: WindowSize, onHandoutsClick: () -> Unit, onAnnouncementsClick: () -> Unit, diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt similarity index 75% rename from course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt rename to course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 64ed6758e..dbc3f8c1f 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -2,9 +2,6 @@ package org.openedx.course.presentation.outline import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,16 +38,12 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.AndroidUriHandler -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -58,11 +51,6 @@ 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 -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage @@ -71,8 +59,6 @@ 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.extension.takeIfNotEmpty -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton @@ -80,16 +66,11 @@ 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.DatesShiftedSnackBar -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 @@ -97,167 +78,17 @@ import org.openedx.course.presentation.ui.CourseImageHeader import org.openedx.course.presentation.ui.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem import org.openedx.course.presentation.ui.DatesShiftedSnackBar -import java.io.File import java.util.Date -class CourseOutlineFragment : Fragment() { - - private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) - } - 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?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState(CourseOutlineUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val refreshing by viewModel.isUpdating.observeAsState(false) - - CourseOutlineScreen( - windowSize = windowSize, - uiState = uiState, - apiHostUrl = viewModel.apiHostUrl, - isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = viewModel.isCourseBannerEnabled, - uiMessage = uiMessage, - refreshing = refreshing, - onSwipeRefresh = { - viewModel.setIsUpdating() - (parentFragment as CourseContainerFragment).updateCourseStructure(true) - }, - hasInternetConnection = viewModel.hasInternetConnection, - onReloadClick = { - (parentFragment as CourseContainerFragment).updateCourseStructure(false) - }, - onItemClick = { block -> - viewModel.sequentialClickedEvent(block.blockId, block.displayName) - router.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.FULL - ) - }, - onExpandClick = { block -> - if (viewModel.switchCourseSections(block.id)) { - viewModel.sequentialClickedEvent(block.blockId, block.displayName) - } - }, - onSubSectionClick = { subSectionBlock -> - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.logUnitDetailViewedEvent(unit.blockId, unit.displayName) - router.navigateToCourseContainer( - requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.FULL - ) - } - }, - onResumeClick = { componentId -> - viewModel.resumeSectionBlock?.let { subSection -> - viewModel.resumeCourseTappedEvent(subSection.id) - viewModel.resumeVerticalBlock?.let { unit -> - if (viewModel.isCourseExpandableSectionsEnabled) { - router.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - router.navigateToCourseSubsections( - requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } - } - } - }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - router.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - viewModel.getDownloadableChildren(it.id) ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onResetDatesClick = { - viewModel.resetCourseDatesBanner(onResetDates = { - (parentFragment as CourseContainerFragment).updateCourseDates() - }) - }, - onViewDates = { - (parentFragment as CourseContainerFragment).navigateToTab(CourseContainerTab.DATES) - }, - onCertificateClick = { - viewModel.viewCertificateTappedEvent() - it.takeIfNotEmpty() - ?.let { url -> AndroidUriHandler(requireContext()).openUri(url) } - } - ) - } - } - } - - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_TITLE = "title" - fun newInstance( - courseId: String, - title: String, - ): CourseOutlineFragment { - val fragment = CourseOutlineFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId, - ARG_TITLE to title - ) - return fragment - } - - fun getUnitBlockIcon(block: Block): Int { - return when (block.type) { - BlockType.VIDEO -> org.openedx.course.R.drawable.ic_course_video - BlockType.PROBLEM -> org.openedx.course.R.drawable.ic_course_pen - BlockType.DISCUSSION -> org.openedx.course.R.drawable.ic_course_discussion - else -> org.openedx.course.R.drawable.ic_course_block - } - } +fun getUnitBlockIcon(block: Block): Int { + return when (block.type) { + BlockType.VIDEO -> org.openedx.course.R.drawable.ic_course_video + BlockType.PROBLEM -> org.openedx.course.R.drawable.ic_course_pen + BlockType.DISCUSSION -> org.openedx.course.R.drawable.ic_course_discussion + else -> org.openedx.course.R.drawable.ic_course_block } } - @OptIn(ExperimentalMaterialApi::class) @Composable internal fun CourseOutlineScreen( 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 eeabab539..1e9e64260 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 @@ -4,10 +4,12 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -38,6 +40,7 @@ import org.openedx.course.R as courseR class CourseOutlineViewModel( val courseId: String, + val courseTitle: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -66,16 +69,14 @@ class CourseOutlineViewModel( val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() private val _isUpdating = MutableLiveData() val isUpdating: LiveData get() = _isUpdating - var courseTitle = "" - var resumeSectionBlock: Block? = null private set var resumeVerticalBlock: Block? = null @@ -133,8 +134,9 @@ class CourseOutlineViewModel( if (networkConnection.isWifiConnected()) { super.saveDownloadModels(folder, id) } else { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi)) + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(courseR.string.course_can_download_only_with_wifi))) + } } } else { super.saveDownloadModels(folder, id) @@ -224,13 +226,9 @@ class CourseOutlineViewModel( ) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } _isUpdating.value = false @@ -281,15 +279,13 @@ class CourseOutlineViewModel( try { interactor.resetCourseDates(courseId = courseId) updateCourseData(false) - _uiMessage.value = DatesShiftedSnackBar() + _uiMessage.emit(DatesShiftedSnackBar()) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg))) } onResetDates(false) } 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 088f28d72..f476fa06e 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 @@ -109,7 +109,7 @@ 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 org.openedx.course.presentation.outline.getUnitBlockIcon import subtitleFile.Caption import subtitleFile.TimedTextObject import java.util.Date @@ -1020,7 +1020,7 @@ fun SubSectionUnitsList( modifier = Modifier .size(18.dp), painter = painterResource( - id = CourseOutlineFragment.getUnitBlockIcon(unit) + id = getUnitBlockIcon(unit) ), contentDescription = null, tint = MaterialTheme.appColors.textPrimary diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index 958d479c1..b175f8946 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -9,7 +9,7 @@ import org.openedx.course.presentation.unit.html.HtmlUnitFragment import org.openedx.course.presentation.unit.video.VideoUnitFragment import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment +import org.openedx.discussion.presentation.topics.DiscussionTopic class CourseUnitContainerAdapter( fragment: Fragment, @@ -57,7 +57,7 @@ class CourseUnitContainerAdapter( (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { DiscussionThreadsFragment.newInstance( - DiscussionTopicsFragment.TOPIC, + DiscussionTopic.TOPIC, viewModel.courseId, block.studentViewData?.topicId ?: "", block.displayName, diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 5d0f3996d..407c48e06 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -3,11 +3,13 @@ package org.openedx.course.presentation.videos import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -29,6 +31,7 @@ import org.openedx.course.presentation.CourseAnalytics class CourseVideoViewModel( val courseId: String, + val courseTitle: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -58,15 +61,13 @@ class CourseVideoViewModel( val uiState: LiveData get() = _uiState - var courseTitle = "" - private val _isUpdating = MutableLiveData() val isUpdating: LiveData get() = _isUpdating - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() private val _videoSettings = MutableStateFlow(VideoSettings.default) val videoSettings = _videoSettings.asStateFlow() @@ -126,8 +127,9 @@ class CourseVideoViewModel( if (networkConnection.isWifiConnected()) { super.saveDownloadModels(folder, id) } else { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi))) + } } } else { super.saveDownloadModels(folder, id) @@ -136,8 +138,9 @@ class CourseVideoViewModel( override fun saveAllDownloadModels(folder: String) { if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { - _uiMessage.value = - UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) + viewModelScope.launch { + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi))) + } return } @@ -198,9 +201,9 @@ class CourseVideoViewModel( } fun onChangingVideoQualityWhileDownloading() { - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(R.string.course_change_quality_when_downloading) - ) + viewModelScope.launch { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.course_change_quality_when_downloading))) + } } private fun sortBlocks(blocks: List): List { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt deleted file mode 100644 index aa17ad783..000000000 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt +++ /dev/null @@ -1,163 +0,0 @@ -package org.openedx.course.presentation.videos - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf -import org.openedx.core.R -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.settings.VideoQualityType -import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.container.CourseContainerFragment -import org.openedx.course.presentation.ui.CourseVideosScreen -import java.io.File - -class CourseVideosFragment : Fragment() { - - private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) - } - 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?, - savedInstanceState: Bundle?, - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState(CourseVideosUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val isUpdating by viewModel.isUpdating.observeAsState(false) - val videoSettings by viewModel.videoSettings.collectAsState() - - CourseVideosScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - courseTitle = viewModel.courseTitle, - apiHostUrl = viewModel.apiHostUrl, - isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = viewModel.isCourseBannerEnabled, - hasInternetConnection = viewModel.hasInternetConnection, - isUpdating = isUpdating, - videoSettings = videoSettings, - onSwipeRefresh = { - viewModel.setIsUpdating() - (parentFragment as CourseContainerFragment).updateCourseStructure(true) - }, - onReloadClick = { - (parentFragment as CourseContainerFragment).updateCourseStructure(false) - }, - onItemClick = { block -> - router.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.VIDEOS - ) - }, - onExpandClick = { block -> - viewModel.switchCourseSections(block.id) - }, - onSubSectionClick = { subSectionBlock -> - viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - viewModel.sequentialClickedEvent(unit.blockId, unit.displayName) - router.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = viewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS - ) - } - }, - onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - router.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - viewModel.getDownloadableChildren(it.id) ?: arrayListOf() - ) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadModels(it.id) - } else { - viewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) - if (isAllBlocksDownloadedOrDownloading) { - viewModel.removeAllDownloadModels() - } else { - viewModel.saveAllDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(R.string.app_name) - .replace(Regex("\\s"), "_") - ) - } - }, - onDownloadQueueClick = { - if (viewModel.hasDownloadModelsInQueue()) { - router.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) - } - }, - onVideoDownloadQualityClick = { - if (viewModel.hasDownloadModelsInQueue()) { - viewModel.onChangingVideoQualityWhileDownloading() - } else { - router.navigateToVideoQuality( - requireActivity().supportFragmentManager, VideoQualityType.Download - ) - } - } - ) - } - } - } - - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_TITLE = "title" - fun newInstance( - courseId: String, - title: String, - ): CourseVideosFragment { - val fragment = CourseVideosFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId, - ARG_TITLE to title - ) - return fragment - } - } -} - diff --git a/course/src/main/res/layout/fragment_course_container.xml b/course/src/main/res/layout/fragment_course_container.xml index 9990fd80d..f3186791e 100644 --- a/course/src/main/res/layout/fragment_course_container.xml +++ b/course/src/main/res/layout/fragment_course_container.xml @@ -23,28 +23,6 @@ app:tabMode="scrollable" app:tabTextAppearance="@style/TabLayoutTextAppearance" /> - - - - - - - - - - - - - - - - - diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt index 08709538b..e630f5a2f 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.SingleEventLiveData @@ -11,11 +12,10 @@ import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment +import org.openedx.discussion.presentation.topics.DiscussionTopic import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged -import kotlinx.coroutines.launch class DiscussionThreadsViewModel( private val interactor: DiscussionInteractor, @@ -101,15 +101,15 @@ class DiscussionThreadsViewModel( } lastOrderBy = orderBy when (threadType) { - DiscussionTopicsFragment.ALL_POSTS -> { + DiscussionTopic.ALL_POSTS -> { getAllThreads(orderBy) } - DiscussionTopicsFragment.FOLLOWING_POSTS -> { + DiscussionTopic.FOLLOWING_POSTS -> { getFollowingThreads(orderBy) } - DiscussionTopicsFragment.TOPIC -> { + DiscussionTopic.TOPIC -> { getThreads( topicId, orderBy @@ -129,15 +129,15 @@ class DiscussionThreadsViewModel( filter } when (threadType) { - DiscussionTopicsFragment.ALL_POSTS -> { + DiscussionTopic.ALL_POSTS -> { getAllThreads(lastOrderBy) } - DiscussionTopicsFragment.FOLLOWING_POSTS -> { + DiscussionTopic.FOLLOWING_POSTS -> { getFollowingThreads(lastOrderBy) } - DiscussionTopicsFragment.TOPIC -> { + DiscussionTopic.TOPIC -> { getThreads( topicId, lastOrderBy diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt index 7d386a2e8..72cb30571 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt @@ -1,9 +1,6 @@ package org.openedx.discussion.presentation.topics import android.content.res.Configuration -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -14,26 +11,17 @@ import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState 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.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView 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.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf -import org.openedx.core.FragmentViewType import org.openedx.core.UIMessage import org.openedx.core.ui.* import org.openedx.core.ui.theme.OpenEdXTheme @@ -41,91 +29,19 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.discussion.domain.model.Topic -import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem import org.openedx.discussion.R as discussionR -class DiscussionTopicsFragment : Fragment() { - - private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) - } - private val router by inject() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - viewModel.courseName = requireArguments().getString(ARG_COURSE_NAME, "") - viewModel.getCourseTopics() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - - val uiState by viewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() - val refreshing by viewModel.isUpdating.observeAsState(false) - DiscussionTopicsScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - refreshing = refreshing, - onSwipeRefresh = { - viewModel.updateCourseTopics() - }, - onItemClick = { action, data, title -> - viewModel.discussionClickedEvent(action, data, title) - router.navigateToDiscussionThread( - requireActivity().supportFragmentManager, - action, - viewModel.courseId, - data, - title, - FragmentViewType.FULL_CONTENT - ) - }, - onSearchClick = { - router.navigateToSearchThread( - requireActivity().supportFragmentManager, - viewModel.courseId - ) - } - ) - } - } - } - - companion object { - const val TOPIC = "Topic" - const val ALL_POSTS = "All posts" - const val FOLLOWING_POSTS = "Following" - - private const val ARG_COURSE_ID = "argCourseID" - private const val ARG_COURSE_NAME = "argCourseName" - fun newInstance( - courseId: String, - courseName: String - ): DiscussionTopicsFragment { - val fragment = DiscussionTopicsFragment() - fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId, - ARG_COURSE_NAME to courseName, - ) - return fragment - } - } +object DiscussionTopic { + const val TOPIC = "Topic" + const val ALL_POSTS = "All posts" + const val FOLLOWING_POSTS = "Following" } @OptIn(ExperimentalMaterialApi::class) @Composable -private fun DiscussionTopicsScreen( +fun DiscussionTopicsScreen( windowSize: WindowSize, uiState: DiscussionTopicsUIState, uiMessage: UIMessage?, @@ -241,7 +157,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopicsFragment.ALL_POSTS, + DiscussionTopic.ALL_POSTS, "", context.getString(discussionR.string.discussion_all_posts) ) @@ -254,7 +170,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopicsFragment.FOLLOWING_POSTS, + DiscussionTopic.FOLLOWING_POSTS, "", context.getString(discussionR.string.discussion_posts_following) ) @@ -274,7 +190,7 @@ private fun DiscussionTopicsScreen( } else { TopicItem(topic = topic, onClick = { id, title -> onItemClick( - DiscussionTopicsFragment.TOPIC, + DiscussionTopic.TOPIC, id, title ) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 9b2bafd48..efc5c05e0 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -3,17 +3,16 @@ package org.openedx.discussion.presentation.topics import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import org.openedx.core.BaseViewModel import org.openedx.core.R -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.ALL_POSTS -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.FOLLOWING_POSTS -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment.Companion.TOPIC import kotlinx.coroutines.launch class DiscussionTopicsViewModel( @@ -27,32 +26,30 @@ class DiscussionTopicsViewModel( val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _isUpdating = MutableSharedFlow() + val isUpdating: SharedFlow + get() = _isUpdating.asSharedFlow() var courseName = "" fun updateCourseTopics() { viewModelScope.launch { try { - _isUpdating.value = true + _isUpdating.emit(true) val response = interactor.getCourseTopics(courseId) _uiState.value = DiscussionTopicsUIState.Topics(response) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } - _isUpdating.value = false + _isUpdating.emit(false) } } @@ -68,11 +65,9 @@ class DiscussionTopicsViewModel( _uiState.value = DiscussionTopicsUIState.Topics(response) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } } @@ -80,15 +75,15 @@ class DiscussionTopicsViewModel( fun discussionClickedEvent(action: String, data: String, title: String) { when (action) { - ALL_POSTS -> { + DiscussionTopic.ALL_POSTS -> { analytics.discussionAllPostsClickedEvent(courseId, courseName) } - FOLLOWING_POSTS -> { + DiscussionTopic.FOLLOWING_POSTS -> { analytics.discussionFollowingClickedEvent(courseId, courseName) } - TOPIC -> { + DiscussionTopic.TOPIC -> { analytics.discussionTopicClickedEvent(courseId, courseName, data, title) } } From 1b02a480e01c2ad8a91c023947e4733cb22616c9 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 29 Mar 2024 16:05:49 +0200 Subject: [PATCH 02/18] feat: Changed logic of swipe to refresh, added blur for android <= 12, minor code refactoring --- .../java/org/openedx/app/di/ScreenModule.kt | 5 +- .../org/openedx/core/ui/theme/Colors.kt | 4 +- .../container/CollapsingLayout.kt | 140 +++- .../container/CourseContainerFragment.kt | 771 +++++++++--------- .../container/CourseContainerViewModel.kt | 10 +- .../presentation/container/HeaderContent.kt | 11 +- .../presentation/container/TabContent.kt | 11 +- .../presentation/dates/CourseDatesScreen.kt | 31 +- .../dates/CourseDatesViewModel.kt | 2 +- .../outline/CourseOutlineScreen.kt | 31 +- .../course/presentation/ui/CourseVideosUI.kt | 40 +- .../res/layout/fragment_course_container.xml | 13 +- .../topics/DiscussionTopicsFragment.kt | 55 +- .../topics/DiscussionTopicsViewModel.kt | 9 +- 14 files changed, 593 insertions(+), 540 deletions(-) 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 495440f18..7d67f9c9e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -279,9 +279,10 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, enrollmentMode: String) -> + viewModel { (courseId: String, courseName: String, enrollmentMode: String) -> CourseDatesViewModel( courseId, + courseName, enrollmentMode, get(), get(), @@ -307,7 +308,7 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { (courseId: String) -> DiscussionTopicsViewModel(get(), get(), get(), courseId) } + viewModel { (courseId: String, courseName: String) -> DiscussionTopicsViewModel(get(), get(), get(), courseId, courseName) } viewModel { (courseId: String, topicId: String, threadType: String) -> DiscussionThreadsViewModel( get(), diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 6d04abb87..a22c53ef1 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -54,7 +54,7 @@ val light_navigation_btn_background = Color.White val light_navigation_btn_unselected_content = light_primary val light_course_home_header_shade_primary = Color.White val light_course_home_header_shade_secondary = Color(0xFFBABABA) -val light_course_home_back_btn_background = Color.Black +val light_course_home_back_btn_background = Color.White val dark_primary = Color(0xFF5478F9) @@ -109,4 +109,4 @@ val dark_navigation_btn_background = Color(0xFF273346) val dark_navigation_btn_unselected_content = Color.White val dark_course_home_header_shade_primary = Color(0xFF273346) val dark_course_home_header_shade_secondary = Color(0xFF999999) -val dark_course_home_back_btn_background = Color.White \ No newline at end of file +val dark_course_home_back_btn_background = Color.Black \ No newline at end of file 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 3d0a1e4c0..abdc4dbb6 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 @@ -1,5 +1,13 @@ package org.openedx.course.presentation.container +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Picture +import android.os.Build +import android.renderscript.Allocation +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -20,7 +28,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -28,13 +36,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.draw +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset @@ -43,6 +57,8 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.appColors import kotlin.math.roundToInt @@ -58,21 +74,29 @@ internal fun CollapsingLayout( ) { val localDensity = LocalDensity.current var expandedTopHeight by remember { - mutableStateOf(0f) + mutableFloatStateOf(0f) } var collapsedTopHeight by remember { - mutableStateOf(0f) + mutableFloatStateOf(0f) } var navigationHeight by remember { - mutableStateOf(0f) + mutableFloatStateOf(0f) } var offset by remember { - mutableStateOf(0f) + mutableFloatStateOf(0f) } var backgroundImageHeight by remember { - mutableStateOf(0f) + mutableFloatStateOf(0f) } - val factor = ((-expandedTopHeight - offset - navigationHeight) / (-expandedTopHeight - navigationHeight)) + val picture = remember { Picture() } + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + val imageHeight = if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + 100 + } else { + 200 + } + val factor = (-imageHeight - offset) / -imageHeight val alpha = if (factor.isNaN() || factor < 0) 0f else factor val blurImagePadding = 40.dp val toolbarOffset = with(localDensity) { (offset + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() } @@ -88,6 +112,17 @@ internal fun CollapsingLayout( } else { imageStartY } + val backBtnStartPadding = if (!windowSize.isTablet) { + 0.dp + } else { + 60.dp + } + val imageModel = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .allowHardware(false) + .build() fun calculateOffset(delta: Float): Offset { val oldOffset = offset @@ -127,15 +162,11 @@ internal fun CollapsingLayout( AsyncImage( modifier = Modifier .fillMaxWidth() - .height(200.dp) + .height(imageHeight.dp) .onSizeChanged { size -> backgroundImageHeight = size.height.toFloat() }, - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) - .build(), + model = imageModel, contentDescription = null, contentScale = ContentScale.Crop ) @@ -145,6 +176,24 @@ internal fun CollapsingLayout( .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } .background(Color.White) .blur(100.dp) + .drawWithCache { + val width = this.size.width.toInt() + val height = this.size.height.toInt() + onDrawWithContent { + val pictureCanvas = + androidx.compose.ui.graphics.Canvas( + picture.beginRecording( + width, + height + ) + ) + draw(this, this.layoutDirection, pictureCanvas, this.size) { + this@onDrawWithContent.drawContent() + } + picture.endRecording() + drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) } + } + } ) { Box( modifier = Modifier @@ -158,11 +207,7 @@ internal fun CollapsingLayout( .fillMaxWidth() .height(blurImagePadding) .align(Alignment.TopCenter), - model = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) - .build(), + model = imageModel, contentDescription = null, contentScale = ContentScale.Crop, alignment = PixelAlignment(0f, blurImageAlignment), @@ -176,6 +221,15 @@ internal fun CollapsingLayout( ) } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && picture.width > 0 && picture.height > 0) { + LegacyBlurImage( + modifier = Modifier + .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) }, + bitmap = createBitmapFromPicture(picture), + blurRadio = 25f + ) + } + Box( modifier = Modifier .onSizeChanged { size -> @@ -195,7 +249,8 @@ internal fun CollapsingLayout( ) { Icon( modifier = Modifier - .padding(top = 12.dp) + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding) .clip(CircleShape) .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(alpha / 2)) .clickable { @@ -236,6 +291,53 @@ internal fun CollapsingLayout( } } +private fun createBitmapFromPicture(picture: Picture): Bitmap { + val bitmap = Bitmap.createBitmap( + picture.width, + picture.height, + Bitmap.Config.ARGB_8888 + ) + + val canvas = android.graphics.Canvas(bitmap) + canvas.drawColor(android.graphics.Color.WHITE) + canvas.drawPicture(picture) + return bitmap +} + +@Composable +fun BlurImage( + bitmap: Bitmap, + modifier: Modifier = Modifier.fillMaxSize(), +) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + ) +} + +@Composable +private fun LegacyBlurImage( + bitmap: Bitmap, + blurRadio: Float, + modifier: Modifier = Modifier.fillMaxSize() +) { + val renderScript = RenderScript.create(LocalContext.current) + val bitmapAlloc = Allocation.createFromBitmap(renderScript, bitmap) + ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { + setRadius(blurRadio) + setInput(bitmapAlloc) + repeat(5) { + forEach(bitmapAlloc) + } + } + bitmapAlloc.copyTo(bitmap) + renderScript.destroy() + + BlurImage(bitmap, modifier) +} + @Immutable data class PixelAlignment( val offsetX: Float, 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 86265c662..037752bb0 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 @@ -5,13 +5,19 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.AndroidUriHandler @@ -19,7 +25,9 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -32,7 +40,6 @@ import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding @@ -72,13 +79,17 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private val discussionTopicsViewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_TITLE, "") + ) } private val courseDatesViewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_ENROLLMENT_MODE, "") + requireArguments().getString(ARG_ENROLLMENT_MODE, ""), + requireArguments().getString(ARG_TITLE, "") ) } @@ -114,7 +125,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupToolbar() + setupCollapsingLayout() if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { setUpCourseCalendar() } @@ -129,12 +140,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> if (isReady == true) { - setupToolbar() - - discussionTopicsViewModel.courseName = viewModel.courseName - discussionTopicsViewModel.getCourseTopics() - - courseDatesViewModel.courseName = viewModel.courseName + setupCollapsingLayout() courseDatesViewModel.isSelfPaced = viewModel.isSelfPaced courseDatesViewModel.updateAndFetchCalendarSyncState() } else { @@ -152,401 +158,414 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { snackBar?.show() } - viewModel.showProgress.observe(viewLifecycleOwner) { - binding.progressBar.isVisible = it + lifecycleScope.launch { + viewModel.showProgress.collect { + binding.progressBar.isVisible = it + } } } - @OptIn(ExperimentalFoundationApi::class) - private fun setupToolbar() { - binding.toolbar.setContent { + private fun onRefresh(currentPage: Int) { + when(currentPage) { + 0 -> { + courseOutlineViewModel.setIsUpdating() + updateCourseStructure(true) + } + 1 -> { + courseVideoViewModel.setIsUpdating() + updateCourseStructure(true) + } + 2 -> { + courseDatesViewModel.getCourseDates(swipeToRefresh = true) + } + 3 -> { + discussionTopicsViewModel.updateCourseTopics() + } + } + } + + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) + private fun setupCollapsingLayout() { + binding.composeCollapsingLayout.setContent { OpenEdXTheme { + val refreshing by viewModel.showProgress.collectAsState(true) val pagerState = rememberPagerState(pageCount = { 5 }) + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onRefresh(pagerState.currentPage) } + ) val windowSize = rememberWindowSize() - CollapsingLayout( - modifier = Modifier - .statusBarsInset() - .fillMaxWidth(), - imageUrl = viewModel.image, - expandedTop = { - ExpandedHeaderContent( - courseTitle = viewModel.courseName, - org = viewModel.org - ) - }, - collapsedTop = { - CollapsedHeaderContent( - courseTitle = viewModel.courseName - ) - }, - navigation = { - Tabs(pagerState = pagerState) - }, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - }, - bodyContent = { - HorizontalPager( - modifier = Modifier.background(Color.White), - state = pagerState - ) { page -> - when (page) { - 0 -> { - val uiState by courseOutlineViewModel.uiState.observeAsState(CourseOutlineUIState.Loading) - val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) - val refreshing by courseOutlineViewModel.isUpdating.observeAsState(false) - - CourseOutlineScreen( - windowSize = windowSize, - uiState = uiState, - apiHostUrl = courseOutlineViewModel.apiHostUrl, - isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = courseOutlineViewModel.isCourseBannerEnabled, - uiMessage = uiMessage, - refreshing = refreshing, - onSwipeRefresh = { - courseOutlineViewModel.setIsUpdating() - updateCourseStructure(true) - }, - hasInternetConnection = courseOutlineViewModel.hasInternetConnection, - onReloadClick = { - updateCourseStructure(false) - }, - onItemClick = { block -> - courseOutlineViewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - courseRouter.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.FULL - ) - }, - onExpandClick = { block -> - if (courseOutlineViewModel.switchCourseSections(block.id)) { + Box { + CollapsingLayout( + modifier = Modifier + .fillMaxWidth() + .pullRefresh(pullRefreshState), + imageUrl = viewModel.image, + expandedTop = { + ExpandedHeaderContent( + courseTitle = viewModel.courseName, + org = viewModel.org + ) + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = viewModel.courseName + ) + }, + navigation = { + Tabs(pagerState = pagerState) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + bodyContent = { + HorizontalPager( + modifier = Modifier.background(Color.White), + state = pagerState + ) { page -> + when (page) { + 0 -> { + val uiState by courseOutlineViewModel.uiState.observeAsState(CourseOutlineUIState.Loading) + val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + + CourseOutlineScreen( + windowSize = windowSize, + uiState = uiState, + apiHostUrl = courseOutlineViewModel.apiHostUrl, + isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, + isCourseBannerEnabled = courseOutlineViewModel.isCourseBannerEnabled, + uiMessage = uiMessage, + hasInternetConnection = courseOutlineViewModel.hasInternetConnection, + onReloadClick = { + updateCourseStructure(false) + }, + onItemClick = { block -> courseOutlineViewModel.sequentialClickedEvent( block.blockId, block.displayName ) - } - }, - onSubSectionClick = { subSectionBlock -> - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( - unit.blockId, - unit.displayName - ) - courseRouter.navigateToCourseContainer( - requireActivity().supportFragmentManager, + courseRouter.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, courseId = courseOutlineViewModel.courseId, - unitId = unit.id, + subSectionId = block.id, mode = CourseViewMode.FULL ) - } - }, - onResumeClick = { componentId -> - courseOutlineViewModel.resumeSectionBlock?.let { subSection -> - courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) - courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - courseRouter.navigateToCourseSubsections( - requireActivity().supportFragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } + }, + onExpandClick = { block -> + if (courseOutlineViewModel.switchCourseSections(block.id)) { + courseOutlineViewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) } - } - }, - onDownloadClick = { - if (courseOutlineViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - courseOutlineViewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { - courseOutlineViewModel.removeDownloadModels(it.id) - } else { - courseOutlineViewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onResetDatesClick = { - courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { - updateCourseDates() - }) - }, - onViewDates = { - runBlocking { - pagerState.animateScrollToPage(4) - } - }, - onCertificateClick = { - courseOutlineViewModel.viewCertificateTappedEvent() - it.takeIfNotEmpty() - ?.let { url -> AndroidUriHandler(requireContext()).openUri(url) } - } - ) - } - - 1 -> { - val uiState by courseVideoViewModel.uiState.observeAsState(CourseVideosUIState.Loading) - val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) - val isUpdating by courseVideoViewModel.isUpdating.observeAsState(false) - val videoSettings by courseVideoViewModel.videoSettings.collectAsState() - - CourseVideosScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - courseTitle = courseVideoViewModel.courseTitle, - apiHostUrl = courseVideoViewModel.apiHostUrl, - isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = courseVideoViewModel.isCourseBannerEnabled, - hasInternetConnection = courseVideoViewModel.hasInternetConnection, - isUpdating = isUpdating, - videoSettings = videoSettings, - onSwipeRefresh = { - courseVideoViewModel.setIsUpdating() - updateCourseStructure(true) - }, - onReloadClick = { - updateCourseStructure(false) - }, - onItemClick = { block -> - courseRouter.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = courseVideoViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.VIDEOS - ) - }, - onExpandClick = { block -> - courseVideoViewModel.switchCourseSections(block.id) - }, - onSubSectionClick = { subSectionBlock -> - courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseVideoViewModel.sequentialClickedEvent( - unit.blockId, - unit.displayName - ) - courseRouter.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = courseVideoViewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS - ) - } - }, - onDownloadClick = { - if (courseVideoViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - courseVideoViewModel.getDownloadableChildren(it.id) ?: arrayListOf() - ) - } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { - courseVideoViewModel.removeDownloadModels(it.id) - } else { - courseVideoViewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) - if (isAllBlocksDownloadedOrDownloading) { - courseVideoViewModel.removeAllDownloadModels() - } else { - courseVideoViewModel.saveAllDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_") - ) - } - }, - onDownloadQueueClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseRouter.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) - } - }, - onVideoDownloadQualityClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseVideoViewModel.onChangingVideoQualityWhileDownloading() - } else { - courseRouter.navigateToVideoQuality( - requireActivity().supportFragmentManager, VideoQualityType.Download - ) - } - } - ) - } - - 2 -> { - val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) - val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) - val refreshing by courseDatesViewModel.updating.observeAsState(false) - val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() - - CourseDatesScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - refreshing = refreshing, - isSelfPaced = courseDatesViewModel.isSelfPaced, - hasInternetConnection = courseDatesViewModel.hasInternetConnection, - calendarSyncUIState = calendarSyncUIState, - onReloadClick = { - courseDatesViewModel.getCourseDates() - }, - onSwipeRefresh = { - courseDatesViewModel.getCourseDates(swipeToRefresh = true) - }, - onItemClick = { block -> - if (block.blockId.isNotEmpty()) { - courseDatesViewModel.getVerticalBlock(block.blockId) - ?.let { verticalBlock -> - courseDatesViewModel.logCourseComponentTapped(true, block) - if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { + }, + onSubSectionClick = { subSectionBlock -> + courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseOutlineViewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + requireActivity().supportFragmentManager, + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + }, + onResumeClick = { componentId -> + courseOutlineViewModel.resumeSectionBlock?.let { subSection -> + courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) + courseOutlineViewModel.resumeVerticalBlock?.let { unit -> + if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { courseRouter.navigateToCourseContainer( fm = requireActivity().supportFragmentManager, - courseId = courseDatesViewModel.courseId, - unitId = verticalBlock.id, - componentId = "", + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + componentId = componentId, mode = CourseViewMode.FULL ) } else { - courseDatesViewModel.getSequentialBlock(verticalBlock.id) - ?.let { sequentialBlock -> - courseRouter.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - subSectionId = sequentialBlock.id, - courseId = courseDatesViewModel.courseId, - unitId = verticalBlock.id, - mode = CourseViewMode.FULL - ) - } + courseRouter.navigateToCourseSubsections( + requireActivity().supportFragmentManager, + courseId = courseOutlineViewModel.courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = componentId + ) } - } ?: { - courseDatesViewModel.logCourseComponentTapped(false, block) - ActionDialogFragment.newInstance( - title = getString(org.openedx.core.R.string.core_leaving_the_app), - message = getString( - org.openedx.core.R.string.core_leaving_the_app_message, - getString(org.openedx.core.R.string.platform_name) - ), - url = block.link, - source = CoreAnalyticsScreen.COURSE_DATES.screenName - ).show( - requireActivity().supportFragmentManager, - ActionDialogFragment::class.simpleName + } + } + }, + onDownloadClick = { + if (courseOutlineViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + courseOutlineViewModel.getDownloadableChildren(it.id) + ?: arrayListOf() + ) + } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { + courseOutlineViewModel.removeDownloadModels(it.id) + } else { + courseOutlineViewModel.saveDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_"), it.id ) - } + }, + onResetDatesClick = { + courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { + updateCourseDates() + }) + }, + onViewDates = { + runBlocking { + pagerState.animateScrollToPage(4) + } + }, + onCertificateClick = { + courseOutlineViewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(requireContext()).openUri(url) } } - }, - onPLSBannerViewed = { - if (isResumed) { - courseDatesViewModel.logPlsBannerViewed() - } - }, - onSyncDates = { - courseDatesViewModel.logPlsShiftButtonClicked() - courseDatesViewModel.resetCourseDatesBanner { - courseDatesViewModel.logPlsShiftDates(it) - if (it) { - updateCourseStructure(false) + ) + } + + 1 -> { + val uiState by courseVideoViewModel.uiState.observeAsState(CourseVideosUIState.Loading) + val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) + val videoSettings by courseVideoViewModel.videoSettings.collectAsState() + + CourseVideosScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + courseTitle = courseVideoViewModel.courseTitle, + apiHostUrl = courseVideoViewModel.apiHostUrl, + isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, + isCourseBannerEnabled = courseVideoViewModel.isCourseBannerEnabled, + hasInternetConnection = courseVideoViewModel.hasInternetConnection, + videoSettings = videoSettings, + onReloadClick = { + updateCourseStructure(false) + }, + onItemClick = { block -> + courseRouter.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + courseId = courseVideoViewModel.courseId, + subSectionId = block.id, + mode = CourseViewMode.VIDEOS + ) + }, + onExpandClick = { block -> + courseVideoViewModel.switchCourseSections(block.id) + }, + onSubSectionClick = { subSectionBlock -> + courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseVideoViewModel.sequentialClickedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = courseVideoViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.VIDEOS + ) + } + }, + onDownloadClick = { + if (courseVideoViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + courseVideoViewModel.getDownloadableChildren(it.id) + ?: arrayListOf() + ) + } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { + courseVideoViewModel.removeDownloadModels(it.id) + } else { + courseVideoViewModel.saveDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_"), it.id + ) + } + }, + onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> + courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + if (isAllBlocksDownloadedOrDownloading) { + courseVideoViewModel.removeAllDownloadModels() + } else { + courseVideoViewModel.saveAllDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_") + ) + } + }, + onDownloadQueueClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseRouter.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) + } + }, + onVideoDownloadQualityClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseVideoViewModel.onChangingVideoQualityWhileDownloading() + } else { + courseRouter.navigateToVideoQuality( + requireActivity().supportFragmentManager, + VideoQualityType.Download + ) } } - }, - onCalendarSyncSwitch = { isChecked -> - courseDatesViewModel.handleCalendarSyncState(isChecked) - }, - ) - } - - 3 -> { - val uiState by discussionTopicsViewModel.uiState.observeAsState( - DiscussionTopicsUIState.Loading - ) - val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) - val refreshing by discussionTopicsViewModel.isUpdating.collectAsState(false) - DiscussionTopicsScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - refreshing = refreshing, - onSwipeRefresh = { - discussionTopicsViewModel.updateCourseTopics() - }, - onItemClick = { action, data, title -> - discussionTopicsViewModel.discussionClickedEvent(action, data, title) - discussionRouter.navigateToDiscussionThread( - requireActivity().supportFragmentManager, - action, - discussionTopicsViewModel.courseId, - data, - title, - FragmentViewType.FULL_CONTENT - ) - }, - onSearchClick = { - discussionRouter.navigateToSearchThread( - requireActivity().supportFragmentManager, - discussionTopicsViewModel.courseId - ) - } - ) - } + ) + } + + 2 -> { + val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) + val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) + val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() + + CourseDatesScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + isSelfPaced = courseDatesViewModel.isSelfPaced, + hasInternetConnection = courseDatesViewModel.hasInternetConnection, + calendarSyncUIState = calendarSyncUIState, + onReloadClick = { + courseDatesViewModel.getCourseDates() + }, + onItemClick = { block -> + if (block.blockId.isNotEmpty()) { + courseDatesViewModel.getVerticalBlock(block.blockId) + ?.let { verticalBlock -> + courseDatesViewModel.logCourseComponentTapped(true, block) + if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = requireActivity().supportFragmentManager, + courseId = courseDatesViewModel.courseId, + unitId = verticalBlock.id, + componentId = "", + mode = CourseViewMode.FULL + ) + } else { + courseDatesViewModel.getSequentialBlock(verticalBlock.id) + ?.let { sequentialBlock -> + courseRouter.navigateToCourseSubsections( + fm = requireActivity().supportFragmentManager, + subSectionId = sequentialBlock.id, + courseId = courseDatesViewModel.courseId, + unitId = verticalBlock.id, + mode = CourseViewMode.FULL + ) + } + } + } ?: { + courseDatesViewModel.logCourseComponentTapped(false, block) + ActionDialogFragment.newInstance( + title = getString(org.openedx.core.R.string.core_leaving_the_app), + message = getString( + org.openedx.core.R.string.core_leaving_the_app_message, + getString(org.openedx.core.R.string.platform_name) + ), + url = block.link, + source = CoreAnalyticsScreen.COURSE_DATES.screenName + ).show( + requireActivity().supportFragmentManager, + ActionDialogFragment::class.simpleName + ) - 4 -> { - HandoutsScreen( - windowSize = windowSize, - onHandoutsClick = { - courseRouter.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(R.string.course_handouts), - HandoutsType.Handouts - ) - }, - onAnnouncementsClick = { - courseRouter.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(R.string.course_announcements), - HandoutsType.Announcements - ) - }) + } + } + }, + onPLSBannerViewed = { + if (isResumed) { + courseDatesViewModel.logPlsBannerViewed() + } + }, + onSyncDates = { + courseDatesViewModel.logPlsShiftButtonClicked() + courseDatesViewModel.resetCourseDatesBanner { + courseDatesViewModel.logPlsShiftDates(it) + if (it) { + updateCourseStructure(false) + } + } + }, + onCalendarSyncSwitch = { isChecked -> + courseDatesViewModel.handleCalendarSyncState(isChecked) + }, + ) + } + + 3 -> { + val uiState by discussionTopicsViewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) + val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) + + DiscussionTopicsScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + + onItemClick = { action, data, title -> + discussionTopicsViewModel.discussionClickedEvent(action, data, title) + discussionRouter.navigateToDiscussionThread( + requireActivity().supportFragmentManager, + action, + discussionTopicsViewModel.courseId, + data, + title, + FragmentViewType.FULL_CONTENT + ) + }, + onSearchClick = { + discussionRouter.navigateToSearchThread( + requireActivity().supportFragmentManager, + discussionTopicsViewModel.courseId + ) + } + ) + } + + 4 -> { + HandoutsScreen( + windowSize = windowSize, + onHandoutsClick = { + courseRouter.navigateToHandoutsWebView( + requireActivity().supportFragmentManager, + requireArguments().getString(ARG_COURSE_ID, ""), + getString(R.string.course_handouts), + HandoutsType.Handouts + ) + }, + onAnnouncementsClick = { + courseRouter.navigateToHandoutsWebView( + requireActivity().supportFragmentManager, + requireArguments().getString(ARG_COURSE_ID, ""), + getString(R.string.course_announcements), + HandoutsType.Announcements + ) + }) + } } } } - } - ) + ) + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + } } } } 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 19e181953..766f7bfed 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 @@ -67,9 +67,9 @@ class CourseContainerViewModel( val errorMessage: LiveData get() = _errorMessage - private val _showProgress = MutableLiveData() - val showProgress: LiveData - get() = _showProgress + private val _showProgress = MutableStateFlow(true) + val showProgress: StateFlow = + _showProgress.asStateFlow() private var _isSelfPaced: Boolean = true val isSelfPaced: Boolean @@ -129,7 +129,9 @@ class CourseContainerViewModel( val courseStructure = interactor.getCourseStructureFromCache() courseName = courseStructure.name org = courseStructure.org - image = courseStructure.media?.image?.large ?: "" + courseStructure.media?.image?.large?.let { + image = config.getApiHostURL() + it + } _isSelfPaced = courseStructure.isSelfPaced _dataReady.value = courseStructure.start?.let { start -> start < Date() diff --git a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt index e0cd97066..51411d7e5 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -11,6 +11,8 @@ 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 org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography @@ -20,10 +22,16 @@ internal fun ExpandedHeaderContent( org: String, courseTitle: String ) { + val windowSize = rememberWindowSize() + val horizontalPadding = if (!windowSize.isTablet){ + 24.dp + } else { + 98.dp + } Column( modifier .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 8.dp) + .padding(horizontal = horizontalPadding, vertical = 8.dp) ) { Text( modifier = Modifier @@ -50,6 +58,7 @@ internal fun CollapsedHeaderContent( Text( modifier = modifier .fillMaxWidth() + .statusBarsInset() .padding(top = 4.dp), text = courseTitle, color = MaterialTheme.appColors.textDark, diff --git a/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt b/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt index 6dfb4ea59..1ff00c7b1 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.appColors import org.openedx.course.R @@ -46,14 +47,20 @@ fun Tabs(pagerState: PagerState) { stringResource(id = R.string.course_navigation_videos) to rememberVectorPainter(Icons.Rounded.PlayCircleFilled), stringResource(id = R.string.course_navigation_dates) to rememberVectorPainter(Icons.Default.CalendarMonth), stringResource(id = R.string.course_navigation_discussions) to rememberVectorPainter(Icons.AutoMirrored.Filled.Chat), - stringResource(id = R.string.course_handouts) to rememberVectorPainter(Icons.AutoMirrored.Filled.TextSnippet) + stringResource(id = R.string.course_navigation_more) to rememberVectorPainter(Icons.AutoMirrored.Filled.TextSnippet) ) val scope = rememberCoroutineScope() + val windowSize = rememberWindowSize() + val horizontalPadding = if (!windowSize.isTablet){ + 12.dp + } else { + 98.dp + } LazyRow( modifier = Modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(12.dp), + contentPadding = PaddingValues(vertical = 12.dp, horizontal = horizontalPadding), ) { itemsIndexed(list) { index, item -> val backgroundColor = if (pagerState.currentPage == index) { diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index cf530ef22..750f8837f 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -29,8 +29,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold @@ -45,9 +43,6 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -99,27 +94,21 @@ import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as coreR -@OptIn(ExperimentalMaterialApi::class) @Composable internal fun CourseDatesScreen( windowSize: WindowSize, uiState: DatesUIState, uiMessage: UIMessage?, - refreshing: Boolean, isSelfPaced: Boolean, hasInternetConnection: Boolean, calendarSyncUIState: CalendarSyncUIState, onReloadClick: () -> Unit, - onSwipeRefresh: () -> Unit, onItemClick: (CourseDateBlock) -> Unit, onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, onCalendarSyncSwitch: (Boolean) -> Unit = {}, ) { val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) } @@ -172,18 +161,8 @@ internal fun CourseDatesScreen( Box( Modifier .fillMaxWidth() - .pullRefresh(pullRefreshState) ) { when (uiState) { - is DatesUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is DatesUIState.Dates -> { LazyColumn( modifier = Modifier @@ -265,11 +244,9 @@ internal fun CourseDatesScreen( ) } } - } - PullRefreshIndicator( - refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) - ) + DatesUIState.Loading -> {} + } if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -622,12 +599,10 @@ private fun CourseDatesScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, - refreshing = false, isSelfPaced = true, hasInternetConnection = true, calendarSyncUIState = mockCalendarSyncUIState, onReloadClick = {}, - onSwipeRefresh = {}, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, @@ -645,12 +620,10 @@ private fun CourseDatesScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, - refreshing = false, isSelfPaced = true, hasInternetConnection = true, calendarSyncUIState = mockCalendarSyncUIState, onReloadClick = {}, - onSwipeRefresh = {}, onItemClick = {}, onPLSBannerViewed = {}, 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 5a9968910..f3715b549 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 @@ -39,6 +39,7 @@ import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, + var courseName: String, private val enrollmentMode: String, private val notifier: CourseNotifier, private val interactor: CourseInteractor, @@ -50,7 +51,6 @@ class CourseDatesViewModel( private val config: Config, ) : BaseViewModel() { - var courseName = "" var isSelfPaced = true private val _uiState = MutableLiveData(DatesUIState.Loading) 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 dbc3f8c1f..da8f4f01c 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 @@ -19,7 +19,6 @@ 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.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -31,9 +30,6 @@ import androidx.compose.material.SnackbarHost import androidx.compose.material.SnackbarHostState 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.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -98,10 +94,8 @@ internal fun CourseOutlineScreen( isCourseNestedListEnabled: Boolean, isCourseBannerEnabled: Boolean, uiMessage: UIMessage?, - refreshing: Boolean, hasInternetConnection: Boolean, onReloadClick: () -> Unit, - onSwipeRefresh: () -> Unit, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, @@ -112,9 +106,6 @@ internal fun CourseOutlineScreen( onCertificateClick: (String) -> Unit, ) { val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) } @@ -177,17 +168,8 @@ internal fun CourseOutlineScreen( modifier = screenWidth, color = MaterialTheme.appColors.background ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { when (uiState) { - is CourseOutlineUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is CourseOutlineUIState.CourseData -> { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -325,12 +307,9 @@ internal fun CourseOutlineScreen( } } } + + CourseOutlineUIState.Loading -> {} } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -496,9 +475,7 @@ private fun CourseOutlineScreenPreview() { isCourseNestedListEnabled = true, isCourseBannerEnabled = true, uiMessage = null, - refreshing = false, hasInternetConnection = true, - onSwipeRefresh = {}, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, @@ -538,9 +515,7 @@ private fun CourseOutlineScreenTabletPreview() { isCourseNestedListEnabled = true, isCourseBannerEnabled = true, uiMessage = null, - refreshing = false, hasInternetConnection = true, - onSwipeRefresh = {}, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, 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 8d5bc7172..a68a305ad 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 @@ -21,10 +21,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme @@ -38,9 +39,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Videocam -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -79,7 +77,6 @@ import org.openedx.course.R import org.openedx.course.presentation.videos.CourseVideosUIState import java.util.Date -@OptIn(ExperimentalMaterialApi::class) @Composable fun CourseVideosScreen( windowSize: WindowSize, @@ -89,10 +86,8 @@ fun CourseVideosScreen( apiHostUrl: String, isCourseNestedListEnabled: Boolean, isCourseBannerEnabled: Boolean, - isUpdating: Boolean, hasInternetConnection: Boolean, videoSettings: VideoSettings, - onSwipeRefresh: () -> Unit, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, @@ -103,9 +98,6 @@ fun CourseVideosScreen( onVideoDownloadQualityClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = isUpdating, onRefresh = { onSwipeRefresh() }) - var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) } @@ -169,7 +161,7 @@ fun CourseVideosScreen( modifier = screenWidth, color = MaterialTheme.appColors.background ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { Column( Modifier .fillMaxSize() @@ -177,7 +169,9 @@ fun CourseVideosScreen( when (uiState) { is CourseVideosUIState.Empty -> { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), contentAlignment = Alignment.Center ) { Text( @@ -190,15 +184,6 @@ fun CourseVideosScreen( } } - is CourseVideosUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is CourseVideosUIState.CourseData -> { LazyColumn( modifier = Modifier.fillMaxSize(), @@ -329,13 +314,10 @@ fun CourseVideosScreen( } } } + + CourseVideosUIState.Loading -> {} } } - PullRefreshIndicator( - isUpdating, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -680,9 +662,7 @@ private fun CourseVideosScreenPreview() { onExpandClick = { }, onSubSectionClick = { }, hasInternetConnection = true, - isUpdating = false, videoSettings = VideoSettings.default, - onSwipeRefresh = {}, onReloadClick = {}, onDownloadClick = {}, onDownloadAllClick = {}, @@ -710,10 +690,8 @@ private fun CourseVideosScreenEmptyPreview() { onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onSwipeRefresh = {}, onReloadClick = {}, hasInternetConnection = true, - isUpdating = false, videoSettings = VideoSettings.default, onDownloadClick = {}, onDownloadAllClick = {}, @@ -752,9 +730,7 @@ private fun CourseVideosScreenTabletPreview() { onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onSwipeRefresh = {}, onReloadClick = {}, - isUpdating = false, hasInternetConnection = true, videoSettings = VideoSettings.default, onDownloadClick = {}, diff --git a/course/src/main/res/layout/fragment_course_container.xml b/course/src/main/res/layout/fragment_course_container.xml index f3186791e..3eb159dc3 100644 --- a/course/src/main/res/layout/fragment_course_container.xml +++ b/course/src/main/res/layout/fragment_course_container.xml @@ -6,23 +6,12 @@ android:background="@color/background"> - - Unit, - onSwipeRefresh: () -> Unit, onItemClick: (String, String, String) -> Unit ) { val scaffoldState = rememberScaffoldState() val context = LocalContext.current - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) Scaffold( scaffoldState = scaffoldState, @@ -122,7 +135,7 @@ fun DiscussionTopicsScreen( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { Column( modifier = Modifier .fillMaxSize() @@ -203,21 +216,9 @@ fun DiscussionTopicsScreen( } } - DiscussionTopicsUIState.Loading -> { - Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } + DiscussionTopicsUIState.Loading -> {} } } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) } } } @@ -237,9 +238,7 @@ private fun DiscussionTopicsScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - refreshing = false, onItemClick = { _, _, _ -> }, - onSwipeRefresh = {}, onSearchClick = {} ) } @@ -254,9 +253,7 @@ private fun DiscussionTopicsScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - refreshing = false, onItemClick = { _, _, _ -> }, - onSwipeRefresh = {}, onSearchClick = {} ) } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index efc5c05e0..de2e8c27f 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -19,7 +19,8 @@ class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, - val courseId: String + val courseId: String, + val courseName: String, ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -34,7 +35,9 @@ class DiscussionTopicsViewModel( val isUpdating: SharedFlow get() = _isUpdating.asSharedFlow() - var courseName = "" + init { + getCourseTopics() + } fun updateCourseTopics() { viewModelScope.launch { @@ -53,7 +56,7 @@ class DiscussionTopicsViewModel( } } - fun getCourseTopics() { + private fun getCourseTopics() { _uiState.value = DiscussionTopicsUIState.Loading getCourseTopicsInternal() } From 378e876a710f96be13330a422d5b0aa816377506 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 1 Apr 2024 17:26:42 +0300 Subject: [PATCH 03/18] feat: removed COURSE_TOP_BAR_ENABLED and COURSE_BANNER_ENABLED feature flags --- Documentation/ConfigurationManagement.md | 2 -- .../java/org/openedx/core/config/Config.kt | 10 ------ .../container/CourseContainerFragment.kt | 3 +- .../container/CourseContainerViewModel.kt | 1 - .../outline/CourseOutlineScreen.kt | 31 +++++++------------ .../outline/CourseOutlineViewModel.kt | 8 +---- .../course/presentation/ui/CourseVideosUI.kt | 28 +++++++---------- .../videos/CourseVideoViewModel.kt | 2 -- default_config/dev/config.yaml | 2 -- default_config/prod/config.yaml | 2 -- default_config/stage/config.yaml | 2 -- 11 files changed, 25 insertions(+), 66 deletions(-) diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md index 1aed9a5ab..031a7d4ea 100644 --- a/Documentation/ConfigurationManagement.md +++ b/Documentation/ConfigurationManagement.md @@ -89,8 +89,6 @@ android: - **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. - **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. - **COURSE_NESTED_LIST_ENABLED:** Enables an alternative visual representation for the course structure. -- **COURSE_BANNER_ENABLED:** Enables the display of the course image on the Course Home screen. -- **COURSE_TOP_TAB_BAR_ENABLED:** Enables an alternative navigation on the Course Home screen. - **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. ## Future Support diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 9f626cc2e..4b40fbc29 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -107,14 +107,6 @@ class Config(context: Context) { return getBoolean(COURSE_NESTED_LIST_ENABLED, false) } - fun isCourseBannerEnabled(): Boolean { - return getBoolean(COURSE_BANNER_ENABLED, true) - } - - fun isCourseTopTabBarEnabled(): Boolean { - return getBoolean(COURSE_TOP_TAB_BAR_ENABLED, false) - } - fun isCourseUnitProgressEnabled(): Boolean { return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false) } @@ -174,8 +166,6 @@ class Config(context: Context) { private const val PROGRAM = "PROGRAM" private const val BRANCH = "BRANCH" private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" - private const val COURSE_BANNER_ENABLED = "COURSE_BANNER_ENABLED" - private const val COURSE_TOP_TAB_BAR_ENABLED = "COURSE_TOP_TAB_BAR_ENABLED" private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" private const val PLATFORM_NAME = "PLATFORM_NAME" } 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 037752bb0..fbc8d6080 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 @@ -141,6 +141,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> if (isReady == true) { setupCollapsingLayout() + courseOutlineViewModel.getCourseData() courseDatesViewModel.isSelfPaced = viewModel.isSelfPaced courseDatesViewModel.updateAndFetchCalendarSyncState() } else { @@ -233,7 +234,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { uiState = uiState, apiHostUrl = courseOutlineViewModel.apiHostUrl, isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = courseOutlineViewModel.isCourseBannerEnabled, uiMessage = uiMessage, hasInternetConnection = courseOutlineViewModel.hasInternetConnection, onReloadClick = { @@ -347,7 +347,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { courseTitle = courseVideoViewModel.courseTitle, apiHostUrl = courseVideoViewModel.apiHostUrl, isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, - isCourseBannerEnabled = courseVideoViewModel.isCourseBannerEnabled, hasInternetConnection = courseVideoViewModel.hasInternetConnection, videoSettings = videoSettings, onReloadClick = { 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 766f7bfed..f171aebe1 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 @@ -57,7 +57,6 @@ class CourseContainerViewModel( var org: String = "" var image: String = "" - val isCourseTopTabBarEnabled get() = config.isCourseTopTabBarEnabled() private val _dataReady = MutableLiveData() val dataReady: LiveData 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 da8f4f01c..82ec471f8 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 @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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 @@ -85,14 +84,12 @@ fun getUnitBlockIcon(block: Block): Int { } } -@OptIn(ExperimentalMaterialApi::class) @Composable internal fun CourseOutlineScreen( windowSize: WindowSize, uiState: CourseOutlineUIState, apiHostUrl: String, isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, uiMessage: UIMessage?, hasInternetConnection: Boolean, onReloadClick: () -> Unit, @@ -175,20 +172,18 @@ internal fun CourseOutlineScreen( modifier = Modifier.fillMaxSize(), contentPadding = listBottomPadding ) { - if (isCourseBannerEnabled) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - onCertificateClick = onCertificateClick, - courseName = uiState.courseStructure.name - ) - } + item { + CourseImageHeader( + modifier = Modifier + .aspectRatio(1.86f) + .padding(6.dp), + apiHostUrl = apiHostUrl, + courseImage = uiState.courseStructure.media?.image?.large + ?: "", + courseCertificate = uiState.courseStructure.certificate, + onCertificateClick = onCertificateClick, + courseName = uiState.courseStructure.name + ) } item { Spacer(Modifier.height(28.dp)) } if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { @@ -473,7 +468,6 @@ private fun CourseOutlineScreenPreview() { ), apiHostUrl = "", isCourseNestedListEnabled = true, - isCourseBannerEnabled = true, uiMessage = null, hasInternetConnection = true, onItemClick = {}, @@ -513,7 +507,6 @@ private fun CourseOutlineScreenTabletPreview() { ), apiHostUrl = "", isCourseNestedListEnabled = true, - isCourseBannerEnabled = true, uiMessage = null, hasInternetConnection = true, onItemClick = {}, 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 1e9e64260..353f75bf8 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 @@ -63,8 +63,6 @@ class CourseOutlineViewModel( val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - val isCourseBannerEnabled get() = config.isCourseBannerEnabled() - private val _uiState = MutableLiveData(CourseOutlineUIState.Loading) val uiState: LiveData get() = _uiState @@ -121,10 +119,6 @@ class CourseOutlineViewModel( } } - init { - getCourseData() - } - fun setIsUpdating() { _isUpdating.value = true } @@ -148,7 +142,7 @@ class CourseOutlineViewModel( getCourseDataInternal() } - private fun getCourseData() { + fun getCourseData() { _uiState.value = CourseOutlineUIState.Loading getCourseDataInternal() } 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 a68a305ad..a5a3f18a4 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 @@ -85,7 +85,6 @@ fun CourseVideosScreen( courseTitle: String, apiHostUrl: String, isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, hasInternetConnection: Boolean, videoSettings: VideoSettings, onItemClick: (Block) -> Unit, @@ -189,19 +188,17 @@ fun CourseVideosScreen( modifier = Modifier.fillMaxSize(), contentPadding = listBottomPadding ) { - if (isCourseBannerEnabled) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - courseName = uiState.courseStructure.name - ) - } + item { + CourseImageHeader( + modifier = Modifier + .aspectRatio(1.86f) + .padding(6.dp), + apiHostUrl = apiHostUrl, + courseImage = uiState.courseStructure.media?.image?.large + ?: "", + courseCertificate = uiState.courseStructure.certificate, + courseName = uiState.courseStructure.name + ) } if (uiState.downloadModelsSize.allCount > 0) { @@ -657,7 +654,6 @@ private fun CourseVideosScreenPreview() { courseTitle = "", apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -686,7 +682,6 @@ private fun CourseVideosScreenEmptyPreview() { courseTitle = "", apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, @@ -726,7 +721,6 @@ private fun CourseVideosScreenTabletPreview() { courseTitle = "", apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 407c48e06..57b6890f7 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -55,8 +55,6 @@ class CourseVideoViewModel( val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - val isCourseBannerEnabled get() = config.isCourseBannerEnabled() - private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 59372a8ef..199dfb754 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -77,7 +77,5 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false -COURSE_BANNER_ENABLED: true -COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 423ca0b3c..3b57535f4 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -77,6 +77,4 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false -COURSE_BANNER_ENABLED: true -COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 423ca0b3c..3b57535f4 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -77,6 +77,4 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #Course navigation feature flags COURSE_NESTED_LIST_ENABLED: false -COURSE_BANNER_ENABLED: true -COURSE_TOP_TAB_BAR_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false From 76cda81e82ef18c4c23e65191cff497cbfcaf4f0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 1 Apr 2024 19:52:42 +0300 Subject: [PATCH 04/18] feat: special header style for android < 12 --- .../container/CollapsingLayout.kt | 111 ++++++++++++------ 1 file changed, 75 insertions(+), 36 deletions(-) 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 abdc4dbb6..2c7b28396 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.draw @@ -165,17 +166,7 @@ internal fun CollapsingLayout( .height(imageHeight.dp) .onSizeChanged { size -> backgroundImageHeight = size.height.toFloat() - }, - model = imageModel, - contentDescription = null, - contentScale = ContentScale.Crop - ) - - Box( - modifier = Modifier - .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } - .background(Color.White) - .blur(100.dp) + } .drawWithCache { val width = this.size.width.toInt() val height = this.size.height.toInt() @@ -193,40 +184,90 @@ internal fun CollapsingLayout( picture.endRecording() drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) } } - } - ) { + }, + model = imageModel, + contentDescription = null, + contentScale = ContentScale.Crop + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Box( modifier = Modifier - .background(MaterialTheme.appColors.courseHomeHeaderShadePrimary) + .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShadePrimary) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + AsyncImage( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + model = imageModel, + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = PixelAlignment(0f, blurImageAlignment), + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShadeSecondary) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + } else if (picture.width > 0 && picture.height > 0) { + //TODO Change color + val backgroundColor = MaterialTheme.appColors.cardViewBackground + LegacyBlurImage( + modifier = Modifier .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) - .align(Alignment.Center) + .height(imageHeight.dp), + bitmap = createBitmapFromPicture(picture), + blurRadio = 25f ) - AsyncImage( + Box( modifier = Modifier .fillMaxWidth() - .height(blurImagePadding) - .align(Alignment.TopCenter), - model = imageModel, - contentDescription = null, - contentScale = ContentScale.Crop, - alignment = PixelAlignment(0f, blurImageAlignment), + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } ) + .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) } + .background(backgroundColor) ) + Box( modifier = Modifier - .background(MaterialTheme.appColors.courseHomeHeaderShadeSecondary) .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) - .align(Alignment.BottomCenter) + .height(imageHeight.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 500f, + endY = 400f + ) + ), ) - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S && picture.width > 0 && picture.height > 0) { - LegacyBlurImage( + Box( modifier = Modifier - .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) }, - bitmap = createBitmapFromPicture(picture), - blurRadio = 25f + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = with(localDensity) { (offset + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + } + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), ) } @@ -328,9 +369,7 @@ private fun LegacyBlurImage( ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { setRadius(blurRadio) setInput(bitmapAlloc) - repeat(5) { - forEach(bitmapAlloc) - } + forEach(bitmapAlloc) } bitmapAlloc.copyTo(bitmap) renderScript.destroy() From 9707ac81957be6375318e8d108417da5e36737e1 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 2 Apr 2024 13:35:36 +0300 Subject: [PATCH 05/18] fix: Fixed header image blur for android < 12. Added courseContainerTabClickedEvent --- .../org/openedx/core/ui/theme/AppColors.kt | 7 +- .../java/org/openedx/core/ui/theme/Theme.kt | 14 +- .../org/openedx/core/ui/theme/Colors.kt | 14 +- .../container/CollapsingLayout.kt | 440 ++++++++++-------- .../container/CourseContainerFragment.kt | 11 +- .../container/CourseContainerViewModel.kt | 92 +++- .../presentation/container/HeaderContent.kt | 35 +- .../presentation/container/TabContent.kt | 10 +- .../outline/CourseOutlineScreen.kt | 16 - .../course/presentation/ui/CourseVideosUI.kt | 14 - 10 files changed, 358 insertions(+), 295 deletions(-) diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index f82b33782..ab582d109 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -52,10 +52,9 @@ data class AppColors( val componentHorizontalProgressSelected: Color, val componentHorizontalProgressDefault: Color, - val navigationBtnBackground: Color, - val navigationBtnUnselectedContent: Color, - val courseHomeHeaderShadePrimary: Color, - val courseHomeHeaderShadeSecondary: Color, + val tabUnselectedBtnBackground: Color, + val tabUnselectedBtnContent: Color, + val courseHomeHeaderShade: Color, val courseHomeBackBtnBackground: Color ) { val primary: Color get() = material.primary diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 878579817..83e46fcf9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -72,10 +72,9 @@ private val DarkColorPalette = AppColors( componentHorizontalProgressSelected = dark_component_horizontal_progress_selected, componentHorizontalProgressDefault = dark_component_horizontal_progress_default, - navigationBtnBackground = dark_navigation_btn_background, - navigationBtnUnselectedContent = dark_navigation_btn_unselected_content, - courseHomeHeaderShadePrimary = dark_course_home_header_shade_primary, - courseHomeHeaderShadeSecondary = dark_course_home_header_shade_secondary, + tabUnselectedBtnBackground = dark_tab_unselected_btn_background, + tabUnselectedBtnContent = dark_tab_unselected_btn_content, + courseHomeHeaderShade = dark_course_home_header_shade, courseHomeBackBtnBackground = dark_course_home_back_btn_background ) @@ -141,10 +140,9 @@ private val LightColorPalette = AppColors( componentHorizontalProgressSelected = light_component_horizontal_progress_selected, componentHorizontalProgressDefault = light_component_horizontal_progress_default, - navigationBtnBackground = light_navigation_btn_background, - navigationBtnUnselectedContent = light_navigation_btn_unselected_content, - courseHomeHeaderShadePrimary = light_course_home_header_shade_primary, - courseHomeHeaderShadeSecondary = light_course_home_header_shade_secondary, + tabUnselectedBtnBackground = light_tab_unselected_btn_background, + tabUnselectedBtnContent = light_tab_unselected_btn_content, + courseHomeHeaderShade = light_course_home_header_shade, courseHomeBackBtnBackground = light_course_home_back_btn_background ) diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index a22c53ef1..f2220da1b 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -50,10 +50,9 @@ val light_component_horizontal_progress_completed_and_selected = Color(0xFF30a17 val light_component_horizontal_progress_completed = Color(0xFFbbe6d7) val light_component_horizontal_progress_selected = Color(0xFFF0CB00) val light_component_horizontal_progress_default = Color(0xFFD6D3D1) -val light_navigation_btn_background = Color.White -val light_navigation_btn_unselected_content = light_primary -val light_course_home_header_shade_primary = Color.White -val light_course_home_header_shade_secondary = Color(0xFFBABABA) +val light_tab_unselected_btn_background = Color.White +val light_tab_unselected_btn_content = light_primary +val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = Color.White @@ -105,8 +104,7 @@ val dark_component_horizontal_progress_completed_and_selected = Color(0xFF30a171 val dark_component_horizontal_progress_completed = Color(0xFFbbe6d7) val dark_component_horizontal_progress_selected = Color(0xFFF0CB00) val dark_component_horizontal_progress_default = Color(0xFFD6D3D1) -val dark_navigation_btn_background = Color(0xFF273346) -val dark_navigation_btn_unselected_content = Color.White -val dark_course_home_header_shade_primary = Color(0xFF273346) -val dark_course_home_header_shade_secondary = Color(0xFF999999) +val dark_tab_unselected_btn_background = Color(0xFF273346) +val dark_tab_unselected_btn_content = Color.White +val dark_course_home_header_shade = Color(0xFF999999) val dark_course_home_back_btn_background = Color.Black \ No newline at end of file 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 2c7b28396..7f599a0ae 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 @@ -2,11 +2,7 @@ package org.openedx.course.presentation.container import android.content.res.Configuration import android.graphics.Bitmap -import android.graphics.Picture import android.os.Build -import android.renderscript.Allocation -import android.renderscript.RenderScript -import android.renderscript.ScriptIntrinsicBlur import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,28 +32,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.draw -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import coil.request.ImageRequest +import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.appColors @@ -65,7 +55,7 @@ import kotlin.math.roundToInt @Composable internal fun CollapsingLayout( - imageUrl: String, + courseImage: Bitmap, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, @@ -89,14 +79,9 @@ internal fun CollapsingLayout( var backgroundImageHeight by remember { mutableFloatStateOf(0f) } - val picture = remember { Picture() } val windowSize = rememberWindowSize() val configuration = LocalConfiguration.current - val imageHeight = if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - 100 - } else { - 200 - } + val imageHeight = 200 val factor = (-imageHeight - offset) / -imageHeight val alpha = if (factor.isNaN() || factor < 0) 0f else factor val blurImagePadding = 40.dp @@ -118,12 +103,6 @@ internal fun CollapsingLayout( } else { 60.dp } - val imageModel = ImageRequest.Builder(LocalContext.current) - .data(imageUrl) - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) - .allowHardware(false) - .build() fun calculateOffset(delta: Float): Offset { val oldOffset = offset @@ -160,221 +139,274 @@ internal fun CollapsingLayout( .fillMaxSize() .nestedScroll(nestedScrollConnection), ) { - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp) - .onSizeChanged { size -> - backgroundImageHeight = size.height.toFloat() - } - .drawWithCache { - val width = this.size.width.toInt() - val height = this.size.height.toInt() - onDrawWithContent { - val pictureCanvas = - androidx.compose.ui.graphics.Canvas( - picture.beginRecording( - width, - height - ) - ) - draw(this, this.layoutDirection, pictureCanvas, this.size) { - this@onDrawWithContent.drawContent() - } - picture.endRecording() - drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) } - } - }, - model = imageModel, - contentDescription = null, - contentScale = ContentScale.Crop - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Box( - modifier = Modifier - .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } - .background(Color.White) - .blur(100.dp) - ) { + if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Box( modifier = Modifier - .background(MaterialTheme.appColors.courseHomeHeaderShadePrimary) - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) - .align(Alignment.Center) - ) - AsyncImage( + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + Image( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( modifier = Modifier .fillMaxWidth() - .height(blurImagePadding) - .align(Alignment.TopCenter), - model = imageModel, + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), contentDescription = null, - contentScale = ContentScale.Crop, - alignment = PixelAlignment(0f, blurImageAlignment), + contentScale = ContentScale.Crop ) Box( modifier = Modifier - .background(MaterialTheme.appColors.courseHomeHeaderShadeSecondary) .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) - .align(Alignment.BottomCenter) + .height(with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), ) } - } else if (picture.width > 0 && picture.height > 0) { - //TODO Change color - val backgroundColor = MaterialTheme.appColors.cardViewBackground - LegacyBlurImage( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp), - bitmap = createBitmapFromPicture(picture), - blurRadio = 25f - ) - Box( + + Row( modifier = Modifier .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } ) - .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) } - .background(backgroundColor) - ) + .displayCutoutForLandscape() + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight = size.height.toFloat() + }, + verticalAlignment = Alignment.Bottom + ) { + Icon( + modifier = Modifier + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding) + .clip(CircleShape) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + content = collapsedTop, + ) + } + Box( modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp) - .background( - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - startY = 500f, - endY = 400f - ) - ), + .displayCutoutForLandscape() + .offset { IntOffset(x = 0, y = (collapsedTopHeight).roundToInt()) } + .onSizeChanged { size -> + navigationHeight = size.height.toFloat() + }, + content = navigation, ) + Box( modifier = Modifier - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) .offset { IntOffset( x = 0, - y = with(localDensity) { (offset + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) - } - .background( - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - startY = 400f, - endY = 0f + y = (collapsedTopHeight + navigationHeight).roundToInt() ) - ), + } + .padding(bottom = with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }), + content = bodyContent, ) - } - - Box( - modifier = Modifier - .onSizeChanged { size -> - expandedTopHeight = size.height.toFloat() + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + Image( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = PixelAlignment(0f, blurImageAlignment), + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) } - .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight).roundToInt()) } - .alpha(alpha), - content = expandedTop, - ) + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() }) + .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) } + .background(backgroundColor) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 500f, + endY = 400f + ) + ), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = with(localDensity) { (offset + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + } + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), + ) + } - Row( - modifier = Modifier - .padding(horizontal = 12.dp) - .onSizeChanged { size -> - collapsedTopHeight = size.height.toFloat() - } - ) { - Icon( - modifier = Modifier - .statusBarsInset() - .padding(top = 12.dp, start = backBtnStartPadding) - .clip(CircleShape) - .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(alpha / 2)) - .clickable { - onBackClick() - }, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - tint = MaterialTheme.appColors.textPrimary, - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier - .padding(top = 12.dp) - .alpha(1 - alpha), - content = collapsedTop, + .onSizeChanged { size -> + expandedTopHeight = size.height.toFloat() + } + .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight).roundToInt()) } + .alpha(alpha), + content = expandedTop, ) - } - - Box( - modifier = Modifier - .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight + expandedTopHeight).roundToInt()) } - .onSizeChanged { size -> - navigationHeight = size.height.toFloat() - }, - content = navigation, - ) - - Box( - modifier = Modifier.offset { - IntOffset( - x = 0, - y = (expandedTopHeight + offset + backgroundImageHeight + navigationHeight).roundToInt() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight = size.height.toFloat() + }, + verticalAlignment = Alignment.Bottom + ) { + Icon( + modifier = Modifier + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding) + .clip(CircleShape) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(alpha / 2)) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null ) - }, - content = bodyContent, - ) - } -} - -private fun createBitmapFromPicture(picture: Picture): Bitmap { - val bitmap = Bitmap.createBitmap( - picture.width, - picture.height, - Bitmap.Config.ARGB_8888 - ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .alpha(1 - alpha), + content = collapsedTop, + ) + } - val canvas = android.graphics.Canvas(bitmap) - canvas.drawColor(android.graphics.Color.WHITE) - canvas.drawPicture(picture) - return bitmap -} -@Composable -fun BlurImage( - bitmap: Bitmap, - modifier: Modifier = Modifier.fillMaxSize(), -) { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - ) -} + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight + expandedTopHeight).roundToInt()) } + .onSizeChanged { size -> + navigationHeight = size.height.toFloat() + }, + content = navigation, + ) -@Composable -private fun LegacyBlurImage( - bitmap: Bitmap, - blurRadio: Float, - modifier: Modifier = Modifier.fillMaxSize() -) { - val renderScript = RenderScript.create(LocalContext.current) - val bitmapAlloc = Allocation.createFromBitmap(renderScript, bitmap) - ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { - setRadius(blurRadio) - setInput(bitmapAlloc) - forEach(bitmapAlloc) + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (expandedTopHeight + offset + backgroundImageHeight + navigationHeight).roundToInt() + ) + } + .padding(bottom = with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }), + content = bodyContent, + ) + } } - bitmapAlloc.copyTo(bitmap) - renderScript.destroy() - - BlurImage(bitmap, modifier) } @Immutable 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 fbc8d6080..30588dbbf 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 @@ -118,7 +118,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.preloadCourseStructure() + viewModel.preloadCourseStructure(requireContext()) } private var snackBar: Snackbar? = null @@ -154,7 +154,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.preloadCourseStructure(requireContext()) } snackBar?.show() @@ -190,6 +190,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { binding.composeCollapsingLayout.setContent { OpenEdXTheme { val refreshing by viewModel.showProgress.collectAsState(true) + val courseImage by viewModel.courseImage.collectAsState() val pagerState = rememberPagerState(pageCount = { 5 }) val pullRefreshState = rememberPullRefreshState( refreshing = refreshing, @@ -201,11 +202,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { modifier = Modifier .fillMaxWidth() .pullRefresh(pullRefreshState), - imageUrl = viewModel.image, + courseImage = courseImage, expandedTop = { ExpandedHeaderContent( courseTitle = viewModel.courseName, - org = viewModel.org + org = viewModel.organization ) }, collapsedTop = { @@ -214,7 +215,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) }, navigation = { - Tabs(pagerState = pagerState) + Tabs(pagerState = pagerState, viewModel::courseContainerTabClickedEvent) }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() 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 f171aebe1..4f98daf6a 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 @@ -1,9 +1,18 @@ package org.openedx.course.presentation.container +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.renderscript.Allocation +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import coil.ImageLoader +import coil.request.ImageRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -55,8 +64,7 @@ class CourseContainerViewModel( private val courseAnalytics: CourseAnalytics, ) : BaseViewModel() { - var org: String = "" - var image: String = "" + var organization: String = "" private val _dataReady = MutableLiveData() val dataReady: LiveData @@ -90,6 +98,9 @@ class CourseContainerViewModel( val calendarSyncUIState: StateFlow = _calendarSyncUIState.asStateFlow() + private var _courseImage = MutableStateFlow(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + val courseImage: StateFlow = _courseImage.asStateFlow() + init { viewModelScope.launch { notifier.notifier.collect { event -> @@ -111,7 +122,7 @@ class CourseContainerViewModel( } } - fun preloadCourseStructure() { + fun preloadCourseStructure(context: Context) { courseDashboardViewed() if (_dataReady.value != null) { return @@ -127,11 +138,12 @@ class CourseContainerViewModel( } val courseStructure = interactor.getCourseStructureFromCache() courseName = courseStructure.name - org = courseStructure.org - courseStructure.media?.image?.large?.let { - image = config.getApiHostURL() + it - } + organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced + loadCourseImage( + context = context, + imageUrl = config.getApiHostURL() + courseStructure.media?.image?.large + ) _dataReady.value = courseStructure.start?.let { start -> start < Date() } @@ -167,16 +179,15 @@ class CourseContainerViewModel( } } - //TODO -// fun courseContainerTabClickedEvent(tab: CourseContainerTab) { -// when (tab) { -// CourseContainerTab.COURSE -> courseTabClickedEvent() -// CourseContainerTab.VIDEOS -> videoTabClickedEvent() -// CourseContainerTab.DISCUSSION -> discussionTabClickedEvent() -// CourseContainerTab.DATES -> datesTabClickedEvent() -// CourseContainerTab.HANDOUTS -> handoutsTabClickedEvent() -// } -// } + fun courseContainerTabClickedEvent(index: Int) { + when (index) { + 0 -> courseTabClickedEvent() + 1 -> videoTabClickedEvent() + 2 -> discussionTabClickedEvent() + 3 -> datesTabClickedEvent() + 4 -> handoutsTabClickedEvent() + } + } fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { @@ -299,6 +310,53 @@ class CourseContainerViewModel( (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) } + private fun loadCourseImage(context: Context, imageUrl: String) { + val loader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .target { result -> + viewModelScope.launch { + val bitmap = (result as BitmapDrawable).bitmap + _courseImage.emit( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bitmap + } else { + blurBitmap(context, bitmap, 10f) + } + ) + } + } + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .allowHardware(false) + .build() + loader.enqueue(request) + } + + private fun blurBitmap( + context: Context, + bitmap: Bitmap, + blurRadio: Float + ): Bitmap { + val renderScript = RenderScript.create(context) + val bitmapAlloc = Allocation.createFromBitmap(renderScript, bitmap) + ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { + setRadius(blurRadio) + setInput(bitmapAlloc) + repeat(3) { + forEach(bitmapAlloc) + } + } + val newBitmap: Bitmap = Bitmap.createBitmap( + bitmap.width, + bitmap.height, + Bitmap.Config.ARGB_8888 + ) + bitmapAlloc.copyTo(newBitmap) + renderScript.destroy() + return newBitmap + } + private fun courseDashboardViewed() { logCourseContainerEvent(CourseAnalyticsEvent.DASHBOARD) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt index 51411d7e5..d3aa6975a 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography @@ -23,7 +23,7 @@ internal fun ExpandedHeaderContent( courseTitle: String ) { val windowSize = rememberWindowSize() - val horizontalPadding = if (!windowSize.isTablet){ + val horizontalPadding = if (!windowSize.isTablet) { 24.dp } else { 98.dp @@ -58,8 +58,7 @@ internal fun CollapsedHeaderContent( Text( modifier = modifier .fillMaxWidth() - .statusBarsInset() - .padding(top = 4.dp), + .padding(bottom = 3.dp), text = courseTitle, color = MaterialTheme.appColors.textDark, overflow = TextOverflow.Ellipsis, @@ -70,19 +69,23 @@ internal fun CollapsedHeaderContent( @Preview(showBackground = true, device = Devices.PIXEL) @Composable -fun ExpandedHeaderContentPreview() { - ExpandedHeaderContent( - modifier = Modifier.fillMaxWidth(), - org = "organization", - courseTitle = "Course title" - ) +private fun ExpandedHeaderContentPreview() { + OpenEdXTheme { + ExpandedHeaderContent( + modifier = Modifier.fillMaxWidth(), + org = "organization", + courseTitle = "Course title" + ) + } } @Preview(showBackground = true, device = Devices.PIXEL) @Composable -fun CollapsedHeaderContentPreview() { - CollapsedHeaderContent( - modifier = Modifier.fillMaxWidth(), - courseTitle = "Course title" - ) -} \ No newline at end of file +private fun CollapsedHeaderContentPreview() { + OpenEdXTheme { + CollapsedHeaderContent( + modifier = Modifier.fillMaxWidth(), + courseTitle = "Course title" + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt b/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt index 1ff00c7b1..cee5bc5b6 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt @@ -41,7 +41,10 @@ import org.openedx.course.R @OptIn(ExperimentalFoundationApi::class) @Composable -fun Tabs(pagerState: PagerState) { +fun Tabs( + pagerState: PagerState, + onPageChange: (Int) -> Unit +) { val list = listOf( stringResource(id = R.string.course_navigation_course) to rememberVectorPainter(Icons.Default.Home), stringResource(id = R.string.course_navigation_videos) to rememberVectorPainter(Icons.Rounded.PlayCircleFilled), @@ -66,12 +69,12 @@ fun Tabs(pagerState: PagerState) { val backgroundColor = if (pagerState.currentPage == index) { MaterialTheme.appColors.primary } else { - MaterialTheme.appColors.navigationBtnBackground + MaterialTheme.appColors.tabUnselectedBtnBackground } val contentColor = if (pagerState.currentPage == index) { Color.White } else { - MaterialTheme.appColors.navigationBtnUnselectedContent + MaterialTheme.appColors.tabUnselectedBtnContent } val border = if (isSystemInDarkTheme()) { Modifier @@ -90,6 +93,7 @@ fun Tabs(pagerState: PagerState) { .clickable { scope.launch { pagerState.animateScrollToPage(index) + onPageChange(index) } } then (border) 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 82ec471f8..feebf42ca 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 @@ -9,7 +9,6 @@ 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 @@ -69,7 +68,6 @@ import org.openedx.course.DatesShiftedSnackBar 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 import org.openedx.course.presentation.ui.CourseSubSectionItem import org.openedx.course.presentation.ui.DatesShiftedSnackBar @@ -172,20 +170,6 @@ internal fun CourseOutlineScreen( modifier = Modifier.fillMaxSize(), contentPadding = listBottomPadding ) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - onCertificateClick = onCertificateClick, - courseName = uiState.courseStructure.name - ) - } - item { Spacer(Modifier.height(28.dp)) } if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { item { Box( 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 a5a3f18a4..f38fc1d7c 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 @@ -12,7 +12,6 @@ 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 @@ -188,19 +187,6 @@ fun CourseVideosScreen( modifier = Modifier.fillMaxSize(), contentPadding = listBottomPadding ) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - courseName = uiState.courseStructure.name - ) - } - if (uiState.downloadModelsSize.allCount > 0) { item { AllVideosDownloadItem( From 38709fe766c0e4c352381cff3456753c2b20a27d Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 3 Apr 2024 13:53:08 +0300 Subject: [PATCH 06/18] feat: auto scrolling header to collapsed and expanded states --- .../container/CollapsingLayout.kt | 246 ++++++++++++++++-- 1 file changed, 230 insertions(+), 16 deletions(-) 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 7f599a0ae..7ed09e882 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 @@ -3,9 +3,12 @@ package org.openedx.course.presentation.container import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row @@ -26,6 +29,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,6 +43,10 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration @@ -47,6 +55,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset @@ -73,21 +82,21 @@ internal fun CollapsingLayout( var navigationHeight by remember { mutableFloatStateOf(0f) } - var offset by remember { - mutableFloatStateOf(0f) - } + val offset = remember { Animatable(0f) } var backgroundImageHeight by remember { mutableFloatStateOf(0f) } val windowSize = rememberWindowSize() + val coroutineScope = rememberCoroutineScope() val configuration = LocalConfiguration.current val imageHeight = 200 - val factor = (-imageHeight - offset) / -imageHeight + val factor = (-imageHeight - offset.value) / -imageHeight val alpha = if (factor.isNaN() || factor < 0) 0f else factor val blurImagePadding = 40.dp - val toolbarOffset = with(localDensity) { (offset + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() } + val toolbarOffset = + with(localDensity) { (offset.value + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() } val imageStartY = with(localDensity) { backgroundImageHeight - blurImagePadding.toPx() } * 0.5f - val imageOffsetY = -(offset + imageStartY) + val imageOffsetY = -(offset.value + imageStartY) val toolbarBackgroundOffset = if (toolbarOffset >= 0) { toolbarOffset } else { @@ -105,10 +114,12 @@ internal fun CollapsingLayout( } fun calculateOffset(delta: Float): Offset { - val oldOffset = offset + val oldOffset = offset.value val newOffset = (oldOffset + delta).coerceIn(-expandedTopHeight - backgroundImageHeight + collapsedTopHeight, 0f) - offset = newOffset + coroutineScope.launch { + offset.animateTo(newOffset, tween(0)) + } return Offset(0f, newOffset - oldOffset) } @@ -117,7 +128,7 @@ internal fun CollapsingLayout( override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = when { available.y >= 0 -> Offset.Zero - offset == -expandedTopHeight -> Offset.Zero + offset.value == -expandedTopHeight -> Offset.Zero else -> calculateOffset(available.y) } @@ -128,7 +139,7 @@ internal fun CollapsingLayout( ): Offset = when { available.y <= 0 -> Offset.Zero - offset == 0f -> Offset.Zero + offset.value == 0f -> Offset.Zero else -> calculateOffset(available.y) } } @@ -137,9 +148,190 @@ internal fun CollapsingLayout( Box( modifier = modifier .fillMaxSize() - .nestedScroll(nestedScrollConnection), + .nestedScroll(nestedScrollConnection) + .pointerInput(Unit) { + var yStart = 0f + kotlinx.coroutines.coroutineScope { + routePointerChangesTo( + onDown = { change -> + yStart = change.position.y + }, + onUp = { change -> + val yEnd = change.position.y + val yDelta = yEnd - yStart + val scrollDown = yDelta > 0 + val collapsedOffset = -expandedTopHeight - backgroundImageHeight + collapsedTopHeight + val expandedOffset = 0f + if (scrollDown) { + if (offset.value > -backgroundImageHeight * 0.85) { + launch { + offset.animateTo(expandedOffset) + } + } else { + launch { + offset.animateTo(collapsedOffset) + } + } + } else { + if (offset.value < -backgroundImageHeight * 0.15) { + launch { + offset.animateTo(collapsedOffset) + } + } else { + launch { + offset.animateTo(expandedOffset) + } + } + } + } + ) + } + }, ) { - if (!windowSize.isTablet && configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + if (windowSize.isTablet) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = with(localDensity) { (backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + } + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + Image( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() }) + .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) } + .background(backgroundColor) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 500f, + endY = 400f + ) + ), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = with(localDensity) { (backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + } + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), + ) + } + + Box( + modifier = Modifier + .onSizeChanged { size -> + expandedTopHeight = size.height.toFloat() + } + .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) }, + content = expandedTop, + ) + + Icon( + modifier = Modifier + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding + 12.dp) + .clip(CircleShape) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null + ) + + + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = (backgroundImageHeight + expandedTopHeight).roundToInt()) } + .onSizeChanged { size -> + navigationHeight = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (expandedTopHeight + backgroundImageHeight + navigationHeight).roundToInt() + ) + } + .padding(bottom = with(localDensity) { (expandedTopHeight + navigationHeight + backgroundImageHeight).toDp() }), + content = bodyContent, + ) + } else if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Box( modifier = Modifier @@ -332,7 +524,7 @@ internal fun CollapsingLayout( .offset { IntOffset( x = 0, - y = with(localDensity) { (offset + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + y = with(localDensity) { (offset.value + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) } .background( brush = Brush.verticalGradient( @@ -349,7 +541,7 @@ internal fun CollapsingLayout( .onSizeChanged { size -> expandedTopHeight = size.height.toFloat() } - .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight).roundToInt()) } + .offset { IntOffset(x = 0, y = (offset.value + backgroundImageHeight).roundToInt()) } .alpha(alpha), content = expandedTop, ) @@ -387,7 +579,12 @@ internal fun CollapsingLayout( Box( modifier = Modifier - .offset { IntOffset(x = 0, y = (offset + backgroundImageHeight + expandedTopHeight).roundToInt()) } + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight + expandedTopHeight).roundToInt() + ) + } .onSizeChanged { size -> navigationHeight = size.height.toFloat() }, @@ -399,7 +596,7 @@ internal fun CollapsingLayout( .offset { IntOffset( x = 0, - y = (expandedTopHeight + offset + backgroundImageHeight + navigationHeight).roundToInt() + y = (expandedTopHeight + offset.value + backgroundImageHeight + navigationHeight).roundToInt() ) } .padding(bottom = with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }), @@ -409,6 +606,23 @@ internal fun CollapsingLayout( } } +suspend fun PointerInputScope.routePointerChangesTo( + onDown: (PointerInputChange) -> Unit = {}, + onUp: (PointerInputChange) -> Unit = {} +) { + awaitEachGesture { + do { + val event = awaitPointerEvent() + event.changes.forEach { + when (event.type) { + PointerEventType.Press -> onDown(it) + PointerEventType.Release -> onUp(it) + } + } + } while (event.changes.any { it.pressed }) + } +} + @Immutable data class PixelAlignment( val offsetX: Float, From d8d2f57850e0f92ad39837a662b490cb58551979 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 3 Apr 2024 21:24:04 +0300 Subject: [PATCH 07/18] fix: Fixed junit tests --- .../dates/CourseDatesViewModelTest.kt | 50 +++++--- .../outline/CourseOutlineViewModelTest.kt | 93 ++++++++++---- .../videos/CourseVideoViewModelTest.kt | 42 +++++-- .../topics/DiscussionTopicsViewModel.kt | 4 +- .../threads/DiscussionThreadsViewModelTest.kt | 64 +++++----- .../topics/DiscussionTopicsViewModelTest.kt | 118 ++++++++++++------ 6 files changed, 255 insertions(+), 116 deletions(-) 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 82c3728e4..08f3fffc4 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 @@ -7,12 +7,16 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert import org.junit.Before @@ -153,11 +157,10 @@ class CourseDatesViewModelTest { } @Test - fun `getCourseDates no internet connection exception`() = runTest { + fun `getCourseDates no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( "", "", - true, "", notifier, interactor, @@ -170,23 +173,25 @@ class CourseDatesViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDates(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - Assert.assertEquals(noInternet, message?.message) + Assert.assertEquals(noInternet, message.await()?.message) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Loading) } @Test - fun `getCourseDates unknown exception`() = runTest { + fun `getCourseDates unknown exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( "", "", - true, "", notifier, interactor, @@ -199,23 +204,25 @@ class CourseDatesViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws Exception() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDates(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - Assert.assertEquals(somethingWrong, message?.message) + Assert.assertEquals(somethingWrong, message.await()?.message) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Loading) } @Test - fun `getCourseDates success with internet`() = runTest { + fun `getCourseDates success with internet`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( "", "", - true, "", notifier, interactor, @@ -228,22 +235,25 @@ class CourseDatesViewModelTest { ) every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDates(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Dates) } @Test - fun `getCourseDates success with EmptyList`() = runTest { + fun `getCourseDates success with EmptyList`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( "", "", - true, "", notifier, interactor, @@ -259,12 +269,16 @@ class CourseDatesViewModelTest { datesSection = linkedMapOf(), courseBanner = mockCourseDatesBannerInfo, ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseDates(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Empty) } 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 174e8ea4f..98884d796 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 @@ -12,13 +12,16 @@ import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -215,12 +218,13 @@ class CourseOutlineViewModelTest { } @Test - fun `getCourseDataInternal no internet connection exception`() = runTest { + fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -234,23 +238,27 @@ class CourseOutlineViewModelTest { workerController, ) + val message = async { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) + assertEquals(noInternet, message.await()?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) } @Test - fun `getCourseDataInternal unknown exception`() = runTest { + fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -264,19 +272,22 @@ class CourseOutlineViewModelTest { workerController ) + val message = async { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, message.await()?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) } @Test - fun `getCourseDataInternal success with internet connection`() = runTest { + fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { @@ -292,6 +303,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -305,18 +317,24 @@ class CourseOutlineViewModelTest { workerController ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { interactor.getCourseStatus(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @Test - fun `getCourseDataInternal success without internet connection`() = runTest { + fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns false coEvery { downloadDao.readAllData() } returns flow { @@ -332,6 +350,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -345,18 +364,24 @@ class CourseOutlineViewModelTest { workerController ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + viewModel.getCourseData() advanceUntilIdle() verify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 0) { interactor.getCourseStatus(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @Test - fun `updateCourseData success with internet connection`() = runTest { + fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns flow { @@ -372,6 +397,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -385,13 +411,19 @@ class CourseOutlineViewModelTest { workerController ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + viewModel.getCourseData() viewModel.updateCourseData(false) advanceUntilIdle() coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } coVerify(exactly = 2) { interactor.getCourseStatus(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @@ -399,6 +431,7 @@ class CourseOutlineViewModelTest { @Test fun `CourseStructureUpdated notifier test`() = runTest { val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -427,15 +460,16 @@ class CourseOutlineViewModelTest { lifecycleRegistry.addObserver(viewModel) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + viewModel.getCourseData() viewModel.setIsUpdating() advanceUntilIdle() coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStatus(any()) } } @Test - fun `saveDownloadModels test`() = runTest { + fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isWifiConnected() } returns true @@ -452,6 +486,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -464,7 +499,11 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() verify(exactly = 1) { @@ -474,11 +513,11 @@ class CourseOutlineViewModelTest { ) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest { + fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") every { preferencesManager.videoSettings.wifiDownloadOnly } returns true @@ -491,6 +530,7 @@ class CourseOutlineViewModelTest { every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -503,15 +543,19 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest { + fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false @@ -521,6 +565,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -533,12 +578,16 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value != null) + assert(message.await()?.message.isNullOrEmpty()) assert(!viewModel.hasInternetConnection) } } diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index a825345cf..cccd246f1 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -11,19 +11,24 @@ import io.mockk.mockk import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType +import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block @@ -161,7 +166,7 @@ class CourseVideoViewModelTest { @Before fun setUp() { every { resourceManager.getString(R.string.course_does_not_include_videos) } returns "" - every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload + every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" } @@ -178,6 +183,7 @@ class CourseVideoViewModelTest { every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -208,6 +214,7 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -244,6 +251,7 @@ class CourseVideoViewModelTest { } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -276,6 +284,7 @@ class CourseVideoViewModelTest { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -298,10 +307,11 @@ class CourseVideoViewModelTest { } @Test - fun `saveDownloadModels test`() = runTest { + fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -321,18 +331,23 @@ class CourseVideoViewModelTest { every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest { + fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -355,18 +370,24 @@ class CourseVideoViewModelTest { emit(listOf(DownloadModelEntity.createFrom(downloadModel))) } every { coreAnalytics.logEvent(any(), any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, without conection`() = runTest { + fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -386,12 +407,17 @@ class CourseVideoViewModelTest { coEvery { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } coEvery { workerController.saveModels(any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value != null) + assert(message.await()?.message.isNullOrEmpty()) assert(!viewModel.hasInternetConnection) } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index de2e8c27f..ca2fbc3f0 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage @@ -13,7 +14,6 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics -import kotlinx.coroutines.launch class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, @@ -56,7 +56,7 @@ class DiscussionTopicsViewModel( } } - private fun getCourseTopics() { + fun getCourseTopics() { _uiState.value = DiscussionTopicsUIState.Loading getCourseTopicsInternal() } diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index e34df3ed8..59ef42720 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt @@ -4,6 +4,26 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.Pagination @@ -12,22 +32,10 @@ import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData -import org.openedx.discussion.presentation.topics.DiscussionTopicsFragment +import org.openedx.discussion.presentation.topics.DiscussionTopic import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged -import io.mockk.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TestRule import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -119,7 +127,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopic.ALL_POSTS ) advanceUntilIdle() @@ -139,7 +147,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopic.ALL_POSTS ) coEvery { interactor.getAllThreads(any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -170,7 +178,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopic.ALL_POSTS ) advanceUntilIdle() @@ -198,7 +206,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopic.FOLLOWING_POSTS ) advanceUntilIdle() @@ -218,7 +226,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopic.FOLLOWING_POSTS ) coEvery { interactor.getFollowingThreads( @@ -273,7 +281,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopic.FOLLOWING_POSTS ) advanceUntilIdle() @@ -301,7 +309,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) advanceUntilIdle() @@ -321,7 +329,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -352,7 +360,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) advanceUntilIdle() @@ -371,7 +379,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -391,7 +399,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -411,7 +419,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -441,7 +449,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) viewModel.updateThread("") advanceUntilIdle() @@ -477,7 +485,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) @@ -516,7 +524,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopic.TOPIC ) val mockLifeCycleOwner: LifecycleOwner = mockk() diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 2e7998a73..76c2c95eb 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -1,24 +1,32 @@ package org.openedx.discussion.presentation.topics import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.system.ResourceManager -import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.presentation.DiscussionAnalytics import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.* +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.system.ResourceManager +import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.presentation.DiscussionAnalytics import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -49,92 +57,126 @@ class DiscussionTopicsViewModelTest { } @Test - fun `getCourseTopics no internet exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.getCourseTopics() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) + assertEquals(noInternet, message.await()?.message) assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) } @Test - fun `getCourseTopics unknown exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.getCourseTopics() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, message.await()?.message) assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) } @Test - fun `getCourseTopics success`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.getCourseTopics() advanceUntilIdle() - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DiscussionTopicsUIState.Topics) } @Test - fun `updateCourseTopics no internet exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.updateCourseTopics() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + val isUpdating = async { + withTimeoutOrNull(5000) { + viewModel.isUpdating.first() + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) - assert(viewModel.isUpdating.value == false) + assertEquals(noInternet, message.await()?.message) + assert(!(isUpdating.await() ?: false)) } @Test - fun `updateCourseTopics unknown exception`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.updateCourseTopics() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + val isUpdating = async { + withTimeoutOrNull(5000) { + viewModel.isUpdating.first() + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) - assert(viewModel.isUpdating.value == false) + assertEquals(somethingWrong, message.await()?.message) + assert(!(isUpdating.await() ?: false)) } @Test - fun `updateCourseTopics success`() = runTest { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"") + fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.updateCourseTopics() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + val isUpdating = async { + withTimeoutOrNull(5000) { + viewModel.isUpdating.first() + } + } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } - assert(viewModel.uiMessage.value == null) + assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DiscussionTopicsUIState.Topics) - assert(viewModel.isUpdating.value == false) + assert(!(isUpdating.await() ?: false)) } } \ No newline at end of file From 6aadad19299541c7f75c4609e033aafda7f243ce Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Apr 2024 14:10:48 +0300 Subject: [PATCH 08/18] fix: Fixed CourseContainerViewModelTest --- .../main/java/org/openedx/app/di/AppModule.kt | 3 + .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../java/org/openedx/core/ImageProcessor.kt | 49 ++++++++++++ .../container/CourseContainerFragment.kt | 4 +- .../container/CourseContainerViewModel.kt | 74 ++++--------------- .../container/CourseContainerViewModelTest.kt | 28 +++++-- 6 files changed, 92 insertions(+), 67 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/ImageProcessor.kt 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 2952e16c8..eda9855ea 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -25,6 +25,7 @@ import org.openedx.auth.presentation.sso.FacebookAuthHelper import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper +import org.openedx.core.ImageProcessor import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CorePreferences @@ -83,6 +84,8 @@ val appModule = module { single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get(), get()) } + single { ImageProcessor(get()) } + single { GsonBuilder() .registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer()) 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 7d67f9c9e..770f3b101 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -195,6 +195,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String, courseTitle: String) -> diff --git a/core/src/main/java/org/openedx/core/ImageProcessor.kt b/core/src/main/java/org/openedx/core/ImageProcessor.kt new file mode 100644 index 000000000..758fbe9f6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ImageProcessor.kt @@ -0,0 +1,49 @@ +package org.openedx.core + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.renderscript.Allocation +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur +import coil.ImageLoader +import coil.request.ImageRequest + +class ImageProcessor(private val context: Context) { + fun loadImage(imageUrl: String, onComplete: (result: Drawable) -> Unit) { + val loader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .target { result -> + onComplete(result) + } + .error(R.drawable.core_no_image_course) + .placeholder(R.drawable.core_no_image_course) + .allowHardware(false) + .build() + loader.enqueue(request) + } + + fun applyBlur( + bitmap: Bitmap, + blurRadio: Float + ): Bitmap { + val renderScript = RenderScript.create(context) + val bitmapAlloc = Allocation.createFromBitmap(renderScript, bitmap) + ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { + setRadius(blurRadio) + setInput(bitmapAlloc) + repeat(3) { + forEach(bitmapAlloc) + } + } + val newBitmap: Bitmap = Bitmap.createBitmap( + bitmap.width, + bitmap.height, + Bitmap.Config.ARGB_8888 + ) + bitmapAlloc.copyTo(newBitmap) + renderScript.destroy() + return newBitmap + } +} \ No newline at end of file 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 30588dbbf..7fa3063e1 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 @@ -118,7 +118,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - viewModel.preloadCourseStructure(requireContext()) + viewModel.preloadCourseStructure() } private var snackBar: Snackbar? = null @@ -154,7 +154,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(requireContext()) + viewModel.preloadCourseStructure() } snackBar?.show() 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 4f98daf6a..b86bb8f7d 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 @@ -1,18 +1,12 @@ package org.openedx.course.presentation.container -import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build -import android.renderscript.Allocation -import android.renderscript.RenderScript -import android.renderscript.ScriptIntrinsicBlur import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import coil.ImageLoader -import coil.request.ImageRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +15,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel +import org.openedx.core.ImageProcessor import org.openedx.core.SingleEventLiveData import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -52,7 +47,7 @@ import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, - val enrollmentMode: String, + private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, @@ -62,6 +57,7 @@ class CourseContainerViewModel( private val corePreferences: CorePreferences, private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, + private val imageProcessor: ImageProcessor ) : BaseViewModel() { var organization: String = "" @@ -122,7 +118,7 @@ class CourseContainerViewModel( } } - fun preloadCourseStructure(context: Context) { + fun preloadCourseStructure() { courseDashboardViewed() if (_dataReady.value != null) { return @@ -140,9 +136,18 @@ class CourseContainerViewModel( courseName = courseStructure.name organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced - loadCourseImage( - context = context, - imageUrl = config.getApiHostURL() + courseStructure.media?.image?.large + imageProcessor.loadImage( + imageUrl = config.getApiHostURL() + courseStructure.media?.image?.large, + onComplete = { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap.apply { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + imageProcessor.applyBlur(this@apply, 10f) + } + } + viewModelScope.launch { + _courseImage.emit(bitmap) + } + } ) _dataReady.value = courseStructure.start?.let { start -> start < Date() @@ -310,53 +315,6 @@ class CourseContainerViewModel( (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) } - private fun loadCourseImage(context: Context, imageUrl: String) { - val loader = ImageLoader(context) - val request = ImageRequest.Builder(context) - .data(imageUrl) - .target { result -> - viewModelScope.launch { - val bitmap = (result as BitmapDrawable).bitmap - _courseImage.emit( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - bitmap - } else { - blurBitmap(context, bitmap, 10f) - } - ) - } - } - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) - .allowHardware(false) - .build() - loader.enqueue(request) - } - - private fun blurBitmap( - context: Context, - bitmap: Bitmap, - blurRadio: Float - ): Bitmap { - val renderScript = RenderScript.create(context) - val bitmapAlloc = Allocation.createFromBitmap(renderScript, bitmap) - ScriptIntrinsicBlur.create(renderScript, bitmapAlloc.element).apply { - setRadius(blurRadio) - setInput(bitmapAlloc) - repeat(3) { - forEach(bitmapAlloc) - } - } - val newBitmap: Bitmap = Bitmap.createBitmap( - bitmap.width, - bitmap.height, - Bitmap.Config.ARGB_8888 - ) - bitmapAlloc.copyTo(newBitmap) - renderScript.destroy() - return newBitmap - } - private fun courseDashboardViewed() { logCourseContainerEvent(CourseAnalyticsEvent.DASHBOARD) } 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 0dc214f81..297c085d0 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 @@ -1,5 +1,6 @@ package org.openedx.course.presentation.container +import android.graphics.Bitmap import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.mockk.coEvery import io.mockk.coVerify @@ -21,6 +22,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.ImageProcessor import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.model.User @@ -58,6 +60,8 @@ class CourseContainerViewModelTest { private val analytics = mockk() private val corePreferences = mockk() private val coursePreferences = mockk() + private val mockBitmap = mockk() + private val imageProcessor = mockk() private val openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -112,6 +116,9 @@ class CourseContainerViewModelTest { every { corePreferences.appConfig } returns appConfig every { notifier.notifier } returns emptyFlow() every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle + every { config.getApiHostURL() } returns "baseUrl" + every { imageProcessor.loadImage(any(), any()) } returns Unit + every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap } @After @@ -134,6 +141,7 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() @@ -146,7 +154,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.showProgress.value) assert(viewModel.dataReady.value == null) } @@ -165,6 +173,7 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() @@ -177,7 +186,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.showProgress.value) assert(viewModel.dataReady.value == null) } @@ -196,6 +205,7 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit @@ -208,7 +218,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + assert(!viewModel.showProgress.value) assert(viewModel.dataReady.value != null) } @@ -227,6 +237,7 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor ) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit @@ -240,7 +251,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + assert(!viewModel.showProgress.value) assert(viewModel.dataReady.value != null) } @@ -259,6 +270,7 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor ) coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -269,7 +281,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.showProgress.value) } @Test @@ -287,6 +299,7 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor ) coEvery { interactor.preloadCourseStructure(any()) } throws Exception() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -297,7 +310,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.showProgress.value) } @Test @@ -315,6 +328,7 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor ) coEvery { interactor.preloadCourseStructure(any()) } returns Unit coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -324,6 +338,6 @@ class CourseContainerViewModelTest { coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + assert(!viewModel.showProgress.value) } } From ada422d1370370922210a30425b60df8f190bcfb Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Apr 2024 16:29:12 +0300 Subject: [PATCH 09/18] fix: auto scroll fling fix --- .../container/CollapsingLayout.kt | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) 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 7ed09e882..52725dc16 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 @@ -4,7 +4,6 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -55,6 +54,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize @@ -118,7 +118,7 @@ internal fun CollapsingLayout( val newOffset = (oldOffset + delta).coerceIn(-expandedTopHeight - backgroundImageHeight + collapsedTopHeight, 0f) coroutineScope.launch { - offset.animateTo(newOffset, tween(0)) + offset.snapTo(newOffset) } return Offset(0f, newOffset - oldOffset) } @@ -160,25 +160,26 @@ internal fun CollapsingLayout( val yEnd = change.position.y val yDelta = yEnd - yStart val scrollDown = yDelta > 0 - val collapsedOffset = -expandedTopHeight - backgroundImageHeight + collapsedTopHeight + val collapsedOffset = + -expandedTopHeight - backgroundImageHeight + collapsedTopHeight val expandedOffset = 0f - if (scrollDown) { - if (offset.value > -backgroundImageHeight * 0.85) { - launch { - offset.animateTo(expandedOffset) - } - } else { - launch { - offset.animateTo(collapsedOffset) - } + + launch { + // Handle Fling, offset.animateTo does not work if the value changes faster than 10ms + if (change.uptimeMillis - change.previousUptimeMillis <= 15) { + delay(10) } - } else { - if (offset.value < -backgroundImageHeight * 0.15) { - launch { + + if (scrollDown) { + if (offset.value > -backgroundImageHeight * 0.85) { + offset.animateTo(expandedOffset) + } else { offset.animateTo(collapsedOffset) } } else { - launch { + if (offset.value < -backgroundImageHeight * 0.15) { + offset.animateTo(collapsedOffset) + } else { offset.animateTo(expandedOffset) } } From a6ab821a60a54aa725e9738bbe93a6f369308b89 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 8 Apr 2024 14:41:08 +0300 Subject: [PATCH 10/18] feat: CourseContainerFragment refactoring --- .../java/org/openedx/core/ImageProcessor.kt | 9 +- .../container/CourseContainerFragment.kt | 285 ++---------------- .../container/CourseContainerViewModel.kt | 1 + .../{TabContent.kt => CourseHomeTabs.kt} | 2 +- .../presentation/dates/CourseDatesScreen.kt | 94 ++++++ .../outline/CourseOutlineScreen.kt | 121 +++++++- .../course/presentation/ui/CourseVideosUI.kt | 111 ++++++- .../container/CourseContainerViewModelTest.kt | 2 +- 8 files changed, 351 insertions(+), 274 deletions(-) rename course/src/main/java/org/openedx/course/presentation/container/{TabContent.kt => CourseHomeTabs.kt} (99%) diff --git a/core/src/main/java/org/openedx/core/ImageProcessor.kt b/core/src/main/java/org/openedx/core/ImageProcessor.kt index 758fbe9f6..901ec9f5e 100644 --- a/core/src/main/java/org/openedx/core/ImageProcessor.kt +++ b/core/src/main/java/org/openedx/core/ImageProcessor.kt @@ -6,19 +6,20 @@ import android.graphics.drawable.Drawable import android.renderscript.Allocation import android.renderscript.RenderScript import android.renderscript.ScriptIntrinsicBlur +import androidx.annotation.DrawableRes import coil.ImageLoader import coil.request.ImageRequest class ImageProcessor(private val context: Context) { - fun loadImage(imageUrl: String, onComplete: (result: Drawable) -> Unit) { + fun loadImage(imageUrl: String, @DrawableRes defaultImage: Int, onComplete: (result: Drawable) -> Unit) { val loader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(imageUrl) .target { result -> onComplete(result) } - .error(R.drawable.core_no_image_course) - .placeholder(R.drawable.core_no_image_course) + .error(defaultImage) + .placeholder(defaultImage) .allowHardware(false) .build() loader.enqueue(request) @@ -46,4 +47,4 @@ class ImageProcessor(private val context: Context) { renderScript.destroy() return newBitmap } -} \ No newline at end of file +} 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 7fa3063e1..aa510380a 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 @@ -17,10 +17,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.AndroidUriHandler import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.core.view.isVisible @@ -28,17 +28,12 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType import org.openedx.core.extension.takeIfNotEmpty -import org.openedx.core.presentation.CoreAnalyticsScreen -import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.global.viewBinding -import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.course.R @@ -48,20 +43,16 @@ import org.openedx.course.presentation.calendarsync.CalendarSyncDialog import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.dates.CourseDatesScreen import org.openedx.course.presentation.dates.CourseDatesViewModel -import org.openedx.course.presentation.dates.DatesUIState import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.outline.CourseOutlineScreen -import org.openedx.course.presentation.outline.CourseOutlineUIState import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.videos.CourseVideoViewModel -import org.openedx.course.presentation.videos.CourseVideosUIState import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen import org.openedx.discussion.presentation.topics.DiscussionTopicsUIState import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel -import java.io.File class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -167,18 +158,21 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun onRefresh(currentPage: Int) { - when(currentPage) { + when (currentPage) { 0 -> { courseOutlineViewModel.setIsUpdating() updateCourseStructure(true) } + 1 -> { courseVideoViewModel.setIsUpdating() updateCourseStructure(true) } + 2 -> { courseDatesViewModel.getCourseDates(swipeToRefresh = true) } + 3 -> { discussionTopicsViewModel.updateCourseTopics() } @@ -215,7 +209,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) }, navigation = { - Tabs(pagerState = pagerState, viewModel::courseContainerTabClickedEvent) + CourseHomeTabs(pagerState = pagerState, viewModel::courseContainerTabClickedEvent) }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() @@ -227,288 +221,57 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) { page -> when (page) { 0 -> { - val uiState by courseOutlineViewModel.uiState.observeAsState(CourseOutlineUIState.Loading) - val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) - + val scope = rememberCoroutineScope() CourseOutlineScreen( windowSize = windowSize, - uiState = uiState, - apiHostUrl = courseOutlineViewModel.apiHostUrl, - isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, - uiMessage = uiMessage, - hasInternetConnection = courseOutlineViewModel.hasInternetConnection, + courseOutlineViewModel = courseOutlineViewModel, + courseRouter = courseRouter, + fragmentManager = requireActivity().supportFragmentManager, onReloadClick = { updateCourseStructure(false) }, - onItemClick = { block -> - courseOutlineViewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - courseRouter.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.FULL - ) - }, - onExpandClick = { block -> - if (courseOutlineViewModel.switchCourseSections(block.id)) { - courseOutlineViewModel.sequentialClickedEvent( - block.blockId, - block.displayName - ) - } - }, - onSubSectionClick = { subSectionBlock -> - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( - unit.blockId, - unit.displayName - ) - courseRouter.navigateToCourseContainer( - requireActivity().supportFragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.FULL - ) - } - }, - onResumeClick = { componentId -> - courseOutlineViewModel.resumeSectionBlock?.let { subSection -> - courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) - courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } else { - courseRouter.navigateToCourseSubsections( - requireActivity().supportFragmentManager, - courseId = courseOutlineViewModel.courseId, - subSectionId = subSection.id, - mode = CourseViewMode.FULL, - unitId = unit.id, - componentId = componentId - ) - } - } - } - }, - onDownloadClick = { - if (courseOutlineViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - courseOutlineViewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { - courseOutlineViewModel.removeDownloadModels(it.id) - } else { - courseOutlineViewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, onResetDatesClick = { courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { updateCourseDates() }) }, onViewDates = { - runBlocking { + scope.launch { pagerState.animateScrollToPage(4) } - }, - onCertificateClick = { - courseOutlineViewModel.viewCertificateTappedEvent() - it.takeIfNotEmpty() - ?.let { url -> AndroidUriHandler(requireContext()).openUri(url) } } ) } 1 -> { - val uiState by courseVideoViewModel.uiState.observeAsState(CourseVideosUIState.Loading) - val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) - val videoSettings by courseVideoViewModel.videoSettings.collectAsState() - CourseVideosScreen( windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - courseTitle = courseVideoViewModel.courseTitle, - apiHostUrl = courseVideoViewModel.apiHostUrl, - isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, - hasInternetConnection = courseVideoViewModel.hasInternetConnection, - videoSettings = videoSettings, + courseVideoViewModel = courseVideoViewModel, + fragmentManager = requireActivity().supportFragmentManager, + courseRouter = courseRouter, onReloadClick = { updateCourseStructure(false) - }, - onItemClick = { block -> - courseRouter.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - courseId = courseVideoViewModel.courseId, - subSectionId = block.id, - mode = CourseViewMode.VIDEOS - ) - }, - onExpandClick = { block -> - courseVideoViewModel.switchCourseSections(block.id) - }, - onSubSectionClick = { subSectionBlock -> - courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseVideoViewModel.sequentialClickedEvent( - unit.blockId, - unit.displayName - ) - courseRouter.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = courseVideoViewModel.courseId, - unitId = unit.id, - mode = CourseViewMode.VIDEOS - ) - } - }, - onDownloadClick = { - if (courseVideoViewModel.isBlockDownloading(it.id)) { - courseRouter.navigateToDownloadQueue( - fm = requireActivity().supportFragmentManager, - courseVideoViewModel.getDownloadableChildren(it.id) - ?: arrayListOf() - ) - } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { - courseVideoViewModel.removeDownloadModels(it.id) - } else { - courseVideoViewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id - ) - } - }, - onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) - if (isAllBlocksDownloadedOrDownloading) { - courseVideoViewModel.removeAllDownloadModels() - } else { - courseVideoViewModel.saveAllDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_") - ) - } - }, - onDownloadQueueClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseRouter.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) - } - }, - onVideoDownloadQualityClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseVideoViewModel.onChangingVideoQualityWhileDownloading() - } else { - courseRouter.navigateToVideoQuality( - requireActivity().supportFragmentManager, - VideoQualityType.Download - ) - } } ) } 2 -> { - val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) - val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) - val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() - CourseDatesScreen( windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - isSelfPaced = courseDatesViewModel.isSelfPaced, - hasInternetConnection = courseDatesViewModel.hasInternetConnection, - calendarSyncUIState = calendarSyncUIState, - onReloadClick = { - courseDatesViewModel.getCourseDates() - }, - onItemClick = { block -> - if (block.blockId.isNotEmpty()) { - courseDatesViewModel.getVerticalBlock(block.blockId) - ?.let { verticalBlock -> - courseDatesViewModel.logCourseComponentTapped(true, block) - if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( - fm = requireActivity().supportFragmentManager, - courseId = courseDatesViewModel.courseId, - unitId = verticalBlock.id, - componentId = "", - mode = CourseViewMode.FULL - ) - } else { - courseDatesViewModel.getSequentialBlock(verticalBlock.id) - ?.let { sequentialBlock -> - courseRouter.navigateToCourseSubsections( - fm = requireActivity().supportFragmentManager, - subSectionId = sequentialBlock.id, - courseId = courseDatesViewModel.courseId, - unitId = verticalBlock.id, - mode = CourseViewMode.FULL - ) - } - } - } ?: { - courseDatesViewModel.logCourseComponentTapped(false, block) - ActionDialogFragment.newInstance( - title = getString(org.openedx.core.R.string.core_leaving_the_app), - message = getString( - org.openedx.core.R.string.core_leaving_the_app_message, - getString(org.openedx.core.R.string.platform_name) - ), - url = block.link, - source = CoreAnalyticsScreen.COURSE_DATES.screenName - ).show( - requireActivity().supportFragmentManager, - ActionDialogFragment::class.simpleName - ) - - } - } - }, - onPLSBannerViewed = { - if (isResumed) { - courseDatesViewModel.logPlsBannerViewed() - } - }, - onSyncDates = { - courseDatesViewModel.logPlsShiftButtonClicked() - courseDatesViewModel.resetCourseDatesBanner { - courseDatesViewModel.logPlsShiftDates(it) - if (it) { - updateCourseStructure(false) - } - } - }, - onCalendarSyncSwitch = { isChecked -> - courseDatesViewModel.handleCalendarSyncState(isChecked) - }, + courseDatesViewModel = courseDatesViewModel, + courseRouter = courseRouter, + fragmentManager = requireActivity().supportFragmentManager, + isFragmentResumed = isResumed, + updateCourseStructure = { + updateCourseStructure(false) + } ) } 3 -> { - val uiState by discussionTopicsViewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) + val uiState by discussionTopicsViewModel.uiState.observeAsState( + DiscussionTopicsUIState.Loading + ) val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) DiscussionTopicsScreen( 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 b86bb8f7d..14750a94a 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 @@ -138,6 +138,7 @@ class CourseContainerViewModel( _isSelfPaced = courseStructure.isSelfPaced imageProcessor.loadImage( imageUrl = config.getApiHostURL() + courseStructure.media?.image?.large, + defaultImage = CoreR.drawable.core_no_image_course, onComplete = { drawable -> val bitmap = (drawable as BitmapDrawable).bitmap.apply { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { diff --git a/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt similarity index 99% rename from course/src/main/java/org/openedx/course/presentation/container/TabContent.kt rename to course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt index cee5bc5b6..1b76a265a 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/TabContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt @@ -41,7 +41,7 @@ import org.openedx.course.R @OptIn(ExperimentalFoundationApi::class) @Composable -fun Tabs( +fun CourseHomeTabs( pagerState: PagerState, onPageChange: (Int) -> Unit ) { diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 0aca331d5..1c8fb9eb0 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -46,7 +46,9 @@ import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState 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 @@ -66,6 +68,7 @@ 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 +import androidx.fragment.app.FragmentManager import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock @@ -73,6 +76,9 @@ 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.CoreAnalyticsScreen +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.WindowSize @@ -87,6 +93,7 @@ import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R +import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet @@ -94,6 +101,93 @@ import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as coreR +@Composable +fun CourseDatesScreen( + windowSize: WindowSize, + courseDatesViewModel: CourseDatesViewModel, + courseRouter: CourseRouter, + fragmentManager: FragmentManager, + isFragmentResumed: Boolean, + updateCourseStructure: () -> Unit +) { + val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) + val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) + val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() + val context = LocalContext.current + + CourseDatesScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + isSelfPaced = courseDatesViewModel.isSelfPaced, + hasInternetConnection = courseDatesViewModel.hasInternetConnection, + calendarSyncUIState = calendarSyncUIState, + onReloadClick = { + courseDatesViewModel.getCourseDates() + }, + onItemClick = { block -> + if (block.blockId.isNotEmpty()) { + courseDatesViewModel.getVerticalBlock(block.blockId) + ?.let { verticalBlock -> + courseDatesViewModel.logCourseComponentTapped(true, block) + if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseDatesViewModel.courseId, + unitId = verticalBlock.id, + componentId = "", + mode = CourseViewMode.FULL + ) + } else { + courseDatesViewModel.getSequentialBlock(verticalBlock.id) + ?.let { sequentialBlock -> + courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + subSectionId = sequentialBlock.id, + courseId = courseDatesViewModel.courseId, + unitId = verticalBlock.id, + mode = CourseViewMode.FULL + ) + } + } + } ?: { + courseDatesViewModel.logCourseComponentTapped(false, block) + ActionDialogFragment.newInstance( + title = context.getString(org.openedx.core.R.string.core_leaving_the_app), + message = context.getString( + org.openedx.core.R.string.core_leaving_the_app_message, + context.getString(org.openedx.core.R.string.platform_name) + ), + url = block.link, + source = CoreAnalyticsScreen.COURSE_DATES.screenName + ).show( + fragmentManager, + ActionDialogFragment::class.simpleName + ) + + } + } + }, + onPLSBannerViewed = { + if (isFragmentResumed) { + courseDatesViewModel.logPlsBannerViewed() + } + }, + onSyncDates = { + courseDatesViewModel.logPlsShiftButtonClicked() + courseDatesViewModel.resetCourseDatesBanner { + courseDatesViewModel.logPlsShiftDates(it) + if (it) { + updateCourseStructure() + } + } + }, + onCalendarSyncSwitch = { isChecked -> + courseDatesViewModel.handleCalendarSyncState(isChecked) + }, + ) +} + @Composable internal fun CourseDatesScreen( windowSize: WindowSize, 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 115d622c1..1a2f1fe3b 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 @@ -31,13 +31,17 @@ import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState 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.AndroidUriHandler +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -45,6 +49,7 @@ 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 +import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType import org.openedx.core.R import org.openedx.core.UIMessage @@ -53,6 +58,8 @@ 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.extension.takeIfNotEmpty +import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton @@ -65,12 +72,14 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.course.DatesShiftedSnackBar +import org.openedx.course.presentation.CourseRouter 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.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem import org.openedx.course.presentation.ui.DatesShiftedSnackBar +import java.io.File import java.util.Date fun getUnitBlockIcon(block: Block): Int { @@ -82,11 +91,119 @@ fun getUnitBlockIcon(block: Block): Int { } } +@Composable +fun CourseOutlineScreen( + windowSize: WindowSize, + courseOutlineViewModel: CourseOutlineViewModel, + courseRouter: CourseRouter, + fragmentManager: FragmentManager, + onReloadClick: () -> Unit, + onResetDatesClick: () -> Unit, + onViewDates: () -> Unit +) { + val uiState by courseOutlineViewModel.uiState.observeAsState(CourseOutlineUIState.Loading) + val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + val context = LocalContext.current + + CourseOutlineScreen( + windowSize = windowSize, + uiState = uiState, + isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, + uiMessage = uiMessage, + hasInternetConnection = courseOutlineViewModel.hasInternetConnection, + onReloadClick = onReloadClick, + onItemClick = { block -> + courseOutlineViewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = courseOutlineViewModel.courseId, + subSectionId = block.id, + mode = CourseViewMode.FULL + ) + }, + onExpandClick = { block -> + if (courseOutlineViewModel.switchCourseSections(block.id)) { + courseOutlineViewModel.sequentialClickedEvent( + block.blockId, + block.displayName + ) + } + }, + onSubSectionClick = { subSectionBlock -> + courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseOutlineViewModel.logUnitDetailViewedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + fragmentManager, + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.FULL + ) + } + }, + onResumeClick = { componentId -> + courseOutlineViewModel.resumeSectionBlock?.let { subSection -> + courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) + courseOutlineViewModel.resumeVerticalBlock?.let { unit -> + if (courseOutlineViewModel.isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseOutlineViewModel.courseId, + unitId = unit.id, + componentId = componentId, + mode = CourseViewMode.FULL + ) + } else { + courseRouter.navigateToCourseSubsections( + fragmentManager, + courseId = courseOutlineViewModel.courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = componentId + ) + } + } + } + }, + onDownloadClick = { + if (courseOutlineViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + courseOutlineViewModel.getDownloadableChildren(it.id) + ?: arrayListOf() + ) + } else if (courseOutlineViewModel.isBlockDownloaded(it.id)) { + courseOutlineViewModel.removeDownloadModels(it.id) + } else { + courseOutlineViewModel.saveDownloadModels( + context.externalCacheDir.toString() + + File.separator + + context + .getString(R.string.app_name) + .replace(Regex("\\s"), "_"), it.id + ) + } + }, + onResetDatesClick = onResetDatesClick, + onViewDates = onViewDates, + onCertificateClick = { + courseOutlineViewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(context).openUri(url) } + } + ) +} + @Composable internal fun CourseOutlineScreen( windowSize: WindowSize, uiState: CourseOutlineUIState, - apiHostUrl: String, isCourseNestedListEnabled: Boolean, uiMessage: UIMessage?, hasInternetConnection: Boolean, @@ -449,7 +566,6 @@ private fun CourseOutlineScreenPreview() { hasEnded = false ) ), - apiHostUrl = "", isCourseNestedListEnabled = true, uiMessage = null, hasInternetConnection = true, @@ -488,7 +604,6 @@ private fun CourseOutlineScreenTabletPreview() { hasEnded = false ) ), - apiHostUrl = "", isCourseNestedListEnabled = true, uiMessage = null, hasInternetConnection = true, diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index f38fc1d7c..599b98fd3 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 @@ -40,19 +40,23 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState 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.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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 +import androidx.fragment.app.FragmentManager import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.UIMessage @@ -63,6 +67,8 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.toFileSize import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.WindowSize @@ -73,16 +79,116 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue import org.openedx.course.R +import org.openedx.course.presentation.CourseRouter +import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState +import java.io.File import java.util.Date +@Composable +fun CourseVideosScreen( + windowSize: WindowSize, + courseVideoViewModel: CourseVideoViewModel, + fragmentManager: FragmentManager, + courseRouter: CourseRouter, + onReloadClick: () -> Unit +) { + val uiState by courseVideoViewModel.uiState.observeAsState(CourseVideosUIState.Loading) + val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) + val videoSettings by courseVideoViewModel.videoSettings.collectAsState() + val context = LocalContext.current + + CourseVideosScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + courseTitle = courseVideoViewModel.courseTitle, + isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, + hasInternetConnection = courseVideoViewModel.hasInternetConnection, + videoSettings = videoSettings, + onReloadClick = onReloadClick, + onItemClick = { block -> + courseRouter.navigateToCourseSubsections( + fm = fragmentManager, + courseId = courseVideoViewModel.courseId, + subSectionId = block.id, + mode = CourseViewMode.VIDEOS + ) + }, + onExpandClick = { block -> + courseVideoViewModel.switchCourseSections(block.id) + }, + onSubSectionClick = { subSectionBlock -> + courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + courseVideoViewModel.sequentialClickedEvent( + unit.blockId, + unit.displayName + ) + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseVideoViewModel.courseId, + unitId = unit.id, + mode = CourseViewMode.VIDEOS + ) + } + }, + onDownloadClick = { + if (courseVideoViewModel.isBlockDownloading(it.id)) { + courseRouter.navigateToDownloadQueue( + fm = fragmentManager, + courseVideoViewModel.getDownloadableChildren(it.id) + ?: arrayListOf() + ) + } else if (courseVideoViewModel.isBlockDownloaded(it.id)) { + courseVideoViewModel.removeDownloadModels(it.id) + } else { + courseVideoViewModel.saveDownloadModels( + context.externalCacheDir.toString() + + File.separator + + context + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_"), it.id + ) + } + }, + onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> + courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + if (isAllBlocksDownloadedOrDownloading) { + courseVideoViewModel.removeAllDownloadModels() + } else { + courseVideoViewModel.saveAllDownloadModels( + context.externalCacheDir.toString() + + File.separator + + context + .getString(org.openedx.core.R.string.app_name) + .replace(Regex("\\s"), "_") + ) + } + }, + onDownloadQueueClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseRouter.navigateToDownloadQueue(fm = fragmentManager) + } + }, + onVideoDownloadQualityClick = { + if (courseVideoViewModel.hasDownloadModelsInQueue()) { + courseVideoViewModel.onChangingVideoQualityWhileDownloading() + } else { + courseRouter.navigateToVideoQuality( + fragmentManager, + VideoQualityType.Download + ) + } + } + ) +} + @Composable fun CourseVideosScreen( windowSize: WindowSize, uiState: CourseVideosUIState, uiMessage: UIMessage?, courseTitle: String, - apiHostUrl: String, isCourseNestedListEnabled: Boolean, hasInternetConnection: Boolean, videoSettings: VideoSettings, @@ -638,7 +744,6 @@ private fun CourseVideosScreenPreview() { ) ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, @@ -666,7 +771,6 @@ private fun CourseVideosScreenEmptyPreview() { "This course does not include any videos." ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, @@ -705,7 +809,6 @@ private fun CourseVideosScreenTabletPreview() { ) ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, onItemClick = { }, onExpandClick = { }, 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 297c085d0..47d5db4e3 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 @@ -117,7 +117,7 @@ class CourseContainerViewModelTest { every { notifier.notifier } returns emptyFlow() every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle every { config.getApiHostURL() } returns "baseUrl" - every { imageProcessor.loadImage(any(), any()) } returns Unit + every { imageProcessor.loadImage(any(), any(), any()) } returns Unit every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap } From e079dedc0b36a35a9a4ca2f56fb1a371af8e1dda Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 10 Apr 2024 17:52:30 +0300 Subject: [PATCH 11/18] fix: Removed expanded header content top padding --- .../container/CollapsingLayout.kt | 49 ++++++++++++------- .../container/CourseContainerFragment.kt | 3 -- .../presentation/container/CourseHomeTabs.kt | 2 +- .../presentation/container/HeaderContent.kt | 7 ++- 4 files changed, 37 insertions(+), 24 deletions(-) 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 52725dc16..f0b833879 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 @@ -90,12 +90,12 @@ internal fun CollapsingLayout( val coroutineScope = rememberCoroutineScope() val configuration = LocalConfiguration.current val imageHeight = 200 - val factor = (-imageHeight - offset.value) / -imageHeight - val alpha = if (factor.isNaN() || factor < 0) 0f else factor + val rawFactor = (-imageHeight - offset.value) / -imageHeight + val factor = if (rawFactor.isNaN() || rawFactor < 0) 0f else rawFactor val blurImagePadding = 40.dp - val toolbarOffset = - with(localDensity) { (offset.value + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() } - val imageStartY = with(localDensity) { backgroundImageHeight - blurImagePadding.toPx() } * 0.5f + val blurImagePaddingPx = with(localDensity) { blurImagePadding.toPx() } + val toolbarOffset = (offset.value + backgroundImageHeight - blurImagePaddingPx).roundToInt() + val imageStartY = (backgroundImageHeight - blurImagePaddingPx) * 0.5f val imageOffsetY = -(offset.value + imageStartY) val toolbarBackgroundOffset = if (toolbarOffset >= 0) { toolbarOffset @@ -115,8 +115,15 @@ internal fun CollapsingLayout( fun calculateOffset(delta: Float): Offset { val oldOffset = offset.value - val newOffset = - (oldOffset + delta).coerceIn(-expandedTopHeight - backgroundImageHeight + collapsedTopHeight, 0f) + val maxValue = 0f + val minValue = (-expandedTopHeight - backgroundImageHeight + collapsedTopHeight).let { + if (it >= maxValue) { + 0f + } else { + it + } + } + val newOffset = (oldOffset + delta).coerceIn(minValue, maxValue) coroutineScope.launch { offset.snapTo(newOffset) } @@ -207,7 +214,7 @@ internal fun CollapsingLayout( .offset { IntOffset( x = 0, - y = with(localDensity) { (backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + y = (backgroundImageHeight - blurImagePaddingPx).roundToInt()) } .background(Color.White) .blur(100.dp) @@ -276,7 +283,7 @@ internal fun CollapsingLayout( .offset { IntOffset( x = 0, - y = with(localDensity) { (backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + y = (backgroundImageHeight - blurImagePaddingPx).roundToInt() ) } .background( brush = Brush.verticalGradient( @@ -460,11 +467,12 @@ internal fun CollapsingLayout( .background(Color.White) .blur(100.dp) ) { + val adaptiveBlurImagePadding = blurImagePadding.value * (3 - rawFactor) Box( modifier = Modifier .background(MaterialTheme.appColors.surface) .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) + .height(with(localDensity) { (expandedTopHeight + navigationHeight + adaptiveBlurImagePadding).toDp() }) .align(Alignment.Center) ) Image( @@ -525,7 +533,7 @@ internal fun CollapsingLayout( .offset { IntOffset( x = 0, - y = with(localDensity) { (offset.value + backgroundImageHeight - blurImagePadding.toPx()).roundToInt() }) + y = (offset.value + backgroundImageHeight - blurImagePaddingPx).roundToInt() ) } .background( brush = Brush.verticalGradient( @@ -542,8 +550,13 @@ internal fun CollapsingLayout( .onSizeChanged { size -> expandedTopHeight = size.height.toFloat() } - .offset { IntOffset(x = 0, y = (offset.value + backgroundImageHeight).roundToInt()) } - .alpha(alpha), + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight - blurImagePaddingPx).roundToInt() + ) + } + .alpha(factor), content = expandedTop, ) @@ -561,7 +574,7 @@ internal fun CollapsingLayout( .statusBarsInset() .padding(top = 12.dp, start = backBtnStartPadding) .clip(CircleShape) - .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(alpha / 2)) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(factor / 2)) .clickable { onBackClick() }, @@ -572,18 +585,18 @@ internal fun CollapsingLayout( Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier - .alpha(1 - alpha), + .alpha(1 - factor), content = collapsedTop, ) } - + val adaptiveImagePadding = blurImagePaddingPx * factor Box( modifier = Modifier .offset { IntOffset( x = 0, - y = (offset.value + backgroundImageHeight + expandedTopHeight).roundToInt() + y = (offset.value + backgroundImageHeight + expandedTopHeight - adaptiveImagePadding).roundToInt() ) } .onSizeChanged { size -> @@ -597,7 +610,7 @@ internal fun CollapsingLayout( .offset { IntOffset( x = 0, - y = (expandedTopHeight + offset.value + backgroundImageHeight + navigationHeight).roundToInt() + y = (expandedTopHeight + offset.value + backgroundImageHeight + navigationHeight - blurImagePaddingPx * factor).roundToInt() ) } .padding(bottom = with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }), 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 aa510380a..f80a0de6d 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 @@ -4,7 +4,6 @@ import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.pager.HorizontalPager @@ -20,7 +19,6 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.core.view.isVisible @@ -216,7 +214,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { }, bodyContent = { HorizontalPager( - modifier = Modifier.background(Color.White), state = pagerState ) { page -> when (page) { diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt index 1b76a265a..97da537b9 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt @@ -63,7 +63,7 @@ fun CourseHomeTabs( LazyRow( modifier = Modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 12.dp, horizontal = horizontalPadding), + contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), ) { itemsIndexed(list) { index, item -> val backgroundColor = if (pagerState.currentPage == index) { diff --git a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt index d3aa6975a..a2070eb66 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -31,7 +31,8 @@ internal fun ExpandedHeaderContent( Column( modifier .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = 8.dp) + .padding(horizontal = horizontalPadding) + .padding(top = 16.dp) ) { Text( modifier = Modifier @@ -45,7 +46,9 @@ internal fun ExpandedHeaderContent( .fillMaxWidth(), color = MaterialTheme.appColors.textDark, text = courseTitle, - style = MaterialTheme.appTypography.titleLarge + style = MaterialTheme.appTypography.titleLarge, + overflow = TextOverflow.Ellipsis, + maxLines = 3 ) } } From 3427c994d51e8010f0e0e5d4f998741ef7c82467 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 15 Apr 2024 18:36:03 +0300 Subject: [PATCH 12/18] fix: Collapsing header and navigation tabs UI fixes --- .../presentation/container/CollapsingLayout.kt | 4 ++-- .../container/CourseContainerFragment.kt | 15 +++++++++++++-- .../presentation/container/CourseHomeTabs.kt | 12 ++++++++---- course/src/main/res/values-uk/strings.xml | 2 +- course/src/main/res/values/strings.xml | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) 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 f0b833879..931c3717f 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 @@ -173,8 +173,8 @@ internal fun CollapsingLayout( launch { // Handle Fling, offset.animateTo does not work if the value changes faster than 10ms - if (change.uptimeMillis - change.previousUptimeMillis <= 15) { - delay(10) + if (change.uptimeMillis - change.previousUptimeMillis <= 50) { + delay(50) } if (scrollDown) { 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 f80a0de6d..04e39d219 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 @@ -6,6 +6,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.ExperimentalMaterialApi @@ -184,11 +185,17 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { val refreshing by viewModel.showProgress.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val pagerState = rememberPagerState(pageCount = { 5 }) + val tabState = rememberLazyListState() + val windowSize = rememberWindowSize() val pullRefreshState = rememberPullRefreshState( refreshing = refreshing, onRefresh = { onRefresh(pagerState.currentPage) } ) - val windowSize = rememberWindowSize() + + LaunchedEffect(pagerState.currentPage) { + tabState.animateScrollToItem(pagerState.currentPage) + } + Box { CollapsingLayout( modifier = Modifier @@ -207,7 +214,11 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) }, navigation = { - CourseHomeTabs(pagerState = pagerState, viewModel::courseContainerTabClickedEvent) + CourseHomeTabs( + rowState = tabState, + pagerState = pagerState, + onPageChange = viewModel::courseContainerTabClickedEvent + ) }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt index 97da537b9..1ee0b6404 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt @@ -12,8 +12,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon @@ -22,8 +24,8 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet -import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope @@ -43,12 +45,13 @@ import org.openedx.course.R @Composable fun CourseHomeTabs( pagerState: PagerState, + rowState: LazyListState = rememberLazyListState(), onPageChange: (Int) -> Unit ) { val list = listOf( - stringResource(id = R.string.course_navigation_course) to rememberVectorPainter(Icons.Default.Home), + stringResource(id = R.string.course_navigation_home) to rememberVectorPainter(Icons.Default.Home), stringResource(id = R.string.course_navigation_videos) to rememberVectorPainter(Icons.Rounded.PlayCircleFilled), - stringResource(id = R.string.course_navigation_dates) to rememberVectorPainter(Icons.Default.CalendarMonth), + stringResource(id = R.string.course_navigation_dates) to rememberVectorPainter(Icons.Outlined.CalendarMonth), stringResource(id = R.string.course_navigation_discussions) to rememberVectorPainter(Icons.AutoMirrored.Filled.Chat), stringResource(id = R.string.course_navigation_more) to rememberVectorPainter(Icons.AutoMirrored.Filled.TextSnippet) ) @@ -62,6 +65,7 @@ fun CourseHomeTabs( } LazyRow( modifier = Modifier, + state = rowState, horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), ) { @@ -92,7 +96,7 @@ fun CourseHomeTabs( .background(backgroundColor) .clickable { scope.launch { - pagerState.animateScrollToPage(index) + pagerState.scrollToPage(index) onPageChange(index) } } diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index e7799bdbf..94bcfd73e 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -30,7 +30,7 @@ Повернутись до модуля Цей курс ще не розпочався. Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. - Курс + Курс Відео Обговорення Матеріали diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 002fc06a1..8cbba765d 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -30,7 +30,7 @@ Next section This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. - Course + Home Videos Discussions More From 3bf503d4623594a95e3b68a65a94fd2b97f70827 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 23 Apr 2024 18:59:20 +0300 Subject: [PATCH 13/18] fix: Fixes according to PR feedback --- .../java/org/openedx/app/di/ScreenModule.kt | 2 - .../module/download/BaseDownloadViewModel.kt | 4 +- .../system/notifier/CourseDatesShifted.kt | 3 + .../core/system/notifier/CourseNotifier.kt | 1 + .../org/openedx/core/ui/theme/Colors.kt | 2 +- .../course/presentation/CourseAnalytics.kt | 2 +- .../container/CollapsingLayout.kt | 853 ++++++++++-------- .../container/CourseContainerFragment.kt | 388 ++++---- .../container/CourseContainerViewModel.kt | 44 +- .../presentation/container/CourseHomeTabs.kt | 24 +- .../presentation/dates/CourseDatesScreen.kt | 66 +- .../dates/CourseDatesViewModel.kt | 20 +- .../outline/CourseOutlineScreen.kt | 74 +- .../outline/CourseOutlineViewModel.kt | 30 +- .../course/presentation/ui/CourseVideosUI.kt | 43 +- .../videos/CourseVideoViewModel.kt | 23 +- .../container/CourseContainerViewModelTest.kt | 14 +- .../dates/CourseDatesViewModelTest.kt | 14 - .../outline/CourseOutlineViewModelTest.kt | 26 +- .../section/CourseSectionViewModelTest.kt | 33 +- .../videos/CourseVideoViewModelTest.kt | 5 - .../topics/DiscussionTopicsFragment.kt | 49 +- .../topics/DiscussionTopicsViewModel.kt | 12 - .../topics/DiscussionTopicsViewModelTest.kt | 34 +- 24 files changed, 865 insertions(+), 901 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseDatesShifted.kt 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 2f30652a4..164bd0901 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -292,7 +292,6 @@ val screenModule = module { get(), get(), get(), - get(), ) } viewModel { (courseId: String, handoutsType: String) -> @@ -314,7 +313,6 @@ val screenModule = module { get(), get(), get(), - get(), courseId, courseName ) diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index b6e8a33dc..64d9961d8 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -1,6 +1,5 @@ package org.openedx.core.module.download -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -41,8 +40,7 @@ abstract class BaseDownloadViewModel( private val _downloadingModelsFlow = MutableSharedFlow>() protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) + init { viewModelScope.launch { downloadDao.readAllData().map { list -> list.map { it.mapToDomain() } } .collect { downloadModels -> diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseDatesShifted.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseDatesShifted.kt new file mode 100644 index 000000000..fe3cf54c0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseDatesShifted.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object CourseDatesShifted : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index 455d6e53c..f55beaeee 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -16,4 +16,5 @@ class CourseNotifier { suspend fun send(event: CourseSectionChanged) = channel.emit(event) suspend fun send(event: CourseCompletionSet) = channel.emit(event) suspend fun send(event: CalendarSyncEvent) = channel.emit(event) + suspend fun send(event: CourseDatesShifted) = channel.emit(event) } diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index f2220da1b..38c9fdceb 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -107,4 +107,4 @@ val dark_component_horizontal_progress_default = Color(0xFFD6D3D1) val dark_tab_unselected_btn_background = Color(0xFF273346) val dark_tab_unselected_btn_content = Color.White val dark_course_home_header_shade = Color(0xFF999999) -val dark_course_home_back_btn_background = Color.Black \ No newline at end of file +val dark_course_home_back_btn_background = Color.Black diff --git a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt index 14de268f3..2e9998c55 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseAnalytics.kt @@ -61,7 +61,7 @@ enum class CourseAnalyticsEvent(val eventName: String, val biValue: String) { "Course:Dates Tab", "edx.bi.app.course.dates_tab" ), - HANDOUTS_TAB( + MORE_TAB( "Course:Handouts Tab", "edx.bi.app.course.handouts_tab" ), 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 931c3717f..57747acb3 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 @@ -4,6 +4,8 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -18,6 +20,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -25,11 +29,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -50,52 +53,58 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import kotlin.math.roundToInt @Composable internal fun CollapsingLayout( + modifier: Modifier = Modifier, courseImage: Bitmap, + imageHeight: Int, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, bodyContent: @Composable BoxScope.() -> Unit, onBackClick: () -> Unit, - modifier: Modifier = Modifier, ) { val localDensity = LocalDensity.current - var expandedTopHeight by remember { + val expandedTopHeight = remember { mutableFloatStateOf(0f) } - var collapsedTopHeight by remember { + val collapsedTopHeight = remember { mutableFloatStateOf(0f) } - var navigationHeight by remember { + val navigationHeight = remember { mutableFloatStateOf(0f) } val offset = remember { Animatable(0f) } - var backgroundImageHeight by remember { + val backgroundImageHeight = remember { mutableFloatStateOf(0f) } val windowSize = rememberWindowSize() val coroutineScope = rememberCoroutineScope() val configuration = LocalConfiguration.current - val imageHeight = 200 val rawFactor = (-imageHeight - offset.value) / -imageHeight val factor = if (rawFactor.isNaN() || rawFactor < 0) 0f else rawFactor val blurImagePadding = 40.dp val blurImagePaddingPx = with(localDensity) { blurImagePadding.toPx() } - val toolbarOffset = (offset.value + backgroundImageHeight - blurImagePaddingPx).roundToInt() - val imageStartY = (backgroundImageHeight - blurImagePaddingPx) * 0.5f + val toolbarOffset = (offset.value + backgroundImageHeight.floatValue - blurImagePaddingPx).roundToInt() + val imageStartY = (backgroundImageHeight.floatValue - blurImagePaddingPx) * 0.5f val imageOffsetY = -(offset.value + imageStartY) val toolbarBackgroundOffset = if (toolbarOffset >= 0) { toolbarOffset @@ -116,13 +125,14 @@ internal fun CollapsingLayout( fun calculateOffset(delta: Float): Offset { val oldOffset = offset.value val maxValue = 0f - val minValue = (-expandedTopHeight - backgroundImageHeight + collapsedTopHeight).let { - if (it >= maxValue) { - 0f - } else { - it + val minValue = + (-expandedTopHeight.floatValue - backgroundImageHeight.floatValue + collapsedTopHeight.floatValue).let { + if (it >= maxValue) { + 0f + } else { + it + } } - } val newOffset = (oldOffset + delta).coerceIn(minValue, maxValue) coroutineScope.launch { offset.snapTo(newOffset) @@ -135,7 +145,7 @@ internal fun CollapsingLayout( override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = when { available.y >= 0 -> Offset.Zero - offset.value == -expandedTopHeight -> Offset.Zero + offset.value == -expandedTopHeight.floatValue -> Offset.Zero else -> calculateOffset(available.y) } @@ -158,7 +168,7 @@ internal fun CollapsingLayout( .nestedScroll(nestedScrollConnection) .pointerInput(Unit) { var yStart = 0f - kotlinx.coroutines.coroutineScope { + coroutineScope { routePointerChangesTo( onDown = { change -> yStart = change.position.y @@ -168,7 +178,7 @@ internal fun CollapsingLayout( val yDelta = yEnd - yStart val scrollDown = yDelta > 0 val collapsedOffset = - -expandedTopHeight - backgroundImageHeight + collapsedTopHeight + -expandedTopHeight.floatValue - backgroundImageHeight.floatValue + collapsedTopHeight.floatValue val expandedOffset = 0f launch { @@ -178,13 +188,13 @@ internal fun CollapsingLayout( } if (scrollDown) { - if (offset.value > -backgroundImageHeight * 0.85) { + if (offset.value > -backgroundImageHeight.floatValue * 0.85) { offset.animateTo(expandedOffset) } else { offset.animateTo(collapsedOffset) } } else { - if (offset.value < -backgroundImageHeight * 0.15) { + if (offset.value < -backgroundImageHeight.floatValue * 0.15) { offset.animateTo(collapsedOffset) } else { offset.animateTo(expandedOffset) @@ -197,119 +207,309 @@ internal fun CollapsingLayout( }, ) { if (windowSize.isTablet) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Image( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp) - .onSizeChanged { size -> - backgroundImageHeight = size.height.toFloat() - }, - bitmap = courseImage.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop - ) - Box( - modifier = Modifier - .offset { - IntOffset( - x = 0, - y = (backgroundImageHeight - blurImagePaddingPx).roundToInt()) - } - .background(Color.White) - .blur(100.dp) - ) { - Box( - modifier = Modifier - .background(MaterialTheme.appColors.surface) - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) - .align(Alignment.Center) + CollapsingLayoutTablet( + localDensity = localDensity, + navigationHeight = navigationHeight, + backgroundImageHeight = backgroundImageHeight, + expandedTopHeight = expandedTopHeight, + blurImagePaddingPx = blurImagePaddingPx, + blurImagePadding = blurImagePadding, + backBtnStartPadding = backBtnStartPadding, + courseImage = courseImage, + imageHeight = imageHeight, + onBackClick = onBackClick, + expandedTop = expandedTop, + navigation = navigation, + bodyContent = bodyContent + ) + } else { + CollapsingLayoutMobile( + configuration = configuration, + localDensity = localDensity, + collapsedTopHeight = collapsedTopHeight, + navigationHeight = navigationHeight, + backgroundImageHeight = backgroundImageHeight, + expandedTopHeight = expandedTopHeight, + rawFactor = rawFactor, + factor = factor, + offset = offset, + blurImagePaddingPx = blurImagePaddingPx, + blurImageAlignment = blurImageAlignment, + blurImagePadding = blurImagePadding, + backBtnStartPadding = backBtnStartPadding, + courseImage = courseImage, + imageHeight = imageHeight, + toolbarBackgroundOffset = toolbarBackgroundOffset, + onBackClick = onBackClick, + expandedTop = expandedTop, + collapsedTop = collapsedTop, + navigation = navigation, + bodyContent = bodyContent + ) + } + } +} + +@Composable +private fun CollapsingLayoutTablet( + localDensity: Density, + navigationHeight: MutableState, + backgroundImageHeight: MutableState, + expandedTopHeight: MutableState, + blurImagePaddingPx: Float, + blurImagePadding: Dp, + backBtnStartPadding: Dp, + courseImage: Bitmap, + imageHeight: Int, + onBackClick: () -> Unit, + expandedTop: @Composable BoxScope.() -> Unit, + navigation: @Composable BoxScope.() -> Unit, + bodyContent: @Composable BoxScope.() -> Unit, +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value - blurImagePaddingPx).roundToInt() ) - Image( - modifier = Modifier - .fillMaxWidth() - .height(blurImagePadding) - .align(Alignment.TopCenter), - bitmap = courseImage.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, + } + .background(Color.White) + .blur(100.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .align(Alignment.Center) + ) + Image( + modifier = Modifier + .fillMaxWidth() + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } * 0.1f) + .align(Alignment.BottomCenter) + ) + } + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .onSizeChanged { size -> + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() }) + .offset { IntOffset(x = 0, y = backgroundImageHeight.value.roundToInt()) } + .background(backgroundColor) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 500f, + endY = 400f ) - Box( - modifier = Modifier - .background(MaterialTheme.appColors.courseHomeHeaderShade) - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) - .align(Alignment.BottomCenter) + ), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value - blurImagePaddingPx).roundToInt() ) } - } else { - val backgroundColor = MaterialTheme.appColors.background - Image( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp) - .onSizeChanged { size -> - backgroundImageHeight = size.height.toFloat() - }, - bitmap = courseImage.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), + ) + } + + Box( + modifier = Modifier + .onSizeChanged { size -> + expandedTopHeight.value = size.height.toFloat() + } + .offset { IntOffset(x = 0, y = backgroundImageHeight.value.roundToInt()) }, + content = expandedTop, + ) + + Icon( + modifier = Modifier + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding + 12.dp) + .clip(CircleShape) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground) + .clickable { + onBackClick() + }, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null + ) + + + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt()) } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (expandedTopHeight.value + backgroundImageHeight.value + navigationHeight.value).roundToInt() ) + } + .padding(bottom = with(localDensity) { (expandedTopHeight.value + navigationHeight.value + backgroundImageHeight.value).toDp() }), + content = bodyContent, + ) +} + +@Composable +private fun CollapsingLayoutMobile( + configuration: Configuration, + localDensity: Density, + collapsedTopHeight: MutableState, + navigationHeight: MutableState, + backgroundImageHeight: MutableState, + expandedTopHeight: MutableState, + rawFactor: Float, + factor: Float, + offset: Animatable, + blurImagePaddingPx: Float, + blurImageAlignment: Float, + blurImagePadding: Dp, + backBtnStartPadding: Dp, + courseImage: Bitmap, + imageHeight: Int, + toolbarBackgroundOffset: Int, + onBackClick: () -> Unit, + expandedTop: @Composable BoxScope.() -> Unit, + collapsedTop: @Composable BoxScope.() -> Unit, + navigation: @Composable BoxScope.() -> Unit, + bodyContent: @Composable BoxScope.() -> Unit, +) { + if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Box( + modifier = Modifier + .background(Color.White) + .blur(100.dp) + ) { Box( modifier = Modifier + .background(MaterialTheme.appColors.surface) .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() }) - .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) } - .background(backgroundColor) + .height(with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .align(Alignment.Center) ) - - Box( + Image( modifier = Modifier .fillMaxWidth() - .height(imageHeight.dp) - .background( - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - startY = 500f, - endY = 400f - ) - ), + .height(blurImagePadding) + .align(Alignment.TopCenter), + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.Center, ) Box( modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) - .offset { - IntOffset( - x = 0, - y = (backgroundImageHeight - blurImagePaddingPx).roundToInt() ) - } - .background( - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - startY = 400f, - endY = 0f - ) - ), + .height(with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() } * 0.1f) + .align(Alignment.BottomCenter) ) } - - Box( + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( modifier = Modifier + .fillMaxWidth() + .height(imageHeight.dp) .onSizeChanged { size -> - expandedTopHeight = size.height.toFloat() - } - .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) }, - content = expandedTop, + backgroundImageHeight.value = size.height.toFloat() + }, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .displayCutoutForLandscape() + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight.value = size.height.toFloat() + }, + verticalAlignment = Alignment.Bottom + ) { Icon( modifier = Modifier .statusBarsInset() - .padding(top = 12.dp, start = backBtnStartPadding + 12.dp) + .padding(top = 12.dp, start = backBtnStartPadding) .clip(CircleShape) - .background(MaterialTheme.appColors.courseHomeBackBtnBackground) .clickable { onBackClick() }, @@ -317,306 +517,241 @@ internal fun CollapsingLayout( tint = MaterialTheme.appColors.textPrimary, contentDescription = null ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + content = collapsedTop, + ) + } - Box( + Box( + modifier = Modifier + .displayCutoutForLandscape() + .offset { IntOffset(x = 0, y = (collapsedTopHeight.value).roundToInt()) } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (collapsedTopHeight.value + navigationHeight.value).roundToInt() + ) + } + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + content = bodyContent, + ) + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Image( modifier = Modifier - .offset { IntOffset(x = 0, y = (backgroundImageHeight + expandedTopHeight).roundToInt()) } + .fillMaxWidth() + .height(imageHeight.dp) .onSizeChanged { size -> - navigationHeight = size.height.toFloat() + backgroundImageHeight.value = size.height.toFloat() }, - content = navigation, + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop ) - Box( modifier = Modifier - .offset { - IntOffset( - x = 0, - y = (expandedTopHeight + backgroundImageHeight + navigationHeight).roundToInt() - ) - } - .padding(bottom = with(localDensity) { (expandedTopHeight + navigationHeight + backgroundImageHeight).toDp() }), - content = bodyContent, - ) - } else if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } + .background(Color.White) + .blur(100.dp) + ) { + val adaptiveBlurImagePadding = blurImagePadding.value * (3 - rawFactor) Box( modifier = Modifier - .background(Color.White) - .blur(100.dp) - ) { - Box( - modifier = Modifier - .background(MaterialTheme.appColors.surface) - .fillMaxWidth() - .height(with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() } + blurImagePadding) - .align(Alignment.Center) - ) - Image( - modifier = Modifier - .fillMaxWidth() - .height(blurImagePadding) - .align(Alignment.TopCenter), - bitmap = courseImage.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - ) - Box( - modifier = Modifier - .background(MaterialTheme.appColors.courseHomeHeaderShade) - .fillMaxWidth() - .height(with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() } * 0.1f) - .align(Alignment.BottomCenter) - ) - } - } else { - val backgroundColor = MaterialTheme.appColors.background + .background(MaterialTheme.appColors.surface) + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value + adaptiveBlurImagePadding).toDp() }) + .align(Alignment.Center) + ) Image( modifier = Modifier .fillMaxWidth() - .height(imageHeight.dp) - .onSizeChanged { size -> - backgroundImageHeight = size.height.toFloat() - }, + .height(blurImagePadding) + .align(Alignment.TopCenter), bitmap = courseImage.asImageBitmap(), contentDescription = null, - contentScale = ContentScale.Crop + contentScale = ContentScale.Crop, + alignment = PixelAlignment(0f, blurImageAlignment), ) Box( modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) .fillMaxWidth() - .height(with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }) - .background( - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - startY = 400f, - endY = 0f - ) - ), + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } * 0.1f) + .align(Alignment.BottomCenter) ) } - - Row( + } else { + val backgroundColor = MaterialTheme.appColors.background + Image( modifier = Modifier .fillMaxWidth() - .displayCutoutForLandscape() - .padding(horizontal = 12.dp) + .height(imageHeight.dp) .onSizeChanged { size -> - collapsedTopHeight = size.height.toFloat() + backgroundImageHeight.value = size.height.toFloat() }, - verticalAlignment = Alignment.Bottom - ) { - Icon( - modifier = Modifier - .statusBarsInset() - .padding(top = 12.dp, start = backBtnStartPadding) - .clip(CircleShape) - .clickable { - onBackClick() - }, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - tint = MaterialTheme.appColors.textPrimary, - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - content = collapsedTop, - ) - } - - + bitmap = courseImage.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop + ) Box( modifier = Modifier - .displayCutoutForLandscape() - .offset { IntOffset(x = 0, y = (collapsedTopHeight).roundToInt()) } - .onSizeChanged { size -> - navigationHeight = size.height.toFloat() - }, - content = navigation, + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() }) + .offset { IntOffset(x = 0, y = backgroundImageHeight.value.roundToInt()) } + .background(backgroundColor) ) Box( modifier = Modifier - .offset { - IntOffset( - x = 0, - y = (collapsedTopHeight + navigationHeight).roundToInt() + .fillMaxWidth() + .height(imageHeight.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 500f, + endY = 400f ) - } - .padding(bottom = with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }), - content = bodyContent, + ), ) - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Image( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp) - .onSizeChanged { size -> - backgroundImageHeight = size.height.toFloat() - }, - bitmap = courseImage.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop - ) - Box( - modifier = Modifier - .offset { IntOffset(x = 0, y = toolbarBackgroundOffset) } - .background(Color.White) - .blur(100.dp) - ) { - val adaptiveBlurImagePadding = blurImagePadding.value * (3 - rawFactor) - Box( - modifier = Modifier - .background(MaterialTheme.appColors.surface) - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight + adaptiveBlurImagePadding).toDp() }) - .align(Alignment.Center) - ) - Image( - modifier = Modifier - .fillMaxWidth() - .height(blurImagePadding) - .align(Alignment.TopCenter), - bitmap = courseImage.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - alignment = PixelAlignment(0f, blurImageAlignment), - ) - Box( - modifier = Modifier - .background(MaterialTheme.appColors.courseHomeHeaderShade) - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } * 0.1f) - .align(Alignment.BottomCenter) - ) - } - } else { - val backgroundColor = MaterialTheme.appColors.background - Image( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp) - .onSizeChanged { size -> - backgroundImageHeight = size.height.toFloat() - }, - bitmap = courseImage.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() }) - .offset { IntOffset(x = 0, y = backgroundImageHeight.roundToInt()) } - .background(backgroundColor) - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(imageHeight.dp) - .background( - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - startY = 500f, - endY = 400f - ) - ), - ) - Box( - modifier = Modifier - .fillMaxWidth() - .height(with(localDensity) { (expandedTopHeight + navigationHeight).toDp() } + blurImagePadding) - .offset { - IntOffset( - x = 0, - y = (offset.value + backgroundImageHeight - blurImagePaddingPx).roundToInt() ) - } - .background( - brush = Brush.verticalGradient( - colors = listOf(backgroundColor, Color.Transparent), - startY = 400f, - endY = 0f - ) - ), - ) - } - Box( modifier = Modifier - .onSizeChanged { size -> - expandedTopHeight = size.height.toFloat() - } + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) .offset { IntOffset( x = 0, - y = (offset.value + backgroundImageHeight - blurImagePaddingPx).roundToInt() + y = (offset.value + backgroundImageHeight.value - blurImagePaddingPx).roundToInt() ) } - .alpha(factor), - content = expandedTop, + .background( + brush = Brush.verticalGradient( + colors = listOf(backgroundColor, Color.Transparent), + startY = 400f, + endY = 0f + ) + ), ) + } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .onSizeChanged { size -> - collapsedTopHeight = size.height.toFloat() - }, - verticalAlignment = Alignment.Bottom - ) { - Icon( - modifier = Modifier - .statusBarsInset() - .padding(top = 12.dp, start = backBtnStartPadding) - .clip(CircleShape) - .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(factor / 2)) - .clickable { - onBackClick() - }, - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - tint = MaterialTheme.appColors.textPrimary, - contentDescription = null - ) - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = Modifier - .alpha(1 - factor), - content = collapsedTop, - ) - } + Box( + modifier = Modifier + .onSizeChanged { size -> + expandedTopHeight.value = size.height.toFloat() + } + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .alpha(factor), + content = expandedTop, + ) - val adaptiveImagePadding = blurImagePaddingPx * factor - Box( + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight.value = size.height.toFloat() + }, + verticalAlignment = Alignment.Bottom + ) { + Icon( modifier = Modifier - .offset { - IntOffset( - x = 0, - y = (offset.value + backgroundImageHeight + expandedTopHeight - adaptiveImagePadding).roundToInt() - ) - } - .onSizeChanged { size -> - navigationHeight = size.height.toFloat() + .statusBarsInset() + .padding(top = 12.dp, start = backBtnStartPadding) + .clip(CircleShape) + .background(MaterialTheme.appColors.courseHomeBackBtnBackground.copy(factor / 2)) + .clickable { + onBackClick() }, - content = navigation, + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + tint = MaterialTheme.appColors.textPrimary, + contentDescription = null ) - + Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier - .offset { - IntOffset( - x = 0, - y = (expandedTopHeight + offset.value + backgroundImageHeight + navigationHeight - blurImagePaddingPx * factor).roundToInt() - ) - } - .padding(bottom = with(localDensity) { (collapsedTopHeight + navigationHeight).toDp() }), - content = bodyContent, + .alpha(1 - factor), + content = collapsedTop, ) } + + val adaptiveImagePadding = blurImagePaddingPx * factor + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight.value + expandedTopHeight.value - adaptiveImagePadding).roundToInt() + ) + } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }, + content = navigation, + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (expandedTopHeight.value + offset.value + backgroundImageHeight.value + navigationHeight.value - blurImagePaddingPx * factor).roundToInt() + ) + } + .padding(bottom = with(localDensity) { (collapsedTopHeight.value + navigationHeight.value).toDp() }), + content = bodyContent, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = "spec:parent=pixel_5,orientation=landscape") +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:parent=pixel_5,orientation=landscape") +@Preview(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CollapsingLayoutPreview() { + OpenEdXTheme { + CollapsingLayout( + modifier = Modifier, + courseImage = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888), + imageHeight = 200, + expandedTop = { + ExpandedHeaderContent( + courseTitle = "courseName", + org = "organization" + ) + }, + collapsedTop = { + CollapsedHeaderContent( + courseTitle = "courseName" + ) + }, + navigation = { + CourseHomeTabs( + rowState = rememberLazyListState(), + pagerState = rememberPagerState(pageCount = { 5 }), + onPageChange = { } + ) + }, + 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 72306a731..0b5403d41 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 @@ -5,27 +5,43 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding 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.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarData +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHost +import androidx.compose.material.SnackbarHostState import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.rememberCoroutineScope +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.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -33,8 +49,12 @@ import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.CourseRouter @@ -47,10 +67,10 @@ import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.outline.CourseOutlineScreen import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.ui.CourseVideosScreen +import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen -import org.openedx.discussion.presentation.topics.DiscussionTopicsUIState import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -157,24 +177,25 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun onRefresh(currentPage: Int) { - when (currentPage) { - 0 -> { - courseOutlineViewModel.setIsUpdating() - updateCourseStructure(true) + viewModel.showRefreshIndicator() + when (CourseHomeTab.entries[currentPage]) { + CourseHomeTab.HOME -> { + updateCourseStructure(false) } - 1 -> { - courseVideoViewModel.setIsUpdating() - updateCourseStructure(true) + CourseHomeTab.VIDEOS -> { + updateCourseStructure(false) } - 2 -> { - courseDatesViewModel.getCourseDates(swipeToRefresh = true) + CourseHomeTab.DATES -> { + courseDatesViewModel.getCourseDates() } - 3 -> { + CourseHomeTab.DISCUSSIONS -> { discussionTopicsViewModel.updateCourseTopics() } + + else -> {} } } @@ -182,163 +203,201 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun setupCollapsingLayout() { binding.composeCollapsingLayout.setContent { OpenEdXTheme { - val refreshing by viewModel.showProgress.collectAsState(true) - val courseImage by viewModel.courseImage.collectAsState() - val pagerState = rememberPagerState(pageCount = { 5 }) - val tabState = rememberLazyListState() val windowSize = rememberWindowSize() - val pullRefreshState = rememberPullRefreshState( - refreshing = refreshing, - onRefresh = { onRefresh(pagerState.currentPage) } - ) - - LaunchedEffect(pagerState.currentPage) { - tabState.animateScrollToItem(pagerState.currentPage) - } - - Box { - CollapsingLayout( - modifier = Modifier - .fillMaxWidth() - .pullRefresh(pullRefreshState), - courseImage = courseImage, - expandedTop = { - ExpandedHeaderContent( - courseTitle = viewModel.courseName, - org = viewModel.organization - ) - }, - collapsedTop = { - CollapsedHeaderContent( - courseTitle = viewModel.courseName - ) - }, - navigation = { - CourseHomeTabs( - rowState = tabState, - pagerState = pagerState, - onPageChange = viewModel::courseContainerTabClickedEvent + val scope = rememberCoroutineScope() + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + val refreshing by viewModel.refreshing.collectAsState(true) + val courseImage by viewModel.courseImage.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val pagerState = rememberPagerState(pageCount = { CourseHomeTab.entries.size }) + val tabState = rememberLazyListState() + val snackState = remember { SnackbarHostState() } + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onRefresh(pagerState.currentPage) } + ) + if (uiMessage is DatesShiftedSnackBar) { + val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) + LaunchedEffect(uiMessage) { + snackState.showSnackbar( + message = datesShiftedMessage, + duration = SnackbarDuration.Long ) - }, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - }, - bodyContent = { - HorizontalPager( - state = pagerState - ) { page -> - when (page) { - 0 -> { - val scope = rememberCoroutineScope() - CourseOutlineScreen( - windowSize = windowSize, - courseOutlineViewModel = courseOutlineViewModel, - courseRouter = courseRouter, - fragmentManager = requireActivity().supportFragmentManager, - onReloadClick = { - updateCourseStructure(false) - }, - onResetDatesClick = { - courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { - updateCourseDates() - }) - }, - onViewDates = { - scope.launch { - pagerState.animateScrollToPage(4) - } - } - ) - } - - 1 -> { - CourseVideosScreen( - windowSize = windowSize, - courseVideoViewModel = courseVideoViewModel, - fragmentManager = requireActivity().supportFragmentManager, - courseRouter = courseRouter, - onReloadClick = { - updateCourseStructure(false) - } - ) - } - - 2 -> { - CourseDatesScreen( - windowSize = windowSize, - courseDatesViewModel = courseDatesViewModel, - courseRouter = courseRouter, - fragmentManager = requireActivity().supportFragmentManager, - isFragmentResumed = isResumed, - updateCourseStructure = { - updateCourseStructure(false) - } - ) - } + } + } + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - 3 -> { - val uiState by discussionTopicsViewModel.uiState.observeAsState( - DiscussionTopicsUIState.Loading - ) - val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) - - DiscussionTopicsScreen( - windowSize = windowSize, - uiState = uiState, - uiMessage = uiMessage, - onItemClick = { action, data, title -> - discussionTopicsViewModel.discussionClickedEvent(action, data, title) - discussionRouter.navigateToDiscussionThread( - requireActivity().supportFragmentManager, - action, - discussionTopicsViewModel.courseId, - data, - title, - FragmentViewType.FULL_CONTENT - ) - }, - onSearchClick = { - discussionRouter.navigateToSearchThread( - requireActivity().supportFragmentManager, - discussionTopicsViewModel.courseId - ) - }, - onReloadClick = { - discussionTopicsViewModel.getCourseTopics() - }, - hasInternetConnection = discussionTopicsViewModel.hasInternetConnection - ) - } + LaunchedEffect(pagerState.currentPage) { + tabState.animateScrollToItem(pagerState.currentPage) + } - 4 -> { - HandoutsScreen( - windowSize = windowSize, - onHandoutsClick = { - courseRouter.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(R.string.course_handouts), - HandoutsType.Handouts - ) - }, - onAnnouncementsClick = { - courseRouter.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(R.string.course_announcements), - HandoutsType.Announcements - ) - }) + 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 = { + CourseHomeTabs( + rowState = tabState, + pagerState = pagerState, + onPageChange = viewModel::courseContainerTabClickedEvent + ) + }, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + bodyContent = { + HorizontalPager( + state = pagerState + ) { page -> + when (CourseHomeTab.entries[page]) { + CourseHomeTab.HOME -> { + CourseOutlineScreen( + windowSize = windowSize, + courseOutlineViewModel = courseOutlineViewModel, + courseRouter = courseRouter, + fragmentManager = requireActivity().supportFragmentManager, + onResetDatesClick = { + courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { + updateCourseDates() + }) + } + ) + } + + CourseHomeTab.VIDEOS -> { + CourseVideosScreen( + windowSize = windowSize, + courseVideoViewModel = courseVideoViewModel, + fragmentManager = requireActivity().supportFragmentManager, + courseRouter = courseRouter, + ) + } + + CourseHomeTab.DATES -> { + CourseDatesScreen( + windowSize = windowSize, + courseDatesViewModel = courseDatesViewModel, + courseRouter = courseRouter, + fragmentManager = requireActivity().supportFragmentManager, + isFragmentResumed = isResumed, + updateCourseStructure = { + updateCourseStructure(false) + } + ) + } + + CourseHomeTab.DISCUSSIONS -> { + DiscussionTopicsScreen( + windowSize = windowSize, + discussionTopicsViewModel = discussionTopicsViewModel, + onItemClick = { action, data, title -> + discussionTopicsViewModel.discussionClickedEvent( + action, + data, + title + ) + discussionRouter.navigateToDiscussionThread( + requireActivity().supportFragmentManager, + action, + discussionTopicsViewModel.courseId, + data, + title, + FragmentViewType.FULL_CONTENT + ) + }, + onSearchClick = { + discussionRouter.navigateToSearchThread( + requireActivity().supportFragmentManager, + discussionTopicsViewModel.courseId + ) + }) + } + + CourseHomeTab.MORE -> { + HandoutsScreen( + windowSize = windowSize, + onHandoutsClick = { + courseRouter.navigateToHandoutsWebView( + requireActivity().supportFragmentManager, + requireArguments().getString(ARG_COURSE_ID, ""), + getString(R.string.course_handouts), + HandoutsType.Handouts + ) + }, + onAnnouncementsClick = { + courseRouter.navigateToHandoutsWebView( + requireActivity().supportFragmentManager, + requireArguments().getString(ARG_COURSE_ID, ""), + getString(R.string.course_announcements), + HandoutsType.Announcements + ) + }) + } } } } + ) + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) } - ) - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) + 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 = true, + onViewDates = { + scrollToDates(scope, pagerState) + }, + onClose = { + snackbarData.dismiss() + } + ) + } + } } } } @@ -442,6 +501,13 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { courseDatesViewModel.getCourseDates() } + @OptIn(ExperimentalFoundationApi::class) + private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { + scope.launch { + pagerState.animateScrollToPage(CourseHomeTab.entries.indexOf(CourseHomeTab.DATES)) + } + } + 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 14750a94a..4efaf5dc6 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 @@ -9,14 +9,18 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.ImageProcessor import org.openedx.core.SingleEventLiveData +import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException @@ -26,9 +30,11 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet +import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.utils.TimeUtils +import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor @@ -74,6 +80,14 @@ class CourseContainerViewModel( val showProgress: StateFlow = _showProgress.asStateFlow() + private val _refreshing = MutableStateFlow(false) + val refreshing: StateFlow = + _refreshing.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + private var _isSelfPaced: Boolean = true val isSelfPaced: Boolean get() = _isSelfPaced @@ -97,6 +111,9 @@ class CourseContainerViewModel( private var _courseImage = MutableStateFlow(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) val courseImage: StateFlow = _courseImage.asStateFlow() + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + init { viewModelScope.launch { notifier.notifier.collect { event -> @@ -114,6 +131,10 @@ class CourseContainerViewModel( ) } } + + if (event is CourseDatesShifted) { + _uiMessage.emit(DatesShiftedSnackBar()) + } } } } @@ -166,8 +187,11 @@ class CourseContainerViewModel( } } + fun showRefreshIndicator() { + _refreshing.value = true + } + fun updateData(withSwipeRefresh: Boolean) { - _showProgress.value = true viewModelScope.launch { try { interactor.preloadCourseStructure(courseId) @@ -180,18 +204,18 @@ class CourseContainerViewModel( resourceManager.getString(CoreR.string.core_error_unknown_error) } } - _showProgress.value = false + _refreshing.value = false notifier.send(CourseStructureUpdated(courseId, withSwipeRefresh)) } } fun courseContainerTabClickedEvent(index: Int) { - when (index) { - 0 -> courseTabClickedEvent() - 1 -> videoTabClickedEvent() - 2 -> discussionTabClickedEvent() - 3 -> datesTabClickedEvent() - 4 -> handoutsTabClickedEvent() + when (CourseHomeTab.entries[index]) { + CourseHomeTab.HOME -> courseTabClickedEvent() + CourseHomeTab.VIDEOS -> videoTabClickedEvent() + CourseHomeTab.DISCUSSIONS -> discussionTabClickedEvent() + CourseHomeTab.DATES -> datesTabClickedEvent() + CourseHomeTab.MORE -> moreTabClickedEvent() } } @@ -336,8 +360,8 @@ class CourseContainerViewModel( logCourseContainerEvent(CourseAnalyticsEvent.DATES_TAB) } - private fun handoutsTabClickedEvent() { - logCourseContainerEvent(CourseAnalyticsEvent.HANDOUTS_TAB) + private fun moreTabClickedEvent() { + logCourseContainerEvent(CourseAnalyticsEvent.MORE_TAB) } private fun logCourseContainerEvent(event: CourseAnalyticsEvent) { diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt index 1ee0b6404..44c2e85ab 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.container +import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -33,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -41,6 +43,14 @@ import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.appColors import org.openedx.course.R +enum class CourseHomeTab(@StringRes val title: Int, val icon: ImageVector) { + HOME(R.string.course_navigation_home, Icons.Default.Home), + VIDEOS(R.string.course_navigation_videos, Icons.Rounded.PlayCircleFilled), + DATES(R.string.course_navigation_dates, Icons.Outlined.CalendarMonth), + DISCUSSIONS(R.string.course_navigation_discussions, Icons.AutoMirrored.Filled.Chat), + MORE(R.string.course_navigation_more, Icons.AutoMirrored.Filled.TextSnippet) +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun CourseHomeTabs( @@ -48,14 +58,6 @@ fun CourseHomeTabs( rowState: LazyListState = rememberLazyListState(), onPageChange: (Int) -> Unit ) { - val list = listOf( - stringResource(id = R.string.course_navigation_home) to rememberVectorPainter(Icons.Default.Home), - stringResource(id = R.string.course_navigation_videos) to rememberVectorPainter(Icons.Rounded.PlayCircleFilled), - stringResource(id = R.string.course_navigation_dates) to rememberVectorPainter(Icons.Outlined.CalendarMonth), - stringResource(id = R.string.course_navigation_discussions) to rememberVectorPainter(Icons.AutoMirrored.Filled.Chat), - stringResource(id = R.string.course_navigation_more) to rememberVectorPainter(Icons.AutoMirrored.Filled.TextSnippet) - ) - val scope = rememberCoroutineScope() val windowSize = rememberWindowSize() val horizontalPadding = if (!windowSize.isTablet){ @@ -69,7 +71,7 @@ fun CourseHomeTabs( horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), ) { - itemsIndexed(list) { index, item -> + itemsIndexed(CourseHomeTab.entries) { index, item -> val backgroundColor = if (pagerState.currentPage == index) { MaterialTheme.appColors.primary } else { @@ -106,13 +108,13 @@ fun CourseHomeTabs( horizontalArrangement = Arrangement.Center ) { Icon( - painter = item.second, + painter = rememberVectorPainter(item.icon), tint = contentColor, contentDescription = null ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = item.first, + text = stringResource(item.title), color = contentColor ) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 1c8fb9eb0..fd49c8a07 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -32,10 +32,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarData -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState import androidx.compose.material.Surface import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults @@ -45,13 +41,11 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 @@ -80,7 +74,6 @@ import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -91,15 +84,13 @@ 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.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet -import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.util.concurrent.atomic.AtomicReference -import org.openedx.core.R as coreR +import org.openedx.core.R as CoreR @Composable fun CourseDatesScreen( @@ -120,11 +111,7 @@ fun CourseDatesScreen( uiState = uiState, uiMessage = uiMessage, isSelfPaced = courseDatesViewModel.isSelfPaced, - hasInternetConnection = courseDatesViewModel.hasInternetConnection, calendarSyncUIState = calendarSyncUIState, - onReloadClick = { - courseDatesViewModel.getCourseDates() - }, onItemClick = { block -> if (block.blockId.isNotEmpty()) { courseDatesViewModel.getVerticalBlock(block.blockId) @@ -153,10 +140,10 @@ fun CourseDatesScreen( } ?: { courseDatesViewModel.logCourseComponentTapped(false, block) ActionDialogFragment.newInstance( - title = context.getString(org.openedx.core.R.string.core_leaving_the_app), + title = context.getString(CoreR.string.core_leaving_the_app), message = context.getString( - org.openedx.core.R.string.core_leaving_the_app_message, - context.getString(org.openedx.core.R.string.platform_name) + CoreR.string.core_leaving_the_app_message, + context.getString(CoreR.string.platform_name) ), url = block.link, source = CoreAnalyticsScreen.COURSE_DATES.screenName @@ -194,18 +181,13 @@ internal fun CourseDatesScreen( uiState: DatesUIState, uiMessage: UIMessage?, isSelfPaced: Boolean, - hasInternetConnection: Boolean, calendarSyncUIState: CalendarSyncUIState, - onReloadClick: () -> Unit, onItemClick: (CourseDateBlock) -> Unit, onPLSBannerViewed: () -> Unit, onSyncDates: () -> Unit, onCalendarSyncSwitch: (Boolean) -> Unit = {}, ) { val scaffoldState = rememberScaffoldState() - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier.fillMaxSize(), @@ -230,16 +212,6 @@ internal fun CourseDatesScreen( ) } - val snackState = remember { SnackbarHostState() } - if (uiMessage is DatesShiftedSnackBar) { - val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) - LaunchedEffect(uiMessage) { - snackState.showSnackbar( - message = datesShiftedMessage, - duration = SnackbarDuration.Long - ) - } - } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -341,28 +313,6 @@ internal fun CourseDatesScreen( DatesUIState.Loading -> {} } - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - }) - } - } - - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar(onClose = { - snackbarData.dismiss() - }) } } } @@ -479,7 +429,7 @@ fun ExpandableView( AnimatedVisibility(visible = expanded.not()) { Text( text = pluralStringResource( - id = coreR.plurals.core_date_items_hidden, + id = CoreR.plurals.core_date_items_hidden, count = sectionDates.size, formatArgs = arrayOf(sectionDates.size) ), @@ -639,7 +589,7 @@ private fun CourseDateItem( modifier = Modifier .padding(end = 4.dp) .align(Alignment.CenterVertically), - painter = painterResource(id = if (dateBlock.learnerHasAccess.not()) coreR.drawable.core_ic_lock else icon), + painter = painterResource(id = if (dateBlock.learnerHasAccess.not()) CoreR.drawable.core_ic_lock else icon), contentDescription = null, tint = MaterialTheme.appColors.textDark ) @@ -694,9 +644,7 @@ private fun CourseDatesScreenPreview() { uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, isSelfPaced = true, - hasInternetConnection = true, calendarSyncUIState = mockCalendarSyncUIState, - onReloadClick = {}, onItemClick = {}, onPLSBannerViewed = {}, onSyncDates = {}, @@ -715,9 +663,7 @@ private fun CourseDatesScreenTabletPreview() { uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, isSelfPaced = true, - hasInternetConnection = true, calendarSyncUIState = mockCalendarSyncUIState, - onReloadClick = {}, onItemClick = {}, onPLSBannerViewed = {}, 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 f3715b549..ed1f6d444 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 @@ -23,11 +23,10 @@ import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -44,7 +43,6 @@ class CourseDatesViewModel( private val notifier: CourseNotifier, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, - private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val corePreferences: CorePreferences, private val courseAnalytics: CourseAnalytics, @@ -71,13 +69,6 @@ class CourseDatesViewModel( val calendarSyncUIState: StateFlow = _calendarSyncUIState.asStateFlow() - private val _updating = MutableLiveData() - val updating: LiveData - get() = _updating - - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - private var courseBannerType: CourseBannerType = CourseBannerType.BLANK val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() @@ -93,11 +84,7 @@ class CourseDatesViewModel( } } - fun getCourseDates(swipeToRefresh: Boolean = false) { - if (!swipeToRefresh) { - _uiState.value = DatesUIState.Loading - } - _updating.value = swipeToRefresh + fun getCourseDates() { loadingCourseDatesInternal() } @@ -119,7 +106,6 @@ class CourseDatesViewModel( _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } } - _updating.value = false } } @@ -128,7 +114,7 @@ class CourseDatesViewModel( try { interactor.resetCourseDates(courseId = courseId) getCourseDates() - _uiMessage.emit(DatesShiftedSnackBar()) + notifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { 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 1a2f1fe3b..4664cc76a 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 @@ -22,22 +22,15 @@ import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarData -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.AndroidUriHandler @@ -61,7 +54,6 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode 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 @@ -71,23 +63,22 @@ 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.DatesShiftedSnackBar import org.openedx.course.presentation.CourseRouter 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.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem -import org.openedx.course.presentation.ui.DatesShiftedSnackBar import java.io.File import java.util.Date +import org.openedx.course.R as CourseR fun getUnitBlockIcon(block: Block): Int { return when (block.type) { - BlockType.VIDEO -> org.openedx.course.R.drawable.ic_course_video - BlockType.PROBLEM -> org.openedx.course.R.drawable.ic_course_pen - BlockType.DISCUSSION -> org.openedx.course.R.drawable.ic_course_discussion - else -> org.openedx.course.R.drawable.ic_course_block + BlockType.VIDEO -> CourseR.drawable.ic_course_video + BlockType.PROBLEM -> CourseR.drawable.ic_course_pen + BlockType.DISCUSSION -> CourseR.drawable.ic_course_discussion + else -> CourseR.drawable.ic_course_block } } @@ -97,9 +88,7 @@ fun CourseOutlineScreen( courseOutlineViewModel: CourseOutlineViewModel, courseRouter: CourseRouter, fragmentManager: FragmentManager, - onReloadClick: () -> Unit, - onResetDatesClick: () -> Unit, - onViewDates: () -> Unit + onResetDatesClick: () -> Unit ) { val uiState by courseOutlineViewModel.uiState.observeAsState(CourseOutlineUIState.Loading) val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) @@ -110,8 +99,6 @@ fun CourseOutlineScreen( uiState = uiState, isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, uiMessage = uiMessage, - hasInternetConnection = courseOutlineViewModel.hasInternetConnection, - onReloadClick = onReloadClick, onItemClick = { block -> courseOutlineViewModel.sequentialClickedEvent( block.blockId, @@ -191,7 +178,6 @@ fun CourseOutlineScreen( } }, onResetDatesClick = onResetDatesClick, - onViewDates = onViewDates, onCertificateClick = { courseOutlineViewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() @@ -206,21 +192,15 @@ internal fun CourseOutlineScreen( uiState: CourseOutlineUIState, isCourseNestedListEnabled: Boolean, uiMessage: UIMessage?, - hasInternetConnection: Boolean, - onReloadClick: () -> Unit, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, onDownloadClick: (Block) -> Unit, onResetDatesClick: () -> Unit, - onViewDates: () -> Unit?, onCertificateClick: (String) -> Unit, ) { val scaffoldState = rememberScaffoldState() - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier @@ -256,17 +236,6 @@ internal fun CourseOutlineScreen( ) } - val snackState = remember { SnackbarHostState() } - if (uiMessage is DatesShiftedSnackBar) { - val datesShiftedMessage = - stringResource(id = org.openedx.course.R.string.course_dates_shifted_message) - LaunchedEffect(uiMessage) { - snackState.showSnackbar( - message = datesShiftedMessage, - duration = SnackbarDuration.Long - ) - } - } HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) Box( @@ -403,31 +372,6 @@ internal fun CourseOutlineScreen( CourseOutlineUIState.Loading -> {} } - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } - } - - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar(showAction = true, - onViewDates = onViewDates, - onClose = { - snackbarData.dismiss() - }) } } } @@ -568,15 +512,12 @@ private fun CourseOutlineScreenPreview() { ), isCourseNestedListEnabled = true, uiMessage = null, - hasInternetConnection = true, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, - onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onViewDates = {}, onCertificateClick = {}, ) } @@ -606,15 +547,12 @@ private fun CourseOutlineScreenTabletPreview() { ), isCourseNestedListEnabled = true, uiMessage = null, - hasInternetConnection = true, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, - onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onViewDates = {}, onCertificateClick = {}, ) } 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 353f75bf8..b70a7dade 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 @@ -1,6 +1,5 @@ package org.openedx.course.presentation.outline -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope @@ -28,9 +27,9 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -58,9 +57,6 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { - - val apiHostUrl get() = config.getApiHostURL() - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() private val _uiState = MutableLiveData(CourseOutlineUIState.Loading) @@ -71,31 +67,23 @@ class CourseOutlineViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating - var resumeSectionBlock: Block? = null private set var resumeVerticalBlock: Block? = null private set - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) + init { viewModelScope.launch { notifier.notifier.collect { event -> if (event is CourseStructureUpdated) { if (event.courseId == courseId) { - updateCourseData(event.withSwipeRefresh) + updateCourseData() } } } @@ -119,10 +107,6 @@ class CourseOutlineViewModel( } } - fun setIsUpdating() { - _isUpdating.value = true - } - override fun saveDownloadModels(folder: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { if (networkConnection.isWifiConnected()) { @@ -137,8 +121,7 @@ class CourseOutlineViewModel( } } - fun updateCourseData(withSwipeRefresh: Boolean) { - _isUpdating.value = withSwipeRefresh + fun updateCourseData() { getCourseDataInternal() } @@ -225,7 +208,6 @@ class CourseOutlineViewModel( _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } - _isUpdating.value = false } } @@ -272,8 +254,8 @@ class CourseOutlineViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - updateCourseData(false) - _uiMessage.emit(DatesShiftedSnackBar()) + updateCourseData() + notifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { 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 599b98fd3..15d053dc2 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 @@ -42,7 +42,6 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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 @@ -70,7 +69,6 @@ import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -90,10 +88,9 @@ fun CourseVideosScreen( windowSize: WindowSize, courseVideoViewModel: CourseVideoViewModel, fragmentManager: FragmentManager, - courseRouter: CourseRouter, - onReloadClick: () -> Unit + courseRouter: CourseRouter ) { - val uiState by courseVideoViewModel.uiState.observeAsState(CourseVideosUIState.Loading) + val uiState by courseVideoViewModel.uiState.collectAsState(CourseVideosUIState.Loading) val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) val videoSettings by courseVideoViewModel.videoSettings.collectAsState() val context = LocalContext.current @@ -104,9 +101,7 @@ fun CourseVideosScreen( uiMessage = uiMessage, courseTitle = courseVideoViewModel.courseTitle, isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, - hasInternetConnection = courseVideoViewModel.hasInternetConnection, videoSettings = videoSettings, - onReloadClick = onReloadClick, onItemClick = { block -> courseRouter.navigateToCourseSubsections( fm = fragmentManager, @@ -190,21 +185,16 @@ fun CourseVideosScreen( uiMessage: UIMessage?, courseTitle: String, isCourseNestedListEnabled: Boolean, - hasInternetConnection: Boolean, videoSettings: VideoSettings, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, - onReloadClick: () -> Unit, onDownloadClick: (Block) -> Unit, onDownloadAllClick: (Boolean) -> Unit, onDownloadQueueClick: () -> Unit, onVideoDownloadQualityClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier @@ -304,7 +294,6 @@ fun CourseVideosScreen( onDownloadAllClick = { isSwitched -> if (isSwitched) { isDeleteDownloadsConfirmationShowed = true - } else { onDownloadAllClick(false) } @@ -317,10 +306,8 @@ fun CourseVideosScreen( if (isCourseNestedListEnabled) { uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] + val courseSubSections = uiState.courseSubSections[section.id] + val courseSectionsState = uiState.courseSectionsState[section.id] item { Column { @@ -407,20 +394,6 @@ fun CourseVideosScreen( CourseVideosUIState.Loading -> {} } } - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } } } } @@ -739,7 +712,7 @@ private fun CourseVideosScreenPreview() { isAllBlocksDownloadedOrDownloading = false, remainingCount = 0, remainingSize = 0, - allCount = 0, + allCount = 1, allSize = 0 ) ), @@ -748,9 +721,7 @@ private fun CourseVideosScreenPreview() { onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - hasInternetConnection = true, videoSettings = VideoSettings.default, - onReloadClick = {}, onDownloadClick = {}, onDownloadAllClick = {}, onDownloadQueueClick = {}, @@ -775,8 +746,6 @@ private fun CourseVideosScreenEmptyPreview() { onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onReloadClick = {}, - hasInternetConnection = true, videoSettings = VideoSettings.default, onDownloadClick = {}, onDownloadAllClick = {}, @@ -813,8 +782,6 @@ private fun CourseVideosScreenTabletPreview() { onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onReloadClick = {}, - hasInternetConnection = true, videoSettings = VideoSettings.default, onDownloadClick = {}, onDownloadAllClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 57b6890f7..89379bd85 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -1,11 +1,10 @@ package org.openedx.course.presentation.videos -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -51,17 +50,11 @@ class CourseVideoViewModel( coreAnalytics ) { - val apiHostUrl get() = config.getApiHostURL() - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - private val _uiState = MutableLiveData() - val uiState: LiveData - get() = _uiState - - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow @@ -70,9 +63,6 @@ class CourseVideoViewModel( private val _videoSettings = MutableStateFlow(VideoSettings.default) val videoSettings = _videoSettings.asStateFlow() - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() @@ -145,13 +135,8 @@ class CourseVideoViewModel( super.saveAllDownloadModels(folder) } - fun setIsUpdating() { - _isUpdating.value = true - } - private fun updateVideos() { getVideos() - _isUpdating.value = false } fun getVideos() { 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 47d5db4e3..8e2a13ef7 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 @@ -154,7 +154,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(!viewModel.showProgress.value) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value == null) } @@ -186,7 +186,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(!viewModel.showProgress.value) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value == null) } @@ -218,7 +218,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) - assert(!viewModel.showProgress.value) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value != null) } @@ -251,7 +251,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } assert(viewModel.errorMessage.value == null) - assert(!viewModel.showProgress.value) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value != null) } @@ -281,7 +281,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(!viewModel.showProgress.value) + assert(!viewModel.refreshing.value) } @Test @@ -310,7 +310,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(!viewModel.showProgress.value) + assert(!viewModel.refreshing.value) } @Test @@ -338,6 +338,6 @@ class CourseContainerViewModelTest { coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } assert(viewModel.errorMessage.value == null) - assert(!viewModel.showProgress.value) + assert(!viewModel.refreshing.value) } } 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 08f3fffc4..fbade5adc 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 @@ -38,7 +38,6 @@ 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 import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor @@ -58,7 +57,6 @@ class CourseDatesViewModelTest { private val notifier = mockk() private val interactor = mockk() private val calendarManager = mockk() - private val networkConnection = mockk() private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() @@ -165,13 +163,11 @@ class CourseDatesViewModelTest { notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { withTimeoutOrNull(5000) { @@ -183,7 +179,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) - assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Loading) } @@ -196,13 +191,11 @@ class CourseDatesViewModelTest { notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { withTimeoutOrNull(5000) { @@ -214,7 +207,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Loading) } @@ -227,13 +219,11 @@ class CourseDatesViewModelTest { notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult val message = async { withTimeoutOrNull(5000) { @@ -245,7 +235,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Dates) } @@ -258,13 +247,11 @@ class CourseDatesViewModelTest { notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), courseBanner = mockCourseDatesBannerInfo, @@ -279,7 +266,6 @@ class CourseDatesViewModelTest { coVerify(exactly = 1) { interactor.getCourseDates(any()) } assert(message.await()?.message.isNullOrEmpty()) - assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DatesUIState.Empty) } } 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 98884d796..6c5c76efd 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 @@ -13,7 +13,6 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -221,6 +220,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() val viewModel = CourseOutlineViewModel( @@ -248,7 +248,6 @@ class CourseOutlineViewModelTest { coVerify(exactly = 1) { interactor.getCourseStatus(any()) } assertEquals(noInternet, message.await()?.message) - assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) } @@ -256,6 +255,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( "", @@ -282,7 +282,6 @@ class CourseOutlineViewModelTest { coVerify(exactly = 1) { interactor.getCourseStatus(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.Loading) } @@ -329,7 +328,6 @@ class CourseOutlineViewModelTest { coVerify(exactly = 1) { interactor.getCourseStatus(any()) } assert(message.await() == null) - assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @@ -376,7 +374,6 @@ class CourseOutlineViewModelTest { coVerify(exactly = 0) { interactor.getCourseStatus(any()) } assert(message.await() == null) - assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @@ -417,19 +414,19 @@ class CourseOutlineViewModelTest { } } viewModel.getCourseData() - viewModel.updateCourseData(false) + viewModel.updateCourseData() advanceUntilIdle() coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } coVerify(exactly = 2) { interactor.getCourseStatus(any()) } assert(message.await() == null) - assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) } @Test - fun `CourseStructureUpdated notifier test`() = runTest { + fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { + coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseOutlineViewModel( "", "", @@ -446,12 +443,6 @@ class CourseOutlineViewModelTest { ) coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } every { interactor.getCourseStructureFromCache() } returns courseStructure - every { downloadDao.readAllData() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } - } every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") @@ -461,11 +452,10 @@ class CourseOutlineViewModelTest { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) viewModel.getCourseData() - viewModel.setIsUpdating() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 1) { interactor.getCourseStatus(any()) } } @Test @@ -523,7 +513,6 @@ class CourseOutlineViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true - coEvery { downloadDao.readAllData() } returns mockk() coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false @@ -588,6 +577,5 @@ class CourseOutlineViewModelTest { advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) - assert(!viewModel.hasInternetConnection) } } diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 6106792f5..ba6aa779c 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -171,6 +171,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -200,6 +201,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -229,6 +231,9 @@ class CourseSectionViewModelTest { @Test fun `getBlocks success`() = runTest { + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } val viewModel = CourseSectionViewModel( "", interactor, @@ -242,9 +247,6 @@ class CourseSectionViewModelTest { downloadDao, ) - coEvery { downloadDao.readAllData() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } coEvery { interactor.getCourseStructureFromCache() } returns courseStructure coEvery { interactor.getCourseStructureForVideos() } returns courseStructure @@ -260,6 +262,9 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels test`() = runTest { + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } val viewModel = CourseSectionViewModel( "", interactor, @@ -275,9 +280,6 @@ class CourseSectionViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -288,6 +290,9 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } val viewModel = CourseSectionViewModel( "", interactor, @@ -303,9 +308,6 @@ class CourseSectionViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } every { coreAnalytics.logEvent(any(), any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -316,6 +318,7 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, without connection`() = runTest { + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } val viewModel = CourseSectionViewModel( "", interactor, @@ -343,6 +346,12 @@ class CourseSectionViewModelTest { @Test fun `updateVideos success`() = runTest { + every { downloadDao.readAllData() } returns flow { + repeat(5) { + delay(10000) + emit(emptyList()) + } + } val viewModel = CourseSectionViewModel( "", interactor, @@ -356,12 +365,6 @@ class CourseSectionViewModelTest { downloadDao, ) - every { downloadDao.readAllData() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } - } coEvery { notifier.notifier } returns flow { } coEvery { interactor.getCourseStructureFromCache() } returns courseStructure coEvery { interactor.getCourseStructureForVideos() } returns courseStructure diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index cccd246f1..fd1ba7956 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -276,7 +276,6 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) - assert(viewModel.isUpdating.value == false) } @Test @@ -300,10 +299,7 @@ class CourseVideoViewModelTest { ) coEvery { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } - viewModel.setIsUpdating() advanceUntilIdle() - - assert(viewModel.isUpdating.value == true) } @Test @@ -418,7 +414,6 @@ class CourseVideoViewModelTest { advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) - assert(!viewModel.hasInternetConnection) } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt index f3706ca28..98491b0d9 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt @@ -22,11 +22,11 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState 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.LocalContext @@ -38,7 +38,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.StaticSearchBar import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -62,21 +61,34 @@ object DiscussionTopic { @Composable fun DiscussionTopicsScreen( + windowSize: WindowSize, + discussionTopicsViewModel: DiscussionTopicsViewModel, + onSearchClick: () -> Unit, + onItemClick: (String, String, String) -> Unit +) { + val uiState by discussionTopicsViewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) + val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) + + DiscussionTopicsScreen( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onSearchClick = onSearchClick, + onItemClick = onItemClick + ) +} + +@Composable +private fun DiscussionTopicsScreen( windowSize: WindowSize, uiState: DiscussionTopicsUIState, uiMessage: UIMessage?, - hasInternetConnection: Boolean, - onReloadClick: () -> Unit, onSearchClick: () -> Unit, onItemClick: (String, String, String) -> Unit ) { val scaffoldState = rememberScaffoldState() val context = LocalContext.current - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), @@ -228,21 +240,6 @@ fun DiscussionTopicsScreen( DiscussionTopicsUIState.Loading -> {} } } - - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } } } } @@ -261,8 +258,6 @@ private fun DiscussionTopicsScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - hasInternetConnection = true, - onReloadClick = {}, onItemClick = { _, _, _ -> }, onSearchClick = {} ) @@ -278,8 +273,6 @@ private fun DiscussionTopicsScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - hasInternetConnection = true, - onReloadClick = {}, onItemClick = { _, _, _ -> }, onSearchClick = {} ) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index d9e13af6f..441357bb7 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -7,13 +7,11 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics @@ -21,7 +19,6 @@ class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, - private val networkConnection: NetworkConnection, val courseId: String, val courseName: String ) : BaseViewModel() { @@ -34,13 +31,6 @@ class DiscussionTopicsViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _isUpdating = MutableSharedFlow() - val isUpdating: SharedFlow - get() = _isUpdating.asSharedFlow() - - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - init { getCourseTopics() } @@ -48,7 +38,6 @@ class DiscussionTopicsViewModel( fun updateCourseTopics() { viewModelScope.launch { try { - _isUpdating.emit(true) val response = interactor.getCourseTopics(courseId) _uiState.value = DiscussionTopicsUIState.Topics(response) } catch (e: Exception) { @@ -58,7 +47,6 @@ class DiscussionTopicsViewModel( _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } - _isUpdating.emit(false) } } diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index a5bbb8083..f91750aaf 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -25,7 +25,6 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.system.ResourceManager -import org.openedx.core.system.connection.NetworkConnection import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import java.net.UnknownHostException @@ -41,7 +40,6 @@ class DiscussionTopicsViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val networkConnection = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -60,7 +58,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -78,7 +76,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -96,7 +94,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() advanceUntilIdle() @@ -113,7 +111,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -121,22 +119,16 @@ class DiscussionTopicsViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - val isUpdating = async { - withTimeoutOrNull(5000) { - viewModel.isUpdating.first() - } - } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } assertEquals(noInternet, message.await()?.message) - assert(!(isUpdating.await() ?: false)) } @Test fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -144,22 +136,16 @@ class DiscussionTopicsViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - val isUpdating = async { - withTimeoutOrNull(5000) { - viewModel.isUpdating.first() - } - } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(!(isUpdating.await() ?: false)) } @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() val message = async { @@ -167,18 +153,12 @@ class DiscussionTopicsViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - val isUpdating = async { - withTimeoutOrNull(5000) { - viewModel.isUpdating.first() - } - } advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseTopics(any()) } assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DiscussionTopicsUIState.Topics) - assert(!(isUpdating.await() ?: false)) } -} \ No newline at end of file +} From 84629257e434326acf33875d17015a4442d2a655 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 24 Apr 2024 14:05:09 +0300 Subject: [PATCH 14/18] refactor: Course home tabs layout --- .../java/org/openedx/core/ui/ComposeCommon.kt | 105 +++++++++++++++ .../main/java/org/openedx/core/ui/TabItem.kt | 8 ++ .../container/CollapsingLayout.kt | 4 +- .../container/CourseContainerFragment.kt | 4 +- .../presentation/container/CourseHomeTab.kt | 20 +++ .../presentation/container/CourseHomeTabs.kt | 123 ------------------ 6 files changed, 139 insertions(+), 125 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/ui/TabItem.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/container/CourseHomeTab.kt delete mode 100644 course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 8e71eebcd..a291d2d41 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -2,12 +2,15 @@ package org.openedx.core.ui import android.os.Build.VERSION.SDK_INT import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme 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.RowScope import androidx.compose.foundation.layout.Spacer @@ -21,7 +24,13 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -37,6 +46,7 @@ import androidx.compose.material.ScaffoldState import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable @@ -45,6 +55,7 @@ import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -60,6 +71,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -88,6 +100,7 @@ import coil.ImageLoader import coil.compose.AsyncImage import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder +import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.RegistrationField @@ -1161,6 +1174,79 @@ fun AuthButtonsPanel( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RoundTabs( + modifier: Modifier = Modifier, + items: List, + pagerState: PagerState, + rowState: LazyListState = rememberLazyListState(), + onPageChange: (Int) -> Unit +) { + val scope = rememberCoroutineScope() + val windowSize = rememberWindowSize() + val horizontalPadding = if (!windowSize.isTablet) { + 12.dp + } else { + 98.dp + } + LazyRow( + modifier = modifier, + state = rowState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), + ) { + itemsIndexed(items) { index, item -> + val backgroundColor = if (pagerState.currentPage == index) { + MaterialTheme.appColors.primary + } else { + MaterialTheme.appColors.tabUnselectedBtnBackground + } + val contentColor = if (pagerState.currentPage == index) { + Color.White + } else { + MaterialTheme.appColors.tabUnselectedBtnContent + } + val border = if (isSystemInDarkTheme()) { + Modifier + } else { + Modifier.border( + 1.dp, + MaterialTheme.appColors.primary, + CircleShape + ) + } + Row( + modifier = Modifier + .height(40.dp) + .clip(CircleShape) + .background(backgroundColor) + .clickable { + scope.launch { + pagerState.scrollToPage(index) + onPageChange(index) + } + } + then (border) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = rememberVectorPainter(item.icon), + tint = contentColor, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(item.title), + color = contentColor + ) + } + } + } +} + @Preview @Composable private fun StaticSearchBarPreview() { @@ -1239,3 +1325,22 @@ private fun ConnectionErrorViewPreview() { ) } } + +val mockTab = object : TabItem { + override val title: Int = R.string.app_name + override val icon: ImageVector = Icons.Default.AccountCircle +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview +@Composable +private fun RoundTabsPreview() { + OpenEdXTheme { + RoundTabs( + items = listOf(mockTab, mockTab, mockTab), + rowState = rememberLazyListState(), + pagerState = rememberPagerState(pageCount = { 3 }), + onPageChange = { } + ) + } +} diff --git a/core/src/main/java/org/openedx/core/ui/TabItem.kt b/core/src/main/java/org/openedx/core/ui/TabItem.kt new file mode 100644 index 000000000..9314d701a --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/TabItem.kt @@ -0,0 +1,8 @@ +package org.openedx.core.ui + +import androidx.compose.ui.graphics.vector.ImageVector + +interface TabItem { + val title: Int + val icon: ImageVector +} \ No newline at end of file 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 57747acb3..13f7995c3 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 @@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.openedx.core.ui.RoundTabs import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset @@ -743,7 +744,8 @@ private fun CollapsingLayoutPreview() { ) }, navigation = { - CourseHomeTabs( + RoundTabs( + items = CourseHomeTab.entries, rowState = rememberLazyListState(), pagerState = rememberPagerState(pageCount = { 5 }), onPageChange = { } 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 0b5403d41..94c262406 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 @@ -51,6 +51,7 @@ import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.RoundTabs import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -258,7 +259,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) }, navigation = { - CourseHomeTabs( + RoundTabs( + items = CourseHomeTab.entries, rowState = tabState, pagerState = pagerState, onPageChange = viewModel::courseContainerTabClickedEvent diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTab.kt new file mode 100644 index 000000000..2a7fc6934 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTab.kt @@ -0,0 +1,20 @@ +package org.openedx.course.presentation.container + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.TextSnippet +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.rounded.PlayCircleFilled +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.ui.TabItem +import org.openedx.course.R + +enum class CourseHomeTab(@StringRes override val title: Int, override val icon: ImageVector) : TabItem { + HOME(R.string.course_navigation_home, Icons.Default.Home), + VIDEOS(R.string.course_navigation_videos, Icons.Rounded.PlayCircleFilled), + DATES(R.string.course_navigation_dates, Icons.Outlined.CalendarMonth), + DISCUSSIONS(R.string.course_navigation_discussions, Icons.AutoMirrored.Filled.Chat), + MORE(R.string.course_navigation_more, Icons.AutoMirrored.Filled.TextSnippet) +} diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt deleted file mode 100644 index 44c2e85ab..000000000 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTabs.kt +++ /dev/null @@ -1,123 +0,0 @@ -package org.openedx.course.presentation.container - -import androidx.annotation.StringRes -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.automirrored.filled.TextSnippet -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.outlined.CalendarMonth -import androidx.compose.material.icons.rounded.PlayCircleFilled -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch -import org.openedx.core.ui.rememberWindowSize -import org.openedx.core.ui.theme.appColors -import org.openedx.course.R - -enum class CourseHomeTab(@StringRes val title: Int, val icon: ImageVector) { - HOME(R.string.course_navigation_home, Icons.Default.Home), - VIDEOS(R.string.course_navigation_videos, Icons.Rounded.PlayCircleFilled), - DATES(R.string.course_navigation_dates, Icons.Outlined.CalendarMonth), - DISCUSSIONS(R.string.course_navigation_discussions, Icons.AutoMirrored.Filled.Chat), - MORE(R.string.course_navigation_more, Icons.AutoMirrored.Filled.TextSnippet) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun CourseHomeTabs( - pagerState: PagerState, - rowState: LazyListState = rememberLazyListState(), - onPageChange: (Int) -> Unit -) { - val scope = rememberCoroutineScope() - val windowSize = rememberWindowSize() - val horizontalPadding = if (!windowSize.isTablet){ - 12.dp - } else { - 98.dp - } - LazyRow( - modifier = Modifier, - state = rowState, - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), - ) { - itemsIndexed(CourseHomeTab.entries) { index, item -> - val backgroundColor = if (pagerState.currentPage == index) { - MaterialTheme.appColors.primary - } else { - MaterialTheme.appColors.tabUnselectedBtnBackground - } - val contentColor = if (pagerState.currentPage == index) { - Color.White - } else { - MaterialTheme.appColors.tabUnselectedBtnContent - } - val border = if (isSystemInDarkTheme()) { - Modifier - } else { - Modifier.border( - 1.dp, - MaterialTheme.appColors.primary, - CircleShape - ) - } - Row( - modifier = Modifier - .height(40.dp) - .clip(CircleShape) - .background(backgroundColor) - .clickable { - scope.launch { - pagerState.scrollToPage(index) - onPageChange(index) - } - } - then (border) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - painter = rememberVectorPainter(item.icon), - tint = contentColor, - contentDescription = null - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(item.title), - color = contentColor - ) - } - } - } -} From 655d2f436c9f20dd83f36214597c06046c853822 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 24 Apr 2024 18:18:41 +0300 Subject: [PATCH 15/18] fix: Fixes according to PR feedback --- .../java/org/openedx/app/di/ScreenModule.kt | 2 + .../core/system/notifier/CourseLoading.kt | 3 + .../core/system/notifier/CourseNotifier.kt | 1 + .../java/org/openedx/core/ui/ComposeCommon.kt | 81 ++++++++++--------- .../org/openedx/core/ui/theme/AppColors.kt | 1 + .../java/org/openedx/core/ui/theme/Theme.kt | 2 + .../org/openedx/core/ui/theme/Colors.kt | 2 + .../container/CourseContainerFragment.kt | 55 +++++++------ .../container/CourseContainerViewModel.kt | 50 +++++++----- .../presentation/dates/CourseDatesScreen.kt | 8 +- .../outline/CourseOutlineScreen.kt | 29 ++++--- .../outline/CourseOutlineUIState.kt | 2 +- .../outline/CourseOutlineViewModel.kt | 19 +++-- .../course/presentation/ui/CourseVideosUI.kt | 10 +-- .../container/CourseUnitContainerAdapter.kt | 4 +- .../container/CourseContainerViewModelTest.kt | 23 ++++-- .../threads/DiscussionThreadsViewModel.kt | 14 ++-- .../topics/DiscussionTopicsFragment.kt | 20 ++--- .../topics/DiscussionTopicsViewModel.kt | 16 +++- .../threads/DiscussionThreadsViewModelTest.kt | 32 ++++---- .../topics/DiscussionTopicsViewModelTest.kt | 14 ++-- 21 files changed, 217 insertions(+), 171 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt 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 164bd0901..153f4f51f 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -195,6 +195,7 @@ val screenModule = module { get(), get(), get(), + get(), get() ) } @@ -313,6 +314,7 @@ val screenModule = module { get(), get(), get(), + get(), courseId, courseName ) diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt new file mode 100644 index 000000000..bcd5d6231 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +data class CourseLoading(val isLoading: Boolean) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index f55beaeee..e39433de3 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -17,4 +17,5 @@ class CourseNotifier { suspend fun send(event: CourseCompletionSet) = channel.emit(event) suspend fun send(event: CalendarSyncEvent) = channel.emit(event) suspend fun send(event: CourseDatesShifted) = channel.emit(event) + suspend fun send(event: CourseLoading) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index a291d2d41..cd8834d85 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1185,11 +1185,7 @@ fun RoundTabs( ) { val scope = rememberCoroutineScope() val windowSize = rememberWindowSize() - val horizontalPadding = if (!windowSize.isTablet) { - 12.dp - } else { - 98.dp - } + val horizontalPadding = if (!windowSize.isTablet) 12.dp else 98.dp LazyRow( modifier = modifier, state = rowState, @@ -1197,56 +1193,61 @@ fun RoundTabs( contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), ) { itemsIndexed(items) { index, item -> - val backgroundColor = if (pagerState.currentPage == index) { - MaterialTheme.appColors.primary - } else { - MaterialTheme.appColors.tabUnselectedBtnBackground - } - val contentColor = if (pagerState.currentPage == index) { - Color.White - } else { - MaterialTheme.appColors.tabUnselectedBtnContent - } - val border = if (isSystemInDarkTheme()) { - Modifier - } else { - Modifier.border( - 1.dp, - MaterialTheme.appColors.primary, - CircleShape - ) - } - Row( + val isSelected = pagerState.currentPage == index + val backgroundColor = + if (isSelected) MaterialTheme.appColors.primary else MaterialTheme.appColors.tabUnselectedBtnBackground + val contentColor = + if (isSelected) MaterialTheme.appColors.tabSelectedBtnContent else MaterialTheme.appColors.tabUnselectedBtnContent + val border = if (!isSystemInDarkTheme()) Modifier.border( + 1.dp, + MaterialTheme.appColors.primary, + CircleShape + ) else Modifier + + RoundTab( modifier = Modifier .height(40.dp) .clip(CircleShape) .background(backgroundColor) + .then(border) .clickable { scope.launch { pagerState.scrollToPage(index) onPageChange(index) } } - then (border) .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - painter = rememberVectorPainter(item.icon), - tint = contentColor, - contentDescription = null - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(item.title), - color = contentColor - ) - } + item = item, + contentColor = contentColor + ) } } } +@Composable +private fun RoundTab( + modifier: Modifier = Modifier, + item: TabItem, + contentColor: Color +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = rememberVectorPainter(item.icon), + tint = contentColor, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(item.title), + color = contentColor + ) + } +} + @Preview @Composable private fun StaticSearchBarPreview() { diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index ab582d109..731a80333 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -54,6 +54,7 @@ data class AppColors( val tabUnselectedBtnBackground: Color, val tabUnselectedBtnContent: Color, + val tabSelectedBtnContent: Color, val courseHomeHeaderShade: Color, val courseHomeBackBtnBackground: Color ) { diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 83e46fcf9..5f31ef0a2 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -74,6 +74,7 @@ private val DarkColorPalette = AppColors( tabUnselectedBtnBackground = dark_tab_unselected_btn_background, tabUnselectedBtnContent = dark_tab_unselected_btn_content, + tabSelectedBtnContent = dark_tab_selected_btn_content, courseHomeHeaderShade = dark_course_home_header_shade, courseHomeBackBtnBackground = dark_course_home_back_btn_background ) @@ -142,6 +143,7 @@ private val LightColorPalette = AppColors( tabUnselectedBtnBackground = light_tab_unselected_btn_background, tabUnselectedBtnContent = light_tab_unselected_btn_content, + tabSelectedBtnContent = light_tab_selected_btn_content, courseHomeHeaderShade = light_course_home_header_shade, courseHomeBackBtnBackground = light_course_home_back_btn_background ) diff --git a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 38c9fdceb..0ef0fc937 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -52,6 +52,7 @@ val light_component_horizontal_progress_selected = Color(0xFFF0CB00) val light_component_horizontal_progress_default = Color(0xFFD6D3D1) val light_tab_unselected_btn_background = Color.White val light_tab_unselected_btn_content = light_primary +val light_tab_selected_btn_content = Color.White val light_course_home_header_shade = Color(0xFFBABABA) val light_course_home_back_btn_background = Color.White @@ -106,5 +107,6 @@ val dark_component_horizontal_progress_selected = Color(0xFFF0CB00) val dark_component_horizontal_progress_default = Color(0xFFD6D3D1) val dark_tab_unselected_btn_background = Color(0xFF273346) val dark_tab_unselected_btn_content = Color.White +val dark_tab_selected_btn_content = Color.White val dark_course_home_header_shade = Color(0xFF999999) val dark_course_home_back_btn_background = Color.Black 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 94c262406..fc4b21c76 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 @@ -5,8 +5,10 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box +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.lazy.rememberLazyListState @@ -36,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -43,7 +46,6 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType @@ -58,7 +60,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.R import org.openedx.course.databinding.FragmentCourseContainerBinding -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarSyncDialog import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.dates.CourseDatesScreen @@ -70,7 +71,6 @@ import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar import org.openedx.course.presentation.videos.CourseVideoViewModel -import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel @@ -78,8 +78,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val binding by viewBinding(FragmentCourseContainerBinding::bind) - private val courseRouter by inject() - private val discussionRouter by inject() + private var isNavigationEnabled = false private val viewModel by viewModel { parametersOf( @@ -89,29 +88,29 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) } - private val discussionTopicsViewModel by viewModel { + private val courseOutlineViewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), requireArguments().getString(ARG_TITLE, "") ) } - private val courseDatesViewModel by viewModel { + private val courseVideoViewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_ENROLLMENT_MODE, ""), requireArguments().getString(ARG_TITLE, "") ) } - private val courseVideoViewModel by viewModel { + private val courseDatesViewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_ENROLLMENT_MODE, ""), requireArguments().getString(ARG_TITLE, "") ) } - private val courseOutlineViewModel by viewModel { + private val discussionTopicsViewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), requireArguments().getString(ARG_TITLE, "") @@ -155,8 +154,9 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { courseOutlineViewModel.getCourseData() courseDatesViewModel.isSelfPaced = viewModel.isSelfPaced courseDatesViewModel.updateAndFetchCalendarSyncState() + isNavigationEnabled = true } else { - courseRouter.navigateToNoAccess( + viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName ) @@ -259,26 +259,31 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) }, navigation = { - RoundTabs( - items = CourseHomeTab.entries, - rowState = tabState, - pagerState = pagerState, - onPageChange = viewModel::courseContainerTabClickedEvent - ) + if (isNavigationEnabled) { + RoundTabs( + items = CourseHomeTab.entries, + rowState = tabState, + pagerState = pagerState, + onPageChange = viewModel::courseContainerTabClickedEvent + ) + } else { + Spacer(modifier = Modifier.height(52.dp)) + } }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, bodyContent = { HorizontalPager( - state = pagerState + state = pagerState, + userScrollEnabled = isNavigationEnabled ) { page -> when (CourseHomeTab.entries[page]) { CourseHomeTab.HOME -> { CourseOutlineScreen( windowSize = windowSize, courseOutlineViewModel = courseOutlineViewModel, - courseRouter = courseRouter, + courseRouter = viewModel.courseRouter, fragmentManager = requireActivity().supportFragmentManager, onResetDatesClick = { courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { @@ -293,7 +298,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { windowSize = windowSize, courseVideoViewModel = courseVideoViewModel, fragmentManager = requireActivity().supportFragmentManager, - courseRouter = courseRouter, + courseRouter = viewModel.courseRouter, ) } @@ -301,7 +306,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { CourseDatesScreen( windowSize = windowSize, courseDatesViewModel = courseDatesViewModel, - courseRouter = courseRouter, + courseRouter = viewModel.courseRouter, fragmentManager = requireActivity().supportFragmentManager, isFragmentResumed = isResumed, updateCourseStructure = { @@ -320,7 +325,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { data, title ) - discussionRouter.navigateToDiscussionThread( + discussionTopicsViewModel.discussionRouter.navigateToDiscussionThread( requireActivity().supportFragmentManager, action, discussionTopicsViewModel.courseId, @@ -330,7 +335,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) }, onSearchClick = { - discussionRouter.navigateToSearchThread( + discussionTopicsViewModel.discussionRouter.navigateToSearchThread( requireActivity().supportFragmentManager, discussionTopicsViewModel.courseId ) @@ -341,7 +346,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { HandoutsScreen( windowSize = windowSize, onHandoutsClick = { - courseRouter.navigateToHandoutsWebView( + viewModel.courseRouter.navigateToHandoutsWebView( requireActivity().supportFragmentManager, requireArguments().getString(ARG_COURSE_ID, ""), getString(R.string.course_handouts), @@ -349,7 +354,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) }, onAnnouncementsClick = { - courseRouter.navigateToHandoutsWebView( + viewModel.courseRouter.navigateToHandoutsWebView( requireActivity().supportFragmentManager, requireArguments().getString(ARG_COURSE_ID, ""), getString(R.string.course_announcements), 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 4efaf5dc6..c921023b4 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 @@ -31,6 +31,7 @@ import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.utils.TimeUtils @@ -43,6 +44,7 @@ import org.openedx.course.presentation.CalendarSyncSnackbar 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.presentation.calendarsync.CalendarManager import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.calendarsync.CalendarSyncUIState @@ -63,11 +65,10 @@ class CourseContainerViewModel( private val corePreferences: CorePreferences, private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, - private val imageProcessor: ImageProcessor + private val imageProcessor: ImageProcessor, + val courseRouter: CourseRouter ) : BaseViewModel() { - var organization: String = "" - private val _dataReady = MutableLiveData() val dataReady: LiveData get() = _dataReady @@ -92,6 +93,10 @@ class CourseContainerViewModel( val isSelfPaced: Boolean get() = _isSelfPaced + private var _organization: String = "" + val organization: String + get() = _organization + val calendarPermissions: Array get() = calendarManager.permissions @@ -135,6 +140,10 @@ class CourseContainerViewModel( if (event is CourseDatesShifted) { _uiMessage.emit(DatesShiftedSnackBar()) } + + if (event is CourseLoading) { + _showProgress.value = event.isLoading + } } } } @@ -155,22 +164,9 @@ class CourseContainerViewModel( } val courseStructure = interactor.getCourseStructureFromCache() courseName = courseStructure.name - organization = courseStructure.org + _organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced - imageProcessor.loadImage( - imageUrl = config.getApiHostURL() + courseStructure.media?.image?.large, - defaultImage = CoreR.drawable.core_no_image_course, - onComplete = { drawable -> - val bitmap = (drawable as BitmapDrawable).bitmap.apply { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - imageProcessor.applyBlur(this@apply, 10f) - } - } - viewModelScope.launch { - _courseImage.emit(bitmap) - } - } - ) + loadCourseImage(courseStructure.media?.image?.large) _dataReady.value = courseStructure.start?.let { start -> start < Date() } @@ -183,10 +179,26 @@ class CourseContainerViewModel( resourceManager.getString(CoreR.string.core_error_unknown_error) } } - _showProgress.value = false } } + private fun loadCourseImage(imageUrl: String?) { + imageProcessor.loadImage( + imageUrl = config.getApiHostURL() + imageUrl, + defaultImage = CoreR.drawable.core_no_image_course, + onComplete = { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap.apply { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + imageProcessor.applyBlur(this@apply, 10f) + } + } + viewModelScope.launch { + _courseImage.emit(bitmap) + } + } + ) + } + fun showRefreshIndicator() { _refreshing.value = true } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index fd49c8a07..715584497 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -106,7 +106,7 @@ fun CourseDatesScreen( val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() val context = LocalContext.current - CourseDatesScreen( + CourseDatesUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, @@ -176,7 +176,7 @@ fun CourseDatesScreen( } @Composable -internal fun CourseDatesScreen( +private fun CourseDatesUI( windowSize: WindowSize, uiState: DatesUIState, uiMessage: UIMessage?, @@ -639,7 +639,7 @@ private fun CourseDateItem( @Composable private fun CourseDatesScreenPreview() { OpenEdXTheme { - CourseDatesScreen( + CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, @@ -658,7 +658,7 @@ private fun CourseDatesScreenPreview() { @Composable private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { - CourseDatesScreen( + CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DatesUIState.Dates(CourseDatesResult(mockedResponse, mockedCourseBannerInfo)), uiMessage = null, 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 4664cc76a..58e9578f0 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 @@ -28,7 +28,6 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -73,15 +72,6 @@ import java.io.File import java.util.Date import org.openedx.course.R as CourseR -fun getUnitBlockIcon(block: Block): Int { - return when (block.type) { - BlockType.VIDEO -> CourseR.drawable.ic_course_video - BlockType.PROBLEM -> CourseR.drawable.ic_course_pen - BlockType.DISCUSSION -> CourseR.drawable.ic_course_discussion - else -> CourseR.drawable.ic_course_block - } -} - @Composable fun CourseOutlineScreen( windowSize: WindowSize, @@ -90,11 +80,11 @@ fun CourseOutlineScreen( fragmentManager: FragmentManager, onResetDatesClick: () -> Unit ) { - val uiState by courseOutlineViewModel.uiState.observeAsState(CourseOutlineUIState.Loading) + val uiState by courseOutlineViewModel.uiState.collectAsState() val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) val context = LocalContext.current - CourseOutlineScreen( + CourseOutlineUI( windowSize = windowSize, uiState = uiState, isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, @@ -187,7 +177,7 @@ fun CourseOutlineScreen( } @Composable -internal fun CourseOutlineScreen( +private fun CourseOutlineUI( windowSize: WindowSize, uiState: CourseOutlineUIState, isCourseNestedListEnabled: Boolean, @@ -488,12 +478,21 @@ private fun ResumeCourseTablet( } } +fun getUnitBlockIcon(block: Block): Int { + return when (block.type) { + BlockType.VIDEO -> CourseR.drawable.ic_course_video + BlockType.PROBLEM -> CourseR.drawable.ic_course_pen + BlockType.DISCUSSION -> CourseR.drawable.ic_course_discussion + else -> CourseR.drawable.ic_course_block + } +} + @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable private fun CourseOutlineScreenPreview() { OpenEdXTheme { - CourseOutlineScreen( + CourseOutlineUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseOutlineUIState.CourseData( mockCourseStructure, @@ -528,7 +527,7 @@ private fun CourseOutlineScreenPreview() { @Composable private fun CourseOutlineScreenTabletPreview() { OpenEdXTheme { - CourseOutlineScreen( + CourseOutlineUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseOutlineUIState.CourseData( mockCourseStructure, 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 8b29be31e..0307b1f8e 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 @@ -16,5 +16,5 @@ sealed class CourseOutlineUIState { val datesBannerInfo: CourseDatesBannerInfo, ) : CourseOutlineUIState() - object Loading : CourseOutlineUIState() + data 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 b70a7dade..d3166a878 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 @@ -1,11 +1,12 @@ package org.openedx.course.presentation.outline -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.R @@ -28,6 +29,7 @@ import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor @@ -39,7 +41,7 @@ import org.openedx.course.R as courseR class CourseOutlineViewModel( val courseId: String, - val courseTitle: String, + private val courseTitle: String, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -59,9 +61,9 @@ class CourseOutlineViewModel( ) { val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - private val _uiState = MutableLiveData(CourseOutlineUIState.Loading) - val uiState: LiveData - get() = _uiState + private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow @@ -126,7 +128,9 @@ class CourseOutlineViewModel( } fun getCourseData() { - _uiState.value = CourseOutlineUIState.Loading + viewModelScope.launch { + notifier.send(CourseLoading(true)) + } getCourseDataInternal() } @@ -201,6 +205,7 @@ class CourseOutlineViewModel( subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, ) + notifier.send(CourseLoading(false)) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) 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 15d053dc2..c69e26c0d 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 @@ -95,7 +95,7 @@ fun CourseVideosScreen( val videoSettings by courseVideoViewModel.videoSettings.collectAsState() val context = LocalContext.current - CourseVideosScreen( + CourseVideosUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, @@ -179,7 +179,7 @@ fun CourseVideosScreen( } @Composable -fun CourseVideosScreen( +private fun CourseVideosUI( windowSize: WindowSize, uiState: CourseVideosUIState, uiMessage: UIMessage?, @@ -699,7 +699,7 @@ private fun AllVideosDownloadItem( @Composable private fun CourseVideosScreenPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideosUIState.CourseData( @@ -735,7 +735,7 @@ private fun CourseVideosScreenPreview() { @Composable private fun CourseVideosScreenEmptyPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideosUIState.Empty( @@ -760,7 +760,7 @@ private fun CourseVideosScreenEmptyPreview() { @Composable private fun CourseVideosScreenTabletPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiMessage = null, uiState = CourseVideosUIState.CourseData( diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt index b175f8946..6d37954ee 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerAdapter.kt @@ -9,7 +9,7 @@ import org.openedx.course.presentation.unit.html.HtmlUnitFragment import org.openedx.course.presentation.unit.video.VideoUnitFragment import org.openedx.course.presentation.unit.video.YoutubeVideoUnitFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment -import org.openedx.discussion.presentation.topics.DiscussionTopic +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel class CourseUnitContainerAdapter( fragment: Fragment, @@ -57,7 +57,7 @@ class CourseUnitContainerAdapter( (block.isDiscussionBlock && block.studentViewData?.topicId.isNullOrEmpty().not()) -> { DiscussionThreadsFragment.newInstance( - DiscussionTopic.TOPIC, + DiscussionTopicsViewModel.TOPIC, viewModel.courseId, block.studentViewData?.topicId ?: "", block.displayName, 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 8e2a13ef7..3691a920c 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 @@ -39,6 +39,7 @@ import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.calendarsync.CalendarManager import java.net.UnknownHostException import java.util.Date @@ -62,6 +63,7 @@ class CourseContainerViewModelTest { private val coursePreferences = mockk() private val mockBitmap = mockk() private val imageProcessor = mockk() + private val courseRouter = mockk() private val openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -141,7 +143,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, - imageProcessor + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() @@ -173,7 +176,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, - imageProcessor + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() @@ -205,7 +209,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, - imageProcessor + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit @@ -237,7 +242,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, - imageProcessor + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit @@ -270,7 +276,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, - imageProcessor + imageProcessor, + courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -299,7 +306,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, - imageProcessor + imageProcessor, + courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } throws Exception() coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit @@ -328,7 +336,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, - imageProcessor + imageProcessor, + courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } returns Unit coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt index e630f5a2f..2dbbd9af6 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt @@ -12,7 +12,7 @@ import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.presentation.topics.DiscussionTopic +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged @@ -101,15 +101,15 @@ class DiscussionThreadsViewModel( } lastOrderBy = orderBy when (threadType) { - DiscussionTopic.ALL_POSTS -> { + DiscussionTopicsViewModel.ALL_POSTS -> { getAllThreads(orderBy) } - DiscussionTopic.FOLLOWING_POSTS -> { + DiscussionTopicsViewModel.FOLLOWING_POSTS -> { getFollowingThreads(orderBy) } - DiscussionTopic.TOPIC -> { + DiscussionTopicsViewModel.TOPIC -> { getThreads( topicId, orderBy @@ -129,15 +129,15 @@ class DiscussionThreadsViewModel( filter } when (threadType) { - DiscussionTopic.ALL_POSTS -> { + DiscussionTopicsViewModel.ALL_POSTS -> { getAllThreads(lastOrderBy) } - DiscussionTopic.FOLLOWING_POSTS -> { + DiscussionTopicsViewModel.FOLLOWING_POSTS -> { getFollowingThreads(lastOrderBy) } - DiscussionTopic.TOPIC -> { + DiscussionTopicsViewModel.TOPIC -> { getThreads( topicId, lastOrderBy diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt index 98491b0d9..c276668b2 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt @@ -53,12 +53,6 @@ import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem import org.openedx.discussion.R as discussionR -object DiscussionTopic { - const val TOPIC = "Topic" - const val ALL_POSTS = "All posts" - const val FOLLOWING_POSTS = "Following" -} - @Composable fun DiscussionTopicsScreen( windowSize: WindowSize, @@ -69,7 +63,7 @@ fun DiscussionTopicsScreen( val uiState by discussionTopicsViewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) - DiscussionTopicsScreen( + DiscussionTopicsUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, @@ -79,7 +73,7 @@ fun DiscussionTopicsScreen( } @Composable -private fun DiscussionTopicsScreen( +private fun DiscussionTopicsUI( windowSize: WindowSize, uiState: DiscussionTopicsUIState, uiMessage: UIMessage?, @@ -191,7 +185,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopic.ALL_POSTS, + DiscussionTopicsViewModel.ALL_POSTS, "", context.getString(discussionR.string.discussion_all_posts) ) @@ -204,7 +198,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopic.FOLLOWING_POSTS, + DiscussionTopicsViewModel.FOLLOWING_POSTS, "", context.getString(discussionR.string.discussion_posts_following) ) @@ -224,7 +218,7 @@ private fun DiscussionTopicsScreen( } else { TopicItem(topic = topic, onClick = { id, title -> onItemClick( - DiscussionTopic.TOPIC, + DiscussionTopicsViewModel.TOPIC, id, title ) @@ -254,7 +248,7 @@ private fun DiscussionTopicsScreen( @Composable private fun DiscussionTopicsScreenPreview() { OpenEdXTheme { - DiscussionTopicsScreen( + DiscussionTopicsUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, @@ -269,7 +263,7 @@ private fun DiscussionTopicsScreenPreview() { @Composable private fun DiscussionTopicsScreenTabletPreview() { OpenEdXTheme { - DiscussionTopicsScreen( + DiscussionTopicsUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 441357bb7..ff8f86884 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -14,11 +14,13 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics +import org.openedx.discussion.presentation.DiscussionRouter class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, + val discussionRouter: DiscussionRouter, val courseId: String, val courseName: String ) : BaseViewModel() { @@ -50,7 +52,7 @@ class DiscussionTopicsViewModel( } } - fun getCourseTopics() { + private fun getCourseTopics() { _uiState.value = DiscussionTopicsUIState.Loading getCourseTopicsInternal() } @@ -72,17 +74,23 @@ class DiscussionTopicsViewModel( fun discussionClickedEvent(action: String, data: String, title: String) { when (action) { - DiscussionTopic.ALL_POSTS -> { + ALL_POSTS -> { analytics.discussionAllPostsClickedEvent(courseId, courseName) } - DiscussionTopic.FOLLOWING_POSTS -> { + FOLLOWING_POSTS -> { analytics.discussionFollowingClickedEvent(courseId, courseName) } - DiscussionTopic.TOPIC -> { + TOPIC -> { analytics.discussionTopicClickedEvent(courseId, courseName, data, title) } } } + + companion object DiscussionTopic { + const val TOPIC = "Topic" + const val ALL_POSTS = "All posts" + const val FOLLOWING_POSTS = "Following" + } } \ No newline at end of file diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index 59ef42720..92e5cd2fa 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt @@ -32,7 +32,7 @@ import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData -import org.openedx.discussion.presentation.topics.DiscussionTopic +import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged @@ -127,7 +127,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) advanceUntilIdle() @@ -147,7 +147,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) coEvery { interactor.getAllThreads(any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -178,7 +178,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) advanceUntilIdle() @@ -206,7 +206,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) advanceUntilIdle() @@ -226,7 +226,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) coEvery { interactor.getFollowingThreads( @@ -281,7 +281,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) advanceUntilIdle() @@ -309,7 +309,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) advanceUntilIdle() @@ -329,7 +329,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -360,7 +360,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) advanceUntilIdle() @@ -379,7 +379,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -399,7 +399,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -419,7 +419,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -449,7 +449,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) viewModel.updateThread("") advanceUntilIdle() @@ -485,7 +485,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) @@ -524,7 +524,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopic.TOPIC + DiscussionTopicsViewModel.TOPIC ) val mockLifeCycleOwner: LifecycleOwner = mockk() diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index f91750aaf..8579edd2a 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -27,6 +27,7 @@ import org.openedx.core.UIMessage import org.openedx.core.system.ResourceManager import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics +import org.openedx.discussion.presentation.DiscussionRouter import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -40,6 +41,7 @@ class DiscussionTopicsViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() + private val router = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -58,7 +60,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -76,7 +78,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -94,7 +96,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() advanceUntilIdle() @@ -111,7 +113,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -128,7 +130,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -145,7 +147,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics,"", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() val message = async { From 64030e403191bee56e93c7e444054f64e490b587 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 25 Apr 2024 14:48:47 +0300 Subject: [PATCH 16/18] fix: Fixes according to PR feedback --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../java/org/openedx/core/ImageProcessor.kt | 2 + .../system/notifier/CourseStructureUpdated.kt | 3 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 10 ++-- .../main/java/org/openedx/core/ui/TabItem.kt | 6 ++- .../container/CollapsingLayout.kt | 6 +-- .../container/CourseContainerFragment.kt | 48 ++++++++++--------- ...CourseHomeTab.kt => CourseContainerTab.kt} | 2 +- .../container/CourseContainerViewModel.kt | 25 ++++++---- .../dates/CourseDatesViewModel.kt | 3 ++ .../outline/CourseOutlineScreen.kt | 30 ++++++------ .../videos/CourseVideoViewModel.kt | 2 + .../container/CourseContainerViewModelTest.kt | 12 ++--- .../dates/CourseDatesViewModelTest.kt | 2 + .../outline/CourseOutlineViewModelTest.kt | 2 +- .../videos/CourseVideoViewModelTest.kt | 17 +------ ...sFragment.kt => DiscussionTopicsScreen.kt} | 0 .../topics/DiscussionTopicsViewModel.kt | 5 ++ .../topics/DiscussionTopicsViewModelTest.kt | 14 +++--- 19 files changed, 101 insertions(+), 89 deletions(-) rename course/src/main/java/org/openedx/course/presentation/container/{CourseHomeTab.kt => CourseContainerTab.kt} (89%) rename discussion/src/main/java/org/openedx/discussion/presentation/topics/{DiscussionTopicsFragment.kt => DiscussionTopicsScreen.kt} (100%) 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 153f4f51f..2a4138072 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -315,6 +315,7 @@ val screenModule = module { get(), get(), get(), + get(), courseId, courseName ) diff --git a/core/src/main/java/org/openedx/core/ImageProcessor.kt b/core/src/main/java/org/openedx/core/ImageProcessor.kt index 901ec9f5e..1d63b22a3 100644 --- a/core/src/main/java/org/openedx/core/ImageProcessor.kt +++ b/core/src/main/java/org/openedx/core/ImageProcessor.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package org.openedx.core import android.content.Context diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt index e89956d0e..0587f5eb4 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureUpdated.kt @@ -1,6 +1,5 @@ package org.openedx.core.system.notifier class CourseStructureUpdated( - val courseId: String, - val withSwipeRefresh: Boolean, + val courseId: String ) : CourseEvent \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index cd8834d85..de27ba179 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1176,7 +1176,7 @@ fun AuthButtonsPanel( @OptIn(ExperimentalFoundationApi::class) @Composable -fun RoundTabs( +fun RoundTabsBar( modifier: Modifier = Modifier, items: List, pagerState: PagerState, @@ -1242,7 +1242,7 @@ private fun RoundTab( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(item.title), + text = stringResource(item.labelResId), color = contentColor ) } @@ -1328,16 +1328,16 @@ private fun ConnectionErrorViewPreview() { } val mockTab = object : TabItem { - override val title: Int = R.string.app_name + override val labelResId: Int = R.string.app_name override val icon: ImageVector = Icons.Default.AccountCircle } @OptIn(ExperimentalFoundationApi::class) @Preview @Composable -private fun RoundTabsPreview() { +private fun RoundTabsBarPreview() { OpenEdXTheme { - RoundTabs( + RoundTabsBar( items = listOf(mockTab, mockTab, mockTab), rowState = rememberLazyListState(), pagerState = rememberPagerState(pageCount = { 3 }), diff --git a/core/src/main/java/org/openedx/core/ui/TabItem.kt b/core/src/main/java/org/openedx/core/ui/TabItem.kt index 9314d701a..65a88861e 100644 --- a/core/src/main/java/org/openedx/core/ui/TabItem.kt +++ b/core/src/main/java/org/openedx/core/ui/TabItem.kt @@ -1,8 +1,10 @@ package org.openedx.core.ui +import androidx.annotation.StringRes import androidx.compose.ui.graphics.vector.ImageVector interface TabItem { - val title: Int + @get:StringRes + val labelResId: Int val icon: ImageVector -} \ No newline at end of file +} 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 13f7995c3..b08ae278d 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 @@ -64,7 +64,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.openedx.core.ui.RoundTabs +import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset @@ -744,8 +744,8 @@ private fun CollapsingLayoutPreview() { ) }, navigation = { - RoundTabs( - items = CourseHomeTab.entries, + RoundTabsBar( + items = CourseContainerTab.entries, rowState = rememberLazyListState(), pagerState = rememberPagerState(pageCount = { 5 }), onPageChange = { } 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 fc4b21c76..a7bcbbd2d 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 @@ -53,7 +53,7 @@ import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog -import org.openedx.core.ui.RoundTabs +import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -179,24 +179,26 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun onRefresh(currentPage: Int) { viewModel.showRefreshIndicator() - when (CourseHomeTab.entries[currentPage]) { - CourseHomeTab.HOME -> { - updateCourseStructure(false) + when (CourseContainerTab.entries[currentPage]) { + CourseContainerTab.HOME -> { + updateCourseStructure() } - CourseHomeTab.VIDEOS -> { - updateCourseStructure(false) + CourseContainerTab.VIDEOS -> { + updateCourseStructure() } - CourseHomeTab.DATES -> { + CourseContainerTab.DATES -> { courseDatesViewModel.getCourseDates() } - CourseHomeTab.DISCUSSIONS -> { + CourseContainerTab.DISCUSSIONS -> { discussionTopicsViewModel.updateCourseTopics() } - else -> {} + else -> { + viewModel.hideRefreshIndicator() + } } } @@ -217,7 +219,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { val refreshing by viewModel.refreshing.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - val pagerState = rememberPagerState(pageCount = { CourseHomeTab.entries.size }) + val pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -260,8 +262,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { }, navigation = { if (isNavigationEnabled) { - RoundTabs( - items = CourseHomeTab.entries, + RoundTabsBar( + items = CourseContainerTab.entries, rowState = tabState, pagerState = pagerState, onPageChange = viewModel::courseContainerTabClickedEvent @@ -278,8 +280,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { state = pagerState, userScrollEnabled = isNavigationEnabled ) { page -> - when (CourseHomeTab.entries[page]) { - CourseHomeTab.HOME -> { + when (CourseContainerTab.entries[page]) { + CourseContainerTab.HOME -> { CourseOutlineScreen( windowSize = windowSize, courseOutlineViewModel = courseOutlineViewModel, @@ -293,7 +295,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) } - CourseHomeTab.VIDEOS -> { + CourseContainerTab.VIDEOS -> { CourseVideosScreen( windowSize = windowSize, courseVideoViewModel = courseVideoViewModel, @@ -302,7 +304,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) } - CourseHomeTab.DATES -> { + CourseContainerTab.DATES -> { CourseDatesScreen( windowSize = windowSize, courseDatesViewModel = courseDatesViewModel, @@ -310,12 +312,12 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { fragmentManager = requireActivity().supportFragmentManager, isFragmentResumed = isResumed, updateCourseStructure = { - updateCourseStructure(false) + updateCourseStructure() } ) } - CourseHomeTab.DISCUSSIONS -> { + CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( windowSize = windowSize, discussionTopicsViewModel = discussionTopicsViewModel, @@ -342,7 +344,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { }) } - CourseHomeTab.MORE -> { + CourseContainerTab.MORE -> { HandoutsScreen( windowSize = windowSize, onHandoutsClick = { @@ -395,7 +397,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { hostState = snackState ) { snackbarData: SnackbarData -> DatesShiftedSnackBar( - showAction = true, + showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, onViewDates = { scrollToDates(scope, pagerState) }, @@ -500,8 +502,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } - private fun updateCourseStructure(withSwipeRefresh: Boolean) { - viewModel.updateData(withSwipeRefresh) + private fun updateCourseStructure() { + viewModel.updateData() } private fun updateCourseDates() { @@ -511,7 +513,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { - pagerState.animateScrollToPage(CourseHomeTab.entries.indexOf(CourseHomeTab.DATES)) + pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) } } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt similarity index 89% rename from course/src/main/java/org/openedx/course/presentation/container/CourseHomeTab.kt rename to course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index 2a7fc6934..8cdd683e0 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseHomeTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.ui.TabItem import org.openedx.course.R -enum class CourseHomeTab(@StringRes override val title: Int, override val icon: ImageVector) : TabItem { +enum class CourseContainerTab(@StringRes override val labelResId: Int, override val icon: ImageVector) : TabItem { HOME(R.string.course_navigation_home, Icons.Default.Home), VIDEOS(R.string.course_navigation_videos, Icons.Rounded.PlayCircleFilled), DATES(R.string.course_navigation_dates, Icons.Outlined.CalendarMonth), 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 c921023b4..30e5a4495 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 @@ -123,7 +123,7 @@ class CourseContainerViewModel( viewModelScope.launch { notifier.notifier.collect { event -> if (event is CourseCompletionSet) { - updateData(false) + updateData() } if (event is CreateCalendarSyncEvent) { @@ -143,6 +143,9 @@ class CourseContainerViewModel( if (event is CourseLoading) { _showProgress.value = event.isLoading + if (!event.isLoading) { + _refreshing.value = false + } } } } @@ -203,7 +206,11 @@ class CourseContainerViewModel( _refreshing.value = true } - fun updateData(withSwipeRefresh: Boolean) { + fun hideRefreshIndicator() { + _refreshing.value = false + } + + fun updateData() { viewModelScope.launch { try { interactor.preloadCourseStructure(courseId) @@ -217,17 +224,17 @@ class CourseContainerViewModel( } } _refreshing.value = false - notifier.send(CourseStructureUpdated(courseId, withSwipeRefresh)) + notifier.send(CourseStructureUpdated(courseId)) } } fun courseContainerTabClickedEvent(index: Int) { - when (CourseHomeTab.entries[index]) { - CourseHomeTab.HOME -> courseTabClickedEvent() - CourseHomeTab.VIDEOS -> videoTabClickedEvent() - CourseHomeTab.DISCUSSIONS -> discussionTabClickedEvent() - CourseHomeTab.DATES -> datesTabClickedEvent() - CourseHomeTab.MORE -> moreTabClickedEvent() + when (CourseContainerTab.entries[index]) { + CourseContainerTab.HOME -> courseTabClickedEvent() + CourseContainerTab.VIDEOS -> videoTabClickedEvent() + CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() + CourseContainerTab.DATES -> datesTabClickedEvent() + CourseContainerTab.MORE -> moreTabClickedEvent() } } 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 ed1f6d444..e0ba0ddca 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 @@ -26,6 +26,7 @@ import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -105,6 +106,8 @@ class CourseDatesViewModel( } else { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } + } finally { + notifier.send(CourseLoading(false)) } } } 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 58e9578f0..7720631f4 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 @@ -43,7 +43,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -62,6 +61,7 @@ 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.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet @@ -70,7 +70,7 @@ import org.openedx.course.presentation.ui.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem import java.io.File import java.util.Date -import org.openedx.course.R as CourseR +import org.openedx.core.R as CoreR @Composable fun CourseOutlineScreen( @@ -162,7 +162,7 @@ fun CourseOutlineScreen( context.externalCacheDir.toString() + File.separator + context - .getString(R.string.app_name) + .getString(CoreR.string.app_name) .replace(Regex("\\s"), "_"), it.id ) } @@ -378,7 +378,7 @@ private fun ResumeCourse( modifier = modifier.fillMaxWidth() ) { Text( - text = stringResource(id = org.openedx.course.R.string.course_continue_with), + text = stringResource(id = R.string.course_continue_with), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -403,14 +403,14 @@ private fun ResumeCourse( } Spacer(Modifier.height(24.dp)) OpenEdXButton( - text = stringResource(id = org.openedx.course.R.string.course_resume), + text = stringResource(id = R.string.course_resume), onClick = { onResumeClick(block.id) }, content = { TextIcon( - text = stringResource(id = org.openedx.course.R.string.course_resume), - painter = painterResource(id = R.drawable.core_ic_forward), + text = stringResource(id = R.string.course_resume), + painter = painterResource(id = CoreR.drawable.core_ic_forward), color = MaterialTheme.appColors.buttonText, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -436,7 +436,7 @@ private fun ResumeCourseTablet( .padding(end = 35.dp) ) { Text( - text = stringResource(id = org.openedx.course.R.string.course_continue_with), + text = stringResource(id = R.string.course_continue_with), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -462,14 +462,14 @@ private fun ResumeCourseTablet( } OpenEdXButton( modifier = Modifier.width(210.dp), - text = stringResource(id = org.openedx.course.R.string.course_resume), + text = stringResource(id = R.string.course_resume), onClick = { onResumeClick(block.id) }, content = { TextIcon( - text = stringResource(id = org.openedx.course.R.string.course_resume), - painter = painterResource(id = R.drawable.core_ic_forward), + text = stringResource(id = R.string.course_resume), + painter = painterResource(id = CoreR.drawable.core_ic_forward), color = MaterialTheme.appColors.buttonText, textStyle = MaterialTheme.appTypography.labelLarge ) @@ -480,10 +480,10 @@ private fun ResumeCourseTablet( fun getUnitBlockIcon(block: Block): Int { return when (block.type) { - BlockType.VIDEO -> CourseR.drawable.ic_course_video - BlockType.PROBLEM -> CourseR.drawable.ic_course_pen - BlockType.DISCUSSION -> CourseR.drawable.ic_course_discussion - else -> CourseR.drawable.ic_course_block + BlockType.VIDEO -> R.drawable.ic_course_video + BlockType.PROBLEM -> R.drawable.ic_course_pen + BlockType.DISCUSSION -> R.drawable.ic_course_discussion + else -> R.drawable.ic_course_block } } diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 89379bd85..9204d2045 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -20,6 +20,7 @@ import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier @@ -163,6 +164,7 @@ class CourseVideoViewModel( courseSectionsState, subSectionsDownloadsCount, getDownloadModelsSize() ) } + courseNotifier.send(CourseLoading(false)) } } 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 3691a920c..63dce6272 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 @@ -280,8 +280,8 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() - coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit - viewModel.updateData(false) + coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + viewModel.updateData() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } @@ -310,8 +310,8 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } throws Exception() - coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit - viewModel.updateData(false) + coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + viewModel.updateData() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } @@ -340,8 +340,8 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.preloadCourseStructure(any()) } returns Unit - coEvery { notifier.send(CourseStructureUpdated("", false)) } returns Unit - viewModel.updateData(false) + coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + viewModel.updateData() advanceUntilIdle() coVerify(exactly = 1) { interactor.preloadCourseStructure(any()) } 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 fbade5adc..69d330d8d 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 @@ -39,6 +39,7 @@ 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.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -147,6 +148,7 @@ class CourseDatesViewModelTest { every { notifier.notifier } returns emptyFlow() every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle coEvery { notifier.send(any()) } returns Unit + coEvery { notifier.send(any()) } returns Unit } @After 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 6c5c76efd..098960a2a 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 @@ -441,7 +441,7 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } + coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index fd1ba7956..4658fd6de 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -242,7 +242,7 @@ class CourseVideoViewModelTest { fun `updateVideos success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } + coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } every { downloadDao.readAllData() } returns flow { repeat(5) { delay(10000) @@ -282,21 +282,6 @@ class CourseVideoViewModelTest { fun `setIsUpdating success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - coreAnalytics, - downloadDao, - workerController - ) coEvery { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } advanceUntilIdle() diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt similarity index 100% rename from discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt rename to discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index ff8f86884..185f48268 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -12,6 +12,8 @@ import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.CourseLoading +import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter @@ -20,6 +22,7 @@ class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, + private val notifier: CourseNotifier, val discussionRouter: DiscussionRouter, val courseId: String, val courseName: String @@ -48,6 +51,8 @@ class DiscussionTopicsViewModel( } else { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } + } finally { + notifier.send(CourseLoading(false)) } } } diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 8579edd2a..e772d4cae 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -25,6 +25,7 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter @@ -42,6 +43,7 @@ class DiscussionTopicsViewModelTest { private val interactor = mockk() private val analytics = mockk() private val router = mockk() + private val courseNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -60,7 +62,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -78,7 +80,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -96,7 +98,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() advanceUntilIdle() @@ -113,7 +115,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -130,7 +132,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -147,7 +149,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") coEvery { interactor.getCourseTopics(any()) } returns mockk() val message = async { From a314e3dcad83b2d9437ac8403440312335e12407 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 29 Apr 2024 21:33:02 +0300 Subject: [PATCH 17/18] refactor: Refactored view models of course container screens --- .../java/org/openedx/app/di/ScreenModule.kt | 10 +- core/build.gradle | 1 + .../course}/CourseContainerTab.kt | 14 +- .../core/system/notifier/CourseDataReady.kt | 5 + .../core/system/notifier/CourseNotifier.kt | 2 + .../core/system/notifier/CourseRefresh.kt | 5 + core/src/main/res/values-uk/strings.xml | 5 + core/src/main/res/values/strings.xml | 6 + .../container/CollapsingLayout.kt | 1 + .../container/CourseContainerFragment.kt | 130 +++++------------- .../container/CourseContainerViewModel.kt | 86 ++++++++---- .../dates/CourseDatesViewModel.kt | 48 ++++--- .../outline/CourseOutlineScreen.kt | 8 +- .../outline/CourseOutlineViewModel.kt | 24 ++-- .../videos/CourseVideoViewModel.kt | 15 +- course/src/main/res/values-uk/strings.xml | 8 +- course/src/main/res/values/strings.xml | 5 - .../dates/CourseDatesViewModelTest.kt | 17 +-- .../videos/CourseVideoViewModelTest.kt | 8 +- .../discovery/presentation/DiscoveryRouter.kt | 5 +- .../presentation/info/CourseInfoViewModel.kt | 2 +- .../topics/DiscussionTopicsScreen.kt | 31 ++++- .../topics/DiscussionTopicsViewModel.kt | 36 ++++- .../topics/DiscussionTopicsViewModelTest.kt | 99 +++++++++++-- 24 files changed, 357 insertions(+), 214 deletions(-) rename {course/src/main/java/org/openedx/course/presentation/container => core/src/main/java/org/openedx/core/presentation/course}/CourseContainerTab.kt (54%) create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt 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 2a4138072..1b1d8f0cd 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -281,10 +281,8 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseName: String, enrollmentMode: String) -> + viewModel { (enrollmentMode: String) -> CourseDatesViewModel( - courseId, - courseName, enrollmentMode, get(), get(), @@ -309,15 +307,13 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { (courseId: String, courseName: String) -> + viewModel { DiscussionTopicsViewModel( get(), get(), get(), get(), - get(), - courseId, - courseName + get() ) } viewModel { (courseId: String, topicId: String, threadType: String) -> diff --git a/core/build.gradle b/core/build.gradle index 8c4bdcc6f..f1f091823 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -139,6 +139,7 @@ dependencies { // Koin DI api "io.insert-koin:koin-core:$koin_version" api "io.insert-koin:koin-android:$koin_version" + api "io.insert-koin:koin-androidx-compose:$koin_version" api "io.coil-kt:coil-compose:$coil_version" api "io.coil-kt:coil-gif:$coil_version" diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt similarity index 54% rename from course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt rename to core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt index 8cdd683e0..7aef4bd9a 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.container +package org.openedx.core.presentation.course import androidx.annotation.StringRes import androidx.compose.material.icons.Icons @@ -8,13 +8,13 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.R import org.openedx.core.ui.TabItem -import org.openedx.course.R enum class CourseContainerTab(@StringRes override val labelResId: Int, override val icon: ImageVector) : TabItem { - HOME(R.string.course_navigation_home, Icons.Default.Home), - VIDEOS(R.string.course_navigation_videos, Icons.Rounded.PlayCircleFilled), - DATES(R.string.course_navigation_dates, Icons.Outlined.CalendarMonth), - DISCUSSIONS(R.string.course_navigation_discussions, Icons.AutoMirrored.Filled.Chat), - MORE(R.string.course_navigation_more, Icons.AutoMirrored.Filled.TextSnippet) + HOME(R.string.core_course_container_nav_home, Icons.Default.Home), + VIDEOS(R.string.core_course_container_nav_videos, Icons.Rounded.PlayCircleFilled), + DATES(R.string.core_course_container_nav_dates, Icons.Outlined.CalendarMonth), + DISCUSSIONS(R.string.core_course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), + MORE(R.string.core_course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt new file mode 100644 index 000000000..0ad123d17 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseDataReady.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +import org.openedx.core.domain.model.CourseStructure + +data class CourseDataReady(val courseStructure: CourseStructure) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index e39433de3..63660b4de 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -18,4 +18,6 @@ class CourseNotifier { suspend fun send(event: CalendarSyncEvent) = channel.emit(event) suspend fun send(event: CourseDatesShifted) = channel.emit(event) suspend fun send(event: CourseLoading) = channel.emit(event) + suspend fun send(event: CourseDataReady) = channel.emit(event) + suspend fun send(event: CourseRefresh) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt new file mode 100644 index 000000000..c85fc595d --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +import org.openedx.core.presentation.course.CourseContainerTab + +data class CourseRefresh(val courseContainerTab: CourseContainerTab) : CourseEvent diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index dc6e2ffa2..f20cd28e1 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -67,4 +67,9 @@ Заглавне зображення для курсу %1$s Якість транслювання відео + + Курс + Відео + Обговорення + Матеріали diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 40c288675..12cc334a7 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -126,4 +126,10 @@ Videos %d, %s Total Video streaming quality Video download quality + + Home + Videos + Discussions + More + Dates 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 b08ae278d..b5d73adaf 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 @@ -64,6 +64,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize 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 a7bcbbd2d..c397f150f 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 @@ -46,10 +46,11 @@ import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope 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.FragmentViewType import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog @@ -63,16 +64,12 @@ import org.openedx.course.databinding.FragmentCourseContainerBinding import org.openedx.course.presentation.calendarsync.CalendarSyncDialog import org.openedx.course.presentation.calendarsync.CalendarSyncDialogType import org.openedx.course.presentation.dates.CourseDatesScreen -import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsScreen import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.outline.CourseOutlineScreen -import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.ui.CourseVideosScreen import org.openedx.course.presentation.ui.DatesShiftedSnackBar -import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen -import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { @@ -88,35 +85,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { ) } - private val courseOutlineViewModel by viewModel { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_TITLE, "") - ) - } - - private val courseVideoViewModel by viewModel { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_TITLE, "") - ) - } - - private val courseDatesViewModel by viewModel { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_ENROLLMENT_MODE, ""), - requireArguments().getString(ARG_TITLE, "") - ) - } - - private val discussionTopicsViewModel by viewModel { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_TITLE, "") - ) - } - private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { isGranted -> @@ -150,10 +118,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> if (isReady == true) { - setupCollapsingLayout() - courseOutlineViewModel.getCourseData() - courseDatesViewModel.isSelfPaced = viewModel.isSelfPaced - courseDatesViewModel.updateAndFetchCalendarSyncState() isNavigationEnabled = true } else { viewModel.courseRouter.navigateToNoAccess( @@ -178,28 +142,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } private fun onRefresh(currentPage: Int) { - viewModel.showRefreshIndicator() - when (CourseContainerTab.entries[currentPage]) { - CourseContainerTab.HOME -> { - updateCourseStructure() - } - - CourseContainerTab.VIDEOS -> { - updateCourseStructure() - } - - CourseContainerTab.DATES -> { - courseDatesViewModel.getCourseDates() - } - - CourseContainerTab.DISCUSSIONS -> { - discussionTopicsViewModel.updateCourseTopics() - } - - else -> { - viewModel.hideRefreshIndicator() - } - } + viewModel.onRefresh(CourseContainerTab.entries[currentPage]) } @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) @@ -278,19 +221,25 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { bodyContent = { HorizontalPager( state = pagerState, - userScrollEnabled = isNavigationEnabled + userScrollEnabled = isNavigationEnabled, + beyondBoundsPageCount = CourseContainerTab.entries.size ) { page -> when (CourseContainerTab.entries[page]) { CourseContainerTab.HOME -> { CourseOutlineScreen( windowSize = windowSize, - courseOutlineViewModel = courseOutlineViewModel, + courseOutlineViewModel = koinViewModel( + parameters = { + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_TITLE, "") + ) + } + ), courseRouter = viewModel.courseRouter, fragmentManager = requireActivity().supportFragmentManager, onResetDatesClick = { - courseOutlineViewModel.resetCourseDatesBanner(onResetDates = { - updateCourseDates() - }) + viewModel.onRefresh(CourseContainerTab.DATES) } ) } @@ -298,7 +247,14 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { CourseContainerTab.VIDEOS -> { CourseVideosScreen( windowSize = windowSize, - courseVideoViewModel = courseVideoViewModel, + courseVideoViewModel = koinViewModel( + parameters = { + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_TITLE, "") + ) + } + ), fragmentManager = requireActivity().supportFragmentManager, courseRouter = viewModel.courseRouter, ) @@ -306,13 +262,19 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { CourseContainerTab.DATES -> { CourseDatesScreen( + courseDatesViewModel = koinViewModel( + parameters = { + parametersOf( + requireArguments().getString(ARG_ENROLLMENT_MODE, "") + ) + } + ), windowSize = windowSize, - courseDatesViewModel = courseDatesViewModel, courseRouter = viewModel.courseRouter, fragmentManager = requireActivity().supportFragmentManager, isFragmentResumed = isResumed, updateCourseStructure = { - updateCourseStructure() + viewModel.updateData() } ) } @@ -320,28 +282,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { CourseContainerTab.DISCUSSIONS -> { DiscussionTopicsScreen( windowSize = windowSize, - discussionTopicsViewModel = discussionTopicsViewModel, - onItemClick = { action, data, title -> - discussionTopicsViewModel.discussionClickedEvent( - action, - data, - title - ) - discussionTopicsViewModel.discussionRouter.navigateToDiscussionThread( - requireActivity().supportFragmentManager, - action, - discussionTopicsViewModel.courseId, - data, - title, - FragmentViewType.FULL_CONTENT - ) - }, - onSearchClick = { - discussionTopicsViewModel.discussionRouter.navigateToSearchThread( - requireActivity().supportFragmentManager, - discussionTopicsViewModel.courseId - ) - }) + fragmentManager = requireActivity().supportFragmentManager + ) } CourseContainerTab.MORE -> { @@ -502,14 +444,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } - private fun updateCourseStructure() { - viewModel.updateData() - } - - private fun updateCourseDates() { - courseDatesViewModel.getCourseDates() - } - @OptIn(ExperimentalFoundationApi::class) private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { scope.launch { diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index c47567156..05ae4e80d 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 @@ -25,14 +25,17 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet +import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseRefresh import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar @@ -60,7 +63,7 @@ class CourseContainerViewModel( private val interactor: CourseInteractor, private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, private val coursePreferences: CoursePreferences, @@ -121,30 +124,32 @@ class CourseContainerViewModel( init { viewModelScope.launch { - notifier.notifier.collect { event -> - if (event is CourseCompletionSet) { - updateData() - } + courseNotifier.notifier.collect { event -> + when (event) { + is CourseCompletionSet -> { + updateData() + } - if (event is CreateCalendarSyncEvent) { - _calendarSyncUIState.update { - val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) - it.copy( - courseDates = event.courseDates, - dialogType = dialogType, - checkForOutOfSync = AtomicReference(event.checkOutOfSync) - ) + is CreateCalendarSyncEvent -> { + _calendarSyncUIState.update { + val dialogType = CalendarSyncDialogType.valueOf(event.dialogType) + it.copy( + courseDates = event.courseDates, + dialogType = dialogType, + checkForOutOfSync = AtomicReference(event.checkOutOfSync) + ) + } } - } - if (event is CourseDatesShifted) { - _uiMessage.emit(DatesShiftedSnackBar()) - } + is CourseDatesShifted -> { + _uiMessage.emit(DatesShiftedSnackBar()) + } - if (event is CourseLoading) { - _showProgress.value = event.isLoading - if (!event.isLoading) { - _refreshing.value = false + is CourseLoading -> { + _showProgress.value = event.isLoading + if (!event.isLoading) { + _refreshing.value = false + } } } } @@ -171,7 +176,11 @@ class CourseContainerViewModel( _isSelfPaced = courseStructure.isSelfPaced loadCourseImage(courseStructure.media?.image?.large) _dataReady.value = courseStructure.start?.let { start -> - start < Date() + val isReady = start < Date() + if (isReady) { + courseNotifier.send(CourseDataReady(courseStructure)) + } + isReady } } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { @@ -202,12 +211,33 @@ class CourseContainerViewModel( ) } - fun showRefreshIndicator() { + fun onRefresh(courseContainerTab: CourseContainerTab) { _refreshing.value = true - } + when (courseContainerTab) { + CourseContainerTab.HOME -> { + updateData() + } + + CourseContainerTab.VIDEOS -> { + updateData() + } - fun hideRefreshIndicator() { - _refreshing.value = false + CourseContainerTab.DATES -> { + viewModelScope.launch { + courseNotifier.send(CourseRefresh(courseContainerTab)) + } + } + + CourseContainerTab.DISCUSSIONS -> { + viewModelScope.launch { + courseNotifier.send(CourseRefresh(courseContainerTab)) + } + } + + else -> { + _refreshing.value = false + } + } } fun updateData() { @@ -224,7 +254,7 @@ class CourseContainerViewModel( } } _refreshing.value = false - notifier.send(CourseStructureUpdated(courseId)) + courseNotifier.send(CourseStructureUpdated(courseId)) } } @@ -302,7 +332,7 @@ class CourseContainerViewModel( val isCalendarSynced = calendarManager.isCalendarExists( calendarTitle = _calendarSyncUIState.value.calendarTitle ) - notifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) + courseNotifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) } } 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 ade1a401d..79f866ba7 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 @@ -22,12 +22,15 @@ import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.extension.getSequentialBlocks import org.openedx.core.extension.getVerticalBlocks import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseRefresh import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -38,10 +41,8 @@ import org.openedx.course.presentation.calendarsync.CalendarSyncUIState import org.openedx.core.R as CoreR class CourseDatesViewModel( - val courseId: String, - var courseName: String, private val enrollmentMode: String, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, @@ -50,6 +51,8 @@ class CourseDatesViewModel( private val config: Config, ) : BaseViewModel() { + var courseId = "" + var courseName = "" var isSelfPaced = true private val _uiState = MutableLiveData(DatesUIState.Loading) @@ -75,20 +78,31 @@ class CourseDatesViewModel( val isCourseExpandableSectionsEnabled get() = config.isCourseNestedListEnabled() init { - getCourseDates() viewModelScope.launch { - notifier.notifier.collect { event -> - if (event is CheckCalendarSyncEvent) { - _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } + courseNotifier.notifier.collect { event -> + when (event) { + is CheckCalendarSyncEvent -> { + _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } + } + + is CourseRefresh -> { + if (event.courseContainerTab == CourseContainerTab.DATES) { + loadingCourseDatesInternal() + } + } + + is CourseDataReady -> { + courseId = event.courseStructure.id + courseName = event.courseStructure.name + isSelfPaced = event.courseStructure.isSelfPaced + loadingCourseDatesInternal() + updateAndFetchCalendarSyncState() + } } } } } - fun getCourseDates() { - loadingCourseDatesInternal() - } - private fun loadingCourseDatesInternal() { viewModelScope.launch { try { @@ -107,7 +121,7 @@ class CourseDatesViewModel( _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_unknown_error))) } } finally { - notifier.send(CourseLoading(false)) + courseNotifier.send(CourseLoading(false)) } } } @@ -116,8 +130,8 @@ class CourseDatesViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - getCourseDates() - notifier.send(CourseDatesShifted) + loadingCourseDatesInternal() + courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { @@ -160,7 +174,7 @@ class CourseDatesViewModel( ) } - fun updateAndFetchCalendarSyncState(): Boolean { + private fun updateAndFetchCalendarSyncState(): Boolean { val isCalendarSynced = calendarManager.isCalendarExists( calendarTitle = _calendarSyncUIState.value.calendarTitle ) @@ -172,7 +186,7 @@ class CourseDatesViewModel( val value = _uiState.value if (value is DatesUIState.Dates) { viewModelScope.launch { - notifier.send( + courseNotifier.send( CreateCalendarSyncEvent( courseDates = value.courseDatesResult.datesSection.values.flatten(), dialogType = dialog.name, @@ -187,7 +201,7 @@ class CourseDatesViewModel( val value = _uiState.value if (value is DatesUIState.Dates) { viewModelScope.launch { - notifier.send( + courseNotifier.send( CreateCalendarSyncEvent( courseDates = value.courseDatesResult.datesSection.values.flatten(), dialogType = CalendarSyncDialogType.NONE.name, 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 7720631f4..7e950cba8 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 @@ -167,7 +167,13 @@ fun CourseOutlineScreen( ) } }, - onResetDatesClick = onResetDatesClick, + onResetDatesClick = { + courseOutlineViewModel.resetCourseDatesBanner( + onResetDates = { + onResetDatesClick() + } + ) + }, onCertificateClick = { courseOutlineViewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() 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 d3166a878..569498ab6 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 @@ -28,6 +28,7 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -45,7 +46,7 @@ class CourseOutlineViewModel( private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, @@ -82,10 +83,15 @@ class CourseOutlineViewModel( init { viewModelScope.launch { - notifier.notifier.collect { event -> - if (event is CourseStructureUpdated) { - if (event.courseId == courseId) { - updateCourseData() + courseNotifier.notifier.collect { event -> + when(event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + updateCourseData() + } + } + is CourseDataReady -> { + getCourseData() } } } @@ -129,7 +135,7 @@ class CourseOutlineViewModel( fun getCourseData() { viewModelScope.launch { - notifier.send(CourseLoading(true)) + courseNotifier.send(CourseLoading(true)) } getCourseDataInternal() } @@ -205,7 +211,7 @@ class CourseOutlineViewModel( subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, ) - notifier.send(CourseLoading(false)) + courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) @@ -260,7 +266,7 @@ class CourseOutlineViewModel( try { interactor.resetCourseDates(courseId = courseId) updateCourseData() - notifier.send(CourseDatesShifted) + courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { if (e.isInternetError()) { @@ -331,7 +337,7 @@ class CourseOutlineViewModel( private fun checkIfCalendarOutOfDate(courseDates: List) { viewModelScope.launch { - notifier.send( + courseNotifier.send( CreateCalendarSyncEvent( courseDates = courseDates, dialogType = CalendarSyncDialogType.NONE.name, diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 9204d2045..dc88105a8 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -20,6 +20,7 @@ import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated @@ -71,9 +72,15 @@ class CourseVideoViewModel( init { viewModelScope.launch { courseNotifier.notifier.collect { event -> - if (event is CourseStructureUpdated) { - if (event.courseId == courseId) { - updateVideos() + when (event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + updateVideos() + } + } + + is CourseDataReady -> { + getVideos() } } } @@ -106,8 +113,6 @@ class CourseVideoViewModel( } } - getVideos() - _videoSettings.value = preferencesManager.videoSettings } diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index 94bcfd73e..ffbf7c459 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -30,10 +30,10 @@ Повернутись до модуля Цей курс ще не розпочався. Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. - Курс - Відео - Обговорення - Матеріали + Курс + Відео + Обговорення + Матеріали Ви можете завантажувати контент тільки через Wi-Fi Ця інтерактивна компонента ще не доступна Досліджуйте інші частини цього курсу або перегляньте це на веб-сайті. diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 8cbba765d..c6b370267 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -30,10 +30,6 @@ Next section This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. - Home - Videos - Discussions - More You can download content only from Wi-fi This interactive component isn\'t available on mobile. Explore other parts of this course or view this on web. @@ -43,7 +39,6 @@ Resume To proceed with \"%s\" press \"Next section\". Some content in this part of the course is locked for upgraded users only. - Dates You cannot change the download video quality when all videos are downloading Dates Shifted 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 69d330d8d..40a2d41c0 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 @@ -8,8 +8,8 @@ import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -39,6 +39,7 @@ 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.notifier.CalendarSyncEvent.CreateCalendarSyncEvent +import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor @@ -145,10 +146,12 @@ class CourseDatesViewModelTest { every { interactor.getCourseStructureFromCache() } returns courseStructure every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns emptyFlow() + every { notifier.notifier } returns flowOf(CourseDataReady(courseStructure)) every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle + every { calendarManager.isCalendarExists(any()) } returns true coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit + coEvery { notifier.send(any()) } returns Unit } @After @@ -159,8 +162,6 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", "", notifier, interactor, @@ -178,7 +179,7 @@ class CourseDatesViewModelTest { } advanceUntilIdle() - coVerify(exactly = 1) { interactor.getCourseDates(any()) } + coVerify(exactly = 1) { interactor.getCourseDates(any()) } Assert.assertEquals(noInternet, message.await()?.message) assert(viewModel.uiState.value is DatesUIState.Loading) @@ -187,8 +188,6 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates unknown exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", "", notifier, interactor, @@ -215,8 +214,6 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with internet`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", "", notifier, interactor, @@ -243,8 +240,6 @@ class CourseDatesViewModelTest { @Test fun `getCourseDates success with EmptyList`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", "", notifier, interactor, diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index 4658fd6de..43d057a6c 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -45,6 +46,7 @@ import org.openedx.core.module.db.FileType import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.VideoNotifier @@ -169,6 +171,7 @@ class CourseVideoViewModelTest { every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" + every { courseNotifier.notifier } returns flowOf(CourseDataReady(courseStructure)) } @After @@ -242,7 +245,10 @@ class CourseVideoViewModelTest { fun `updateVideos success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } + coEvery { courseNotifier.notifier } returns flow { + emit(CourseStructureUpdated("")) + emit(CourseDataReady(courseStructure)) + } every { downloadDao.readAllData() } returns flow { repeat(5) { delay(10000) 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 c1b1c423d..80dcf22de 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/DiscoveryRouter.kt @@ -5,7 +5,10 @@ import androidx.fragment.app.FragmentManager interface DiscoveryRouter { fun navigateToCourseOutline( - fm: FragmentManager, courseId: String, courseTitle: String, enrollmentMode: String + 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/info/CourseInfoViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoViewModel.kt index 6d41ac4b1..636cb9275 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 @@ -122,7 +122,7 @@ class CourseInfoViewModel( fm = fragmentManager, courseId = courseId, courseTitle = "", - enrollmentMode = "" + enrollmentMode = "", ) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index c276668b2..2797ed1a6 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -36,6 +36,9 @@ 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 +import androidx.fragment.app.FragmentManager +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.FragmentViewType import org.openedx.core.UIMessage import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.StaticSearchBar @@ -55,10 +58,9 @@ import org.openedx.discussion.R as discussionR @Composable fun DiscussionTopicsScreen( + discussionTopicsViewModel: DiscussionTopicsViewModel = koinViewModel(), windowSize: WindowSize, - discussionTopicsViewModel: DiscussionTopicsViewModel, - onSearchClick: () -> Unit, - onItemClick: (String, String, String) -> Unit + fragmentManager: FragmentManager ) { val uiState by discussionTopicsViewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) @@ -67,8 +69,27 @@ fun DiscussionTopicsScreen( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - onSearchClick = onSearchClick, - onItemClick = onItemClick + onSearchClick = { + discussionTopicsViewModel.discussionRouter.navigateToSearchThread( + fragmentManager, + discussionTopicsViewModel.courseId + ) + }, + onItemClick = { action, data, title -> + discussionTopicsViewModel.discussionClickedEvent( + action, + data, + title + ) + discussionTopicsViewModel.discussionRouter.navigateToDiscussionThread( + fragmentManager, + action, + discussionTopicsViewModel.courseId, + data, + title, + FragmentViewType.FULL_CONTENT + ) + }, ) } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 185f48268..b40cba9a7 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -11,9 +11,12 @@ import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.CourseDataReady import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseRefresh import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter @@ -22,12 +25,13 @@ class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, val discussionRouter: DiscussionRouter, - val courseId: String, - val courseName: String ) : BaseViewModel() { + var courseId: String = "" + var courseName: String = "" + private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState @@ -37,10 +41,10 @@ class DiscussionTopicsViewModel( get() = _uiMessage.asSharedFlow() init { - getCourseTopics() + collectCourseNotifier() } - fun updateCourseTopics() { + private fun updateCourseTopics() { viewModelScope.launch { try { val response = interactor.getCourseTopics(courseId) @@ -52,7 +56,7 @@ class DiscussionTopicsViewModel( _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } } finally { - notifier.send(CourseLoading(false)) + courseNotifier.send(CourseLoading(false)) } } } @@ -93,6 +97,26 @@ class DiscussionTopicsViewModel( } } + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseDataReady -> { + courseId = event.courseStructure.id + courseName = event.courseStructure.name + updateCourseTopics() + } + + is CourseRefresh -> { + if (event.courseContainerTab == CourseContainerTab.DISCUSSIONS) { + updateCourseTopics() + } + } + } + } + } + } + companion object DiscussionTopic { const val TOPIC = "Topic" const val ALL_POSTS = "All posts" diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index e772d4cae..fcff13a30 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -22,14 +23,22 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.BlockType 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.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.CourseDataReady +import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import java.net.UnknownHostException +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class DiscussionTopicsViewModelTest { @@ -48,11 +57,87 @@ class DiscussionTopicsViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" + private val blocks = listOf( + Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, + completion = 0.0 + ), + Block( + id = "id1", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("id2"), + descendantsType = BlockType.HTML, + completion = 0.0 + ), + Block( + id = "id2", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + completion = 0.0 + ) + ) + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false + ) + @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 { courseNotifier.notifier } returns flowOf(CourseDataReady(courseStructure)) + coEvery { courseNotifier.send(any()) } returns Unit } @After @@ -62,7 +147,7 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -75,12 +160,11 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) } @Test fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -93,12 +177,11 @@ class DiscussionTopicsViewModelTest { coVerify(exactly = 1) { interactor.getCourseTopics(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) } @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() advanceUntilIdle() @@ -115,7 +198,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -132,7 +215,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -149,7 +232,7 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router, "", "") + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() val message = async { From ef48f3bff85ebb2bc63ee970cfba02bcaa4bdff3 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 30 Apr 2024 12:09:30 +0300 Subject: [PATCH 18/18] fix: Fixes according to PR feedback --- .../java/org/openedx/core/ImageProcessor.kt | 7 +- .../presentation/course/CourseContainerTab.kt | 6 +- .../container/CourseContainerFragment.kt | 484 ++++++++++-------- .../container/CourseContainerViewModel.kt | 5 + .../topics/DiscussionTopicsViewModel.kt | 26 +- 5 files changed, 283 insertions(+), 245 deletions(-) diff --git a/core/src/main/java/org/openedx/core/ImageProcessor.kt b/core/src/main/java/org/openedx/core/ImageProcessor.kt index 1d63b22a3..d3a6c4a4c 100644 --- a/core/src/main/java/org/openedx/core/ImageProcessor.kt +++ b/core/src/main/java/org/openedx/core/ImageProcessor.kt @@ -13,7 +13,12 @@ import coil.ImageLoader import coil.request.ImageRequest class ImageProcessor(private val context: Context) { - fun loadImage(imageUrl: String, @DrawableRes defaultImage: Int, onComplete: (result: Drawable) -> Unit) { + fun loadImage( + @DrawableRes + defaultImage: Int, + imageUrl: String, + onComplete: (result: Drawable) -> Unit + ) { val loader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(imageUrl) diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt index 7aef4bd9a..51d235c36 100644 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt +++ b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt @@ -11,7 +11,11 @@ import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.R import org.openedx.core.ui.TabItem -enum class CourseContainerTab(@StringRes override val labelResId: Int, override val icon: ImageVector) : TabItem { +enum class CourseContainerTab( + @StringRes + override val labelResId: Int, + override val icon: ImageVector +) : TabItem { HOME(R.string.core_course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.core_course_container_nav_videos, Icons.Rounded.PlayCircleFilled), DATES(R.string.core_course_container_nav_dates, Icons.Outlined.CalendarMonth), 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 c397f150f..c44733948 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 @@ -26,6 +26,7 @@ import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -42,6 +43,7 @@ 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.FragmentManager import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.CoroutineScope @@ -55,6 +57,7 @@ import org.openedx.core.presentation.global.viewBinding import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.RoundTabsBar +import org.openedx.core.ui.WindowSize import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -75,8 +78,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val binding by viewBinding(FragmentCourseContainerBinding::bind) - private var isNavigationEnabled = false - private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), @@ -103,7 +104,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupCollapsingLayout() + initCourseView() if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { setUpCourseCalendar() } @@ -117,9 +118,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == true) { - isNavigationEnabled = true - } else { + if (isReady == false) { viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName @@ -145,212 +144,19 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { viewModel.onRefresh(CourseContainerTab.entries[currentPage]) } - @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) - private fun setupCollapsingLayout() { + private fun initCourseView() { binding.composeCollapsingLayout.setContent { - OpenEdXTheme { - val windowSize = rememberWindowSize() - val scope = rememberCoroutineScope() - val scaffoldState = rememberScaffoldState() - Scaffold( - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding(), - scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background - ) { paddingValues -> - val refreshing by viewModel.refreshing.collectAsState(true) - val courseImage by viewModel.courseImage.collectAsState() - val uiMessage by viewModel.uiMessage.collectAsState(null) - val pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) - val tabState = rememberLazyListState() - val snackState = remember { SnackbarHostState() } - val pullRefreshState = rememberPullRefreshState( - refreshing = refreshing, - onRefresh = { onRefresh(pagerState.currentPage) } - ) - if (uiMessage is DatesShiftedSnackBar) { - val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) - LaunchedEffect(uiMessage) { - snackState.showSnackbar( - message = datesShiftedMessage, - duration = SnackbarDuration.Long - ) - } - } - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - LaunchedEffect(pagerState.currentPage) { - 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, - rowState = tabState, - pagerState = pagerState, - onPageChange = viewModel::courseContainerTabClickedEvent - ) - } else { - Spacer(modifier = Modifier.height(52.dp)) - } - }, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - }, - bodyContent = { - HorizontalPager( - state = pagerState, - userScrollEnabled = isNavigationEnabled, - beyondBoundsPageCount = CourseContainerTab.entries.size - ) { page -> - when (CourseContainerTab.entries[page]) { - CourseContainerTab.HOME -> { - CourseOutlineScreen( - windowSize = windowSize, - courseOutlineViewModel = koinViewModel( - parameters = { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_TITLE, "") - ) - } - ), - courseRouter = viewModel.courseRouter, - fragmentManager = requireActivity().supportFragmentManager, - onResetDatesClick = { - viewModel.onRefresh(CourseContainerTab.DATES) - } - ) - } - - CourseContainerTab.VIDEOS -> { - CourseVideosScreen( - windowSize = windowSize, - courseVideoViewModel = koinViewModel( - parameters = { - parametersOf( - requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_TITLE, "") - ) - } - ), - fragmentManager = requireActivity().supportFragmentManager, - courseRouter = viewModel.courseRouter, - ) - } - - CourseContainerTab.DATES -> { - CourseDatesScreen( - courseDatesViewModel = koinViewModel( - parameters = { - parametersOf( - requireArguments().getString(ARG_ENROLLMENT_MODE, "") - ) - } - ), - windowSize = windowSize, - courseRouter = viewModel.courseRouter, - fragmentManager = requireActivity().supportFragmentManager, - isFragmentResumed = isResumed, - updateCourseStructure = { - viewModel.updateData() - } - ) - } - - CourseContainerTab.DISCUSSIONS -> { - DiscussionTopicsScreen( - windowSize = windowSize, - fragmentManager = requireActivity().supportFragmentManager - ) - } - - CourseContainerTab.MORE -> { - HandoutsScreen( - windowSize = windowSize, - onHandoutsClick = { - viewModel.courseRouter.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(R.string.course_handouts), - HandoutsType.Handouts - ) - }, - onAnnouncementsClick = { - viewModel.courseRouter.navigateToHandoutsWebView( - requireActivity().supportFragmentManager, - requireArguments().getString(ARG_COURSE_ID, ""), - getString(R.string.course_announcements), - HandoutsType.Announcements - ) - }) - } - } - } - } - ) - 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() - } - ) - } - } + val isNavigationEnabled by viewModel.isNavigationEnabled.collectAsState() + CourseDashboard( + viewModel = viewModel, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = requireActivity().supportFragmentManager, + bundle = requireArguments(), + onRefresh = { page -> + onRefresh(page) } - } + ) } } @@ -444,17 +250,10 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } - @OptIn(ExperimentalFoundationApi::class) - private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { - scope.launch { - pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) - } - } - companion object { - private const val ARG_COURSE_ID = "courseId" - private const val ARG_TITLE = "title" - private const val ARG_ENROLLMENT_MODE = "enrollmentMode" + const val ARG_COURSE_ID = "courseId" + const val ARG_TITLE = "title" + const val ARG_ENROLLMENT_MODE = "enrollmentMode" fun newInstance( courseId: String, courseTitle: String, @@ -471,3 +270,248 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) +@Composable +fun CourseDashboard( + viewModel: CourseContainerViewModel, + onRefresh: (page: Int) -> Unit, + isNavigationEnabled: Boolean, + isResumed: Boolean, + fragmentManager: FragmentManager, + bundle: Bundle +) { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val scope = rememberCoroutineScope() + val scaffoldState = rememberScaffoldState() + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + val refreshing by viewModel.refreshing.collectAsState(true) + val courseImage by viewModel.courseImage.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) + val tabState = rememberLazyListState() + val snackState = remember { SnackbarHostState() } + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshing, + onRefresh = { onRefresh(pagerState.currentPage) } + ) + if (uiMessage is DatesShiftedSnackBar) { + val datesShiftedMessage = stringResource(id = R.string.course_dates_shifted_message) + LaunchedEffect(uiMessage) { + snackState.showSnackbar( + message = datesShiftedMessage, + duration = SnackbarDuration.Long + ) + } + } + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + LaunchedEffect(pagerState.currentPage) { + 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, + rowState = tabState, + pagerState = pagerState, + onPageChange = viewModel::courseContainerTabClickedEvent + ) + } else { + Spacer(modifier = Modifier.height(52.dp)) + } + }, + onBackClick = { + fragmentManager.popBackStack() + }, + bodyContent = { + DashboardPager( + windowSize = windowSize, + viewModel = viewModel, + pagerState = pagerState, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = fragmentManager, + bundle = bundle + ) + } + ) + PullRefreshIndicator( + refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + if (!isInternetConnectionShown && !viewModel.hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onRefresh(pagerState.currentPage) + } + ) + } + + SnackbarHost( + modifier = Modifier.align(Alignment.BottomStart), + hostState = snackState + ) { snackbarData: SnackbarData -> + DatesShiftedSnackBar( + showAction = CourseContainerTab.entries[pagerState.currentPage] != CourseContainerTab.DATES, + onViewDates = { + scrollToDates(scope, pagerState) + }, + onClose = { + snackbarData.dismiss() + } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun DashboardPager( + windowSize: WindowSize, + viewModel: CourseContainerViewModel, + pagerState: PagerState, + isNavigationEnabled: Boolean, + isResumed: Boolean, + fragmentManager: FragmentManager, + bundle: Bundle, +) { + HorizontalPager( + state = pagerState, + userScrollEnabled = isNavigationEnabled, + beyondBoundsPageCount = CourseContainerTab.entries.size + ) { page -> + when (CourseContainerTab.entries[page]) { + CourseContainerTab.HOME -> { + CourseOutlineScreen( + windowSize = windowSize, + courseOutlineViewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, "") + ) + } + ), + courseRouter = viewModel.courseRouter, + fragmentManager = fragmentManager, + onResetDatesClick = { + viewModel.onRefresh(CourseContainerTab.DATES) + } + ) + } + + CourseContainerTab.VIDEOS -> { + CourseVideosScreen( + windowSize = windowSize, + courseVideoViewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + bundle.getString(CourseContainerFragment.ARG_TITLE, "") + ) + } + ), + fragmentManager = fragmentManager, + courseRouter = viewModel.courseRouter, + ) + } + + CourseContainerTab.DATES -> { + CourseDatesScreen( + courseDatesViewModel = koinViewModel( + parameters = { + parametersOf( + bundle.getString(CourseContainerFragment.ARG_ENROLLMENT_MODE, "") + ) + } + ), + windowSize = windowSize, + courseRouter = viewModel.courseRouter, + fragmentManager = fragmentManager, + isFragmentResumed = isResumed, + updateCourseStructure = { + viewModel.updateData() + } + ) + } + + CourseContainerTab.DISCUSSIONS -> { + DiscussionTopicsScreen( + windowSize = windowSize, + fragmentManager = fragmentManager + ) + } + + CourseContainerTab.MORE -> { + val announcementsString = stringResource(id = R.string.course_announcements) + val handoutsString = stringResource(id = R.string.course_handouts) + HandoutsScreen( + windowSize = windowSize, + onHandoutsClick = { + viewModel.courseRouter.navigateToHandoutsWebView( + fragmentManager, + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + handoutsString, + HandoutsType.Handouts + ) + }, + onAnnouncementsClick = { + viewModel.courseRouter.navigateToHandoutsWebView( + fragmentManager, + bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), + announcementsString, + HandoutsType.Announcements + ) + }) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun scrollToDates(scope: CoroutineScope, pagerState: PagerState) { + scope.launch { + pagerState.animateScrollToPage(CourseContainerTab.entries.indexOf(CourseContainerTab.DATES)) + } +} 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 05ae4e80d..c61d7e165 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 @@ -88,6 +88,10 @@ class CourseContainerViewModel( val refreshing: StateFlow = _refreshing.asStateFlow() + private val _isNavigationEnabled = MutableStateFlow(false) + val isNavigationEnabled: StateFlow = + _isNavigationEnabled.asStateFlow() + private val _uiMessage = MutableSharedFlow() val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() @@ -178,6 +182,7 @@ class CourseContainerViewModel( _dataReady.value = courseStructure.start?.let { start -> val isReady = start < Date() if (isReady) { + _isNavigationEnabled.value = true courseNotifier.send(CourseDataReady(courseStructure)) } isReady diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index b40cba9a7..5bdd90d70 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -44,7 +44,7 @@ class DiscussionTopicsViewModel( collectCourseNotifier() } - private fun updateCourseTopics() { + private fun getCourseTopic() { viewModelScope.launch { try { val response = interactor.getCourseTopics(courseId) @@ -61,26 +61,6 @@ class DiscussionTopicsViewModel( } } - private fun getCourseTopics() { - _uiState.value = DiscussionTopicsUIState.Loading - getCourseTopicsInternal() - } - - private fun getCourseTopicsInternal() { - viewModelScope.launch { - try { - val response = interactor.getCourseTopics(courseId) - _uiState.value = DiscussionTopicsUIState.Topics(response) - } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) - } else { - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) - } - } - } - } - fun discussionClickedEvent(action: String, data: String, title: String) { when (action) { ALL_POSTS -> { @@ -104,12 +84,12 @@ class DiscussionTopicsViewModel( is CourseDataReady -> { courseId = event.courseStructure.id courseName = event.courseStructure.name - updateCourseTopics() + getCourseTopic() } is CourseRefresh -> { if (event.courseContainerTab == CourseContainerTab.DISCUSSIONS) { - updateCourseTopics() + getCourseTopic() } } }