From 29e1c8e2c252ff1dbd1a56576a640118ca3ee561 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:55:12 +0300 Subject: [PATCH] feat: [FC-0047] Full-Bleed Header + Top Navigation (#278) * feat: New header and navigation on CourseContainerFragment * feat: Changed logic of swipe to refresh, added blur for android <= 12, minor code refactoring * feat: removed COURSE_TOP_BAR_ENABLED and COURSE_BANNER_ENABLED feature flags * feat: special header style for android < 12 * fix: Fixed header image blur for android < 12. Added courseContainerTabClickedEvent * feat: auto scrolling header to collapsed and expanded states * fix: Fixed junit tests * fix: Fixed CourseContainerViewModelTest * fix: auto scroll fling fix * feat: CourseContainerFragment refactoring * fix: Removed expanded header content top padding * fix: Collapsing header and navigation tabs UI fixes * fix: Fixes according to PR feedback * refactor: Course home tabs layout * fix: Fixes according to PR feedback * fix: Fixes according to PR feedback * refactor: Refactored view models of course container screens * fix: Fixes according to PR feedback --- Documentation/ConfigurationManagement.md | 2 - .../main/java/org/openedx/app/di/AppModule.kt | 3 + .../java/org/openedx/app/di/ScreenModule.kt | 18 +- core/build.gradle | 1 + .../java/org/openedx/core/ImageProcessor.kt | 57 ++ .../java/org/openedx/core/config/Config.kt | 10 - .../module/download/BaseDownloadViewModel.kt | 4 +- .../presentation/course/CourseContainerTab.kt | 24 + .../core/system/notifier/CourseDataReady.kt | 5 + .../system/notifier/CourseDatesShifted.kt | 3 + .../core/system/notifier/CourseLoading.kt | 3 + .../core/system/notifier/CourseNotifier.kt | 4 + .../core/system/notifier/CourseRefresh.kt | 5 + .../system/notifier/CourseStructureUpdated.kt | 3 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 106 +++ .../main/java/org/openedx/core/ui/TabItem.kt | 10 + .../org/openedx/core/ui/theme/AppColors.kt | 6 + .../java/org/openedx/core/ui/theme/Theme.kt | 12 + core/src/main/res/values-uk/strings.xml | 5 + core/src/main/res/values/strings.xml | 6 + .../org/openedx/core/ui/theme/Colors.kt | 10 + .../course/presentation/CourseAnalytics.kt | 2 +- .../container/CollapsingLayout.kt | 793 ++++++++++++++++++ .../container/CourseContainerAdapter.kt | 31 - .../container/CourseContainerFragment.kt | 437 +++++++--- .../container/CourseContainerViewModel.kt | 168 +++- .../presentation/container/HeaderContent.kt | 94 +++ ...eDatesFragment.kt => CourseDatesScreen.kt} | 311 ++----- .../dates/CourseDatesViewModel.kt | 90 +- ...{HandoutsFragment.kt => HandoutsScreen.kt} | 84 +- ...lineFragment.kt => CourseOutlineScreen.kt} | 416 +++------ .../outline/CourseOutlineUIState.kt | 2 +- .../outline/CourseOutlineViewModel.kt | 103 +-- .../course/presentation/ui/CourseUI.kt | 4 +- .../course/presentation/ui/CourseVideosUI.kt | 208 +++-- .../container/CourseUnitContainerAdapter.kt | 4 +- .../videos/CourseVideoViewModel.kt | 71 +- .../videos/CourseVideosFragment.kt | 163 ---- .../res/layout/fragment_course_container.xml | 35 +- .../res/menu/bottom_course_container_menu.xml | 34 - course/src/main/res/values-uk/strings.xml | 8 +- course/src/main/res/values/strings.xml | 5 - .../container/CourseContainerViewModelTest.kt | 49 +- .../dates/CourseDatesViewModelTest.kt | 83 +- .../outline/CourseOutlineViewModelTest.kt | 117 ++- .../section/CourseSectionViewModelTest.kt | 33 +- .../videos/CourseVideoViewModelTest.kt | 68 +- default_config/dev/config.yaml | 2 - default_config/prod/config.yaml | 2 - default_config/stage/config.yaml | 2 - .../discovery/presentation/DiscoveryRouter.kt | 5 +- .../presentation/info/CourseInfoViewModel.kt | 2 +- .../threads/DiscussionThreadsViewModel.kt | 16 +- ...sFragment.kt => DiscussionTopicsScreen.kt} | 195 +---- .../topics/DiscussionTopicsViewModel.kt | 81 +- .../threads/DiscussionThreadsViewModelTest.kt | 64 +- .../topics/DiscussionTopicsViewModelTest.kt | 185 +++- 57 files changed, 2597 insertions(+), 1667 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/ImageProcessor.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt 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/CourseDatesShifted.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseLoading.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt 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/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 rename course/src/main/java/org/openedx/course/presentation/dates/{CourseDatesFragment.kt => CourseDatesScreen.kt} (72%) 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} (59%) 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 rename discussion/src/main/java/org/openedx/discussion/presentation/topics/{DiscussionTopicsFragment.kt => DiscussionTopicsScreen.kt} (65%) 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/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index dc0a70335..16a30c0c6 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 @@ -81,6 +82,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 02787e391..4efd1a19e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -193,11 +193,14 @@ val screenModule = module { get(), get(), get(), + get(), + get() ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, courseTitle: String) -> CourseOutlineViewModel( courseId, + courseTitle, get(), get(), get(), @@ -234,9 +237,10 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, courseTitle: String) -> CourseVideoViewModel( courseId, + courseTitle, get(), get(), get(), @@ -275,11 +279,8 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean, enrollmentMode: String) -> + viewModel { (enrollmentMode: String) -> CourseDatesViewModel( - courseId, - courseName, - isSelfPaced, enrollmentMode, get(), get(), @@ -288,7 +289,6 @@ val screenModule = module { get(), get(), get(), - get(), ) } viewModel { (courseId: String, handoutsType: String) -> @@ -305,13 +305,13 @@ val screenModule = module { single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { (courseId: String) -> + viewModel { DiscussionTopicsViewModel( get(), get(), get(), get(), - courseId + 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/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..d3a6c4a4c --- /dev/null +++ b/core/src/main/java/org/openedx/core/ImageProcessor.kt @@ -0,0 +1,57 @@ +@file:Suppress("DEPRECATION") + +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 androidx.annotation.DrawableRes +import coil.ImageLoader +import coil.request.ImageRequest + +class ImageProcessor(private val context: Context) { + fun loadImage( + @DrawableRes + defaultImage: Int, + imageUrl: String, + onComplete: (result: Drawable) -> Unit + ) { + val loader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .target { result -> + onComplete(result) + } + .error(defaultImage) + .placeholder(defaultImage) + .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 + } +} 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/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index edf4910f5..40cc94e4d 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/presentation/course/CourseContainerTab.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt new file mode 100644 index 000000000..51d235c36 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt @@ -0,0 +1,24 @@ +package org.openedx.core.presentation.course + +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.R +import org.openedx.core.ui.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), + 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/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/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 455d6e53c..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 @@ -16,4 +16,8 @@ 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) + 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/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 9706745b8..3b97742f1 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 @@ -1183,6 +1196,80 @@ fun AuthButtonsPanel( } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RoundTabsBar( + 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 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) + } + } + .padding(horizontal = 12.dp), + 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.labelResId), + color = contentColor + ) + } +} + @Preview @Composable private fun StaticSearchBarPreview() { @@ -1261,3 +1348,22 @@ private fun ConnectionErrorViewPreview() { ) } } + +val mockTab = object : TabItem { + override val labelResId: Int = R.string.app_name + override val icon: ImageVector = Icons.Default.AccountCircle +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview +@Composable +private fun RoundTabsBarPreview() { + OpenEdXTheme { + RoundTabsBar( + 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..65a88861e --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/TabItem.kt @@ -0,0 +1,10 @@ +package org.openedx.core.ui + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector + +interface TabItem { + @get:StringRes + val labelResId: Int + val icon: ImageVector +} 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 b79aa876e..4b7a0ba10 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,6 +52,12 @@ data class AppColors( val componentHorizontalProgressSelected: Color, val componentHorizontalProgressDefault: Color, + val tabUnselectedBtnBackground: Color, + val tabUnselectedBtnContent: Color, + val tabSelectedBtnContent: Color, + val courseHomeHeaderShade: Color, + val courseHomeBackBtnBackground: Color, + val settingsTitleContent: 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 b7afcd24e..1ffa3c73d 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,6 +72,12 @@ private val DarkColorPalette = AppColors( componentHorizontalProgressSelected = dark_component_horizontal_progress_selected, componentHorizontalProgressDefault = dark_component_horizontal_progress_default, + 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, + settingsTitleContent = dark_settings_title_content ) @@ -137,6 +143,12 @@ private val LightColorPalette = AppColors( componentHorizontalProgressSelected = light_component_horizontal_progress_selected, componentHorizontalProgressDefault = light_component_horizontal_progress_default, + 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, + settingsTitleContent = light_settings_title_content ) 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 820782a9e..ed4b1d99d 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -131,4 +131,10 @@ Video streaming quality Video download quality Manage Account + + Home + Videos + Discussions + More + Dates 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 c82d4917e..1cc4c3495 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_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 val light_settings_title_content = Color.White @@ -101,4 +106,9 @@ 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_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 val dark_settings_title_content = Color.White 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 8544e4130..8151226c0 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 new file mode 100644 index 000000000..b5d73adaf --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -0,0 +1,793 @@ +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.AnimationVector1D +import androidx.compose.foundation.ExperimentalFoundationApi +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 +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.lazy.rememberLazyListState +import androidx.compose.foundation.pager.rememberPagerState +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.MutableState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.Brush +import androidx.compose.ui.graphics.Color +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 +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.presentation.course.CourseContainerTab +import org.openedx.core.ui.RoundTabsBar +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, +) { + val localDensity = LocalDensity.current + val expandedTopHeight = remember { + mutableFloatStateOf(0f) + } + val collapsedTopHeight = remember { + mutableFloatStateOf(0f) + } + val navigationHeight = remember { + mutableFloatStateOf(0f) + } + val offset = remember { Animatable(0f) } + val backgroundImageHeight = remember { + mutableFloatStateOf(0f) + } + val windowSize = rememberWindowSize() + val coroutineScope = rememberCoroutineScope() + val configuration = LocalConfiguration.current + 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.floatValue - blurImagePaddingPx).roundToInt() + val imageStartY = (backgroundImageHeight.floatValue - blurImagePaddingPx) * 0.5f + val imageOffsetY = -(offset.value + imageStartY) + val toolbarBackgroundOffset = if (toolbarOffset >= 0) { + toolbarOffset + } else { + 0 + } + val blurImageAlignment = if (toolbarOffset >= 0f) { + imageOffsetY + } else { + imageStartY + } + val backBtnStartPadding = if (!windowSize.isTablet) { + 0.dp + } else { + 60.dp + } + + fun calculateOffset(delta: Float): Offset { + val oldOffset = offset.value + val maxValue = 0f + 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) + } + 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.value == -expandedTopHeight.floatValue -> Offset.Zero + else -> calculateOffset(available.y) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset = + when { + available.y <= 0 -> Offset.Zero + offset.value == 0f -> Offset.Zero + else -> calculateOffset(available.y) + } + } + } + + Box( + modifier = modifier + .fillMaxSize() + .nestedScroll(nestedScrollConnection) + .pointerInput(Unit) { + var yStart = 0f + 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.floatValue - backgroundImageHeight.floatValue + collapsedTopHeight.floatValue + val expandedOffset = 0f + + launch { + // Handle Fling, offset.animateTo does not work if the value changes faster than 10ms + if (change.uptimeMillis - change.previousUptimeMillis <= 50) { + delay(50) + } + + if (scrollDown) { + if (offset.value > -backgroundImageHeight.floatValue * 0.85) { + offset.animateTo(expandedOffset) + } else { + offset.animateTo(collapsedOffset) + } + } else { + if (offset.value < -backgroundImageHeight.floatValue * 0.15) { + offset.animateTo(collapsedOffset) + } else { + offset.animateTo(expandedOffset) + } + } + } + } + ) + } + }, + ) { + if (windowSize.isTablet) { + 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() + ) + } + .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 + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .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) { (collapsedTopHeight.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, + alignment = Alignment.Center, + ) + Box( + modifier = Modifier + .background(MaterialTheme.appColors.courseHomeHeaderShade) + .fillMaxWidth() + .height(with(localDensity) { (collapsedTopHeight.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) { (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) + .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 + .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 + .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 = 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.value + navigationHeight.value + 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.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 + .fillMaxWidth() + .height(with(localDensity) { (expandedTopHeight.value + navigationHeight.value).toDp() } + blurImagePadding) + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .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 = (offset.value + backgroundImageHeight.value - blurImagePaddingPx).roundToInt() + ) + } + .alpha(factor), + content = expandedTop, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .onSizeChanged { size -> + collapsedTopHeight.value = 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, + ) + } + + 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 = { + RoundTabsBar( + items = CourseContainerTab.entries, + rowState = rememberLazyListState(), + pagerState = rememberPagerState(pageCount = { 5 }), + onPageChange = { } + ) + }, + onBackClick = {}, + bodyContent = {} + ) + } +} + +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, + 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..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 @@ -3,40 +3,81 @@ 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.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 +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.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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 +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 -import androidx.viewpager2.widget.ViewPager2 +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator -import org.koin.android.ext.android.inject +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.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 +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 +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.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.handouts.HandoutsScreen +import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.outline.CourseOutlineScreen +import org.openedx.course.presentation.ui.CourseVideosScreen +import org.openedx.course.presentation.ui.DatesShiftedSnackBar +import org.openedx.discussion.presentation.topics.DiscussionTopicsScreen class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private val binding by viewBinding(FragmentCourseContainerBinding::bind) + private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), @@ -44,9 +85,6 @@ 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 permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() @@ -66,7 +104,7 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupToolbar(viewModel.courseName) + initCourseView() if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { setUpCourseCalendar() } @@ -80,11 +118,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { private fun observe() { viewModel.dataReady.observe(viewLifecycleOwner) { isReady -> - if (isReady == true) { - setupToolbar(viewModel.courseName) - initViewPager() - } else { - router.navigateToNoAccess( + if (isReady == false) { + viewModel.courseRouter.navigateToNoAccess( requireActivity().supportFragmentManager, viewModel.courseName ) @@ -98,86 +133,31 @@ 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 + } } } - private fun setupToolbar(courseName: String) { - binding.toolbar.setContent { - CourseToolbar( - title = courseName, - onBackClick = { - requireActivity().supportFragmentManager.popBackStack() - } - ) - } + private fun onRefresh(currentPage: Int) { + viewModel.onRefresh(CourseContainerTab.entries[currentPage]) } - 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]) - } - } - - override fun onTabUnselected(p0: TabLayout.Tab?) {} - - 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) + private fun initCourseView() { + binding.composeCollapsingLayout.setContent { + val isNavigationEnabled by viewModel.isNavigationEnabled.collectAsState() + CourseDashboard( + viewModel = viewModel, + isNavigationEnabled = isNavigationEnabled, + isResumed = isResumed, + fragmentManager = requireActivity().supportFragmentManager, + bundle = requireArguments(), + onRefresh = { page -> + onRefresh(page) } - true - } - binding.bottomNavView.isVisible = true + ) } - viewModel.courseContainerTabClickedEvent(Tabs.entries[binding.viewPager.currentItem]) } private fun setUpCourseCalendar() { @@ -270,26 +250,10 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } - 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) - } - } - 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, @@ -306,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 43a3be5c1..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 @@ -1,30 +1,44 @@ package org.openedx.course.presentation.container +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Build import androidx.annotation.StringRes import androidx.lifecycle.LiveData 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 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 import org.openedx.course.R import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor @@ -33,6 +47,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 @@ -43,20 +58,20 @@ 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, private val resourceManager: ResourceManager, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, + private val imageProcessor: ImageProcessor, + val courseRouter: CourseRouter ) : BaseViewModel() { - val isCourseTopTabBarEnabled get() = config.isCourseTopTabBarEnabled() - private val _dataReady = MutableLiveData() val dataReady: LiveData get() = _dataReady @@ -65,14 +80,30 @@ 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 val _refreshing = MutableStateFlow(false) + 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() private var _isSelfPaced: Boolean = true val isSelfPaced: Boolean get() = _isSelfPaced + private var _organization: String = "" + val organization: String + get() = _organization + val calendarPermissions: Array get() = calendarManager.permissions @@ -89,21 +120,40 @@ class CourseContainerViewModel( val calendarSyncUIState: StateFlow = _calendarSyncUIState.asStateFlow() + 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 -> - if (event is CourseCompletionSet) { - updateData(false) - } + 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) + ) + } + } + + is CourseDatesShifted -> { + _uiMessage.emit(DatesShiftedSnackBar()) + } + + is CourseLoading -> { + _showProgress.value = event.isLoading + if (!event.isLoading) { + _refreshing.value = false + } } } } @@ -126,9 +176,16 @@ class CourseContainerViewModel( } val courseStructure = interactor.getCourseStructureFromCache() courseName = courseStructure.name + _organization = courseStructure.org _isSelfPaced = courseStructure.isSelfPaced + loadCourseImage(courseStructure.media?.image?.large) _dataReady.value = courseStructure.start?.let { start -> - start < Date() + val isReady = start < Date() + if (isReady) { + _isNavigationEnabled.value = true + courseNotifier.send(CourseDataReady(courseStructure)) + } + isReady } } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { @@ -139,12 +196,56 @@ class CourseContainerViewModel( resourceManager.getString(CoreR.string.core_error_unknown_error) } } - _showProgress.value = false } } - fun updateData(withSwipeRefresh: Boolean) { - _showProgress.value = true + 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 onRefresh(courseContainerTab: CourseContainerTab) { + _refreshing.value = true + when (courseContainerTab) { + CourseContainerTab.HOME -> { + updateData() + } + + CourseContainerTab.VIDEOS -> { + updateData() + } + + CourseContainerTab.DATES -> { + viewModelScope.launch { + courseNotifier.send(CourseRefresh(courseContainerTab)) + } + } + + CourseContainerTab.DISCUSSIONS -> { + viewModelScope.launch { + courseNotifier.send(CourseRefresh(courseContainerTab)) + } + } + + else -> { + _refreshing.value = false + } + } + } + + fun updateData() { viewModelScope.launch { try { interactor.preloadCourseStructure(courseId) @@ -157,21 +258,22 @@ class CourseContainerViewModel( resourceManager.getString(CoreR.string.core_error_unknown_error) } } - _showProgress.value = false - notifier.send(CourseStructureUpdated(courseId, withSwipeRefresh)) + _refreshing.value = false + courseNotifier.send(CourseStructureUpdated(courseId)) } } - fun courseContainerTabClickedEvent(tab: CourseContainerTab) { - when (tab) { - CourseContainerTab.COURSE -> courseTabClickedEvent() + fun courseContainerTabClickedEvent(index: Int) { + when (CourseContainerTab.entries[index]) { + CourseContainerTab.HOME -> courseTabClickedEvent() CourseContainerTab.VIDEOS -> videoTabClickedEvent() - CourseContainerTab.DISCUSSION -> discussionTabClickedEvent() + CourseContainerTab.DISCUSSIONS -> discussionTabClickedEvent() CourseContainerTab.DATES -> datesTabClickedEvent() - CourseContainerTab.HANDOUTS -> handoutsTabClickedEvent() + CourseContainerTab.MORE -> moreTabClickedEvent() } } + fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { val currentState = _calendarSyncUIState.value if (currentState.dialogType != dialogType) { @@ -235,7 +337,7 @@ class CourseContainerViewModel( val isCalendarSynced = calendarManager.isCalendarExists( calendarTitle = _calendarSyncUIState.value.calendarTitle ) - notifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) + courseNotifier.send(CheckCalendarSyncEvent(isSynced = isCalendarSynced)) } } @@ -312,8 +414,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/HeaderContent.kt b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt new file mode 100644 index 000000000..a2070eb66 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/container/HeaderContent.kt @@ -0,0 +1,94 @@ +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.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme +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 +) { + val windowSize = rememberWindowSize() + val horizontalPadding = if (!windowSize.isTablet) { + 24.dp + } else { + 98.dp + } + Column( + modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .padding(top = 16.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, + overflow = TextOverflow.Ellipsis, + maxLines = 3 + ) + } +} + +@Composable +internal fun CollapsedHeaderContent( + modifier: Modifier = Modifier, + courseTitle: String +) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 3.dp), + text = courseTitle, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.appTypography.titleSmall, + maxLines = 1 + ) +} + +@Preview(showBackground = true, device = Devices.PIXEL) +@Composable +private fun ExpandedHeaderContentPreview() { + OpenEdXTheme { + ExpandedHeaderContent( + modifier = Modifier.fillMaxWidth(), + org = "organization", + courseTitle = "Course title" + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL) +@Composable +private fun CollapsedHeaderContentPreview() { + OpenEdXTheme { + CollapsedHeaderContent( + modifier = Modifier.fillMaxWidth(), + courseTitle = "Course title" + ) + } +} 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 72% 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 b65532f0b..715584497 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 @@ -32,15 +29,9 @@ 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 -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 @@ -48,27 +39,20 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.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 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.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 +62,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.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 androidx.fragment.app.FragmentManager import org.openedx.core.UIMessage import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock @@ -94,11 +74,9 @@ 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 @@ -106,181 +84,110 @@ 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.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 +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, +@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 + + CourseDatesUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + isSelfPaced = courseDatesViewModel.isSelfPaced, + calendarSyncUIState = calendarSyncUIState, + 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, - 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() - } + } ?: { + courseDatesViewModel.logCourseComponentTapped(false, block) + ActionDialogFragment.newInstance( + title = context.getString(CoreR.string.core_leaving_the_app), + message = context.getString( + CoreR.string.core_leaving_the_app_message, + context.getString(CoreR.string.platform_name) + ), + url = block.link, + source = CoreAnalyticsScreen.COURSE_DATES.screenName + ).show( + fragmentManager, + ActionDialogFragment::class.simpleName + ) - 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 - } - } + } + } + }, + onPLSBannerViewed = { + if (isFragmentResumed) { + courseDatesViewModel.logPlsBannerViewed() + } + }, + onSyncDates = { + courseDatesViewModel.logPlsShiftButtonClicked() + courseDatesViewModel.resetCourseDatesBanner { + courseDatesViewModel.logPlsShiftDates(it) + if (it) { + updateCourseStructure() + } + } + }, + onCalendarSyncSwitch = { isChecked -> + courseDatesViewModel.handleCalendarSyncState(isChecked) + }, + ) } -@OptIn(ExperimentalMaterialApi::class) @Composable -internal fun CourseDatesScreen( +private fun CourseDatesUI( 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) - } Scaffold( modifier = Modifier.fillMaxSize(), @@ -305,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( @@ -330,18 +227,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 @@ -423,34 +310,10 @@ internal fun CourseDatesScreen( ) } } - } - PullRefreshIndicator( - refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - }) + DatesUIState.Loading -> {} } } - - SnackbarHost( - modifier = Modifier.align(Alignment.BottomStart), - hostState = snackState - ) { snackbarData: SnackbarData -> - DatesShiftedSnackBar(onClose = { - snackbarData.dismiss() - }) - } } } } @@ -566,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) ), @@ -726,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 ) @@ -776,16 +639,12 @@ private fun CourseDateItem( @Composable private fun CourseDatesScreenPreview() { OpenEdXTheme { - CourseDatesScreen( + CourseDatesUI( 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 = {}, @@ -799,16 +658,12 @@ private fun CourseDatesScreenPreview() { @Composable private fun CourseDatesScreenTabletPreview() { OpenEdXTheme { - CourseDatesScreen( + CourseDatesUI( 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 52fe7c5e3..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 @@ -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 @@ -20,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.connection.NetworkConnection 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.course.DatesShiftedSnackBar +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 @@ -36,27 +41,27 @@ import org.openedx.course.presentation.calendarsync.CalendarSyncUIState 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 courseNotifier: 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, private val config: Config, ) : BaseViewModel() { + var courseId = "" + 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( @@ -68,36 +73,36 @@ 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() 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(swipeToRefresh: Boolean = false) { - if (!swipeToRefresh) { - _uiState.value = DatesUIState.Loading - } - _updating.value = swipeToRefresh - loadingCourseDatesInternal() - } - private fun loadingCourseDatesInternal() { viewModelScope.launch { try { @@ -111,14 +116,13 @@ 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))) } + } finally { + courseNotifier.send(CourseLoading(false)) } - _updating.value = false } } @@ -126,16 +130,14 @@ class CourseDatesViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - getCourseDates() - _uiMessage.value = DatesShiftedSnackBar() + loadingCourseDatesInternal() + courseNotifier.send(CourseDatesShifted) 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) } @@ -172,7 +174,7 @@ class CourseDatesViewModel( ) } - fun updateAndFetchCalendarSyncState(): Boolean { + private fun updateAndFetchCalendarSyncState(): Boolean { val isCalendarSynced = calendarManager.isCalendarExists( calendarTitle = _calendarSyncUIState.value.calendarTitle ) @@ -184,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, @@ -199,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/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 59% 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 26b6384a6..7e950cba8 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 @@ -12,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 @@ -22,35 +18,22 @@ 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 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.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 -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.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -58,13 +41,8 @@ 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 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 @@ -74,219 +52,151 @@ 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 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.R import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.container.CourseContainerFragment -import org.openedx.course.presentation.container.CourseContainerTab -import org.openedx.course.presentation.outline.CourseOutlineFragment.Companion.getUnitBlockIcon import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseExpandableChapterCard -import org.openedx.course.presentation.ui.CourseImageHeader import org.openedx.course.presentation.ui.CourseSectionCard 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.core.R as CoreR -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, +@Composable +fun CourseOutlineScreen( + windowSize: WindowSize, + courseOutlineViewModel: CourseOutlineViewModel, + courseRouter: CourseRouter, + fragmentManager: FragmentManager, + onResetDatesClick: () -> Unit +) { + val uiState by courseOutlineViewModel.uiState.collectAsState() + val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + val context = LocalContext.current + + CourseOutlineUI( + windowSize = windowSize, + uiState = uiState, + isCourseNestedListEnabled = courseOutlineViewModel.isCourseNestedListEnabled, + uiMessage = uiMessage, + 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 ) - }, - 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) } + } 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(CoreR.string.app_name) + .replace(Regex("\\s"), "_"), it.id ) } - } - } - - 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 + }, + onResetDatesClick = { + courseOutlineViewModel.resetCourseDatesBanner( + onResetDates = { + onResetDatesClick() + } ) - 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 - } + }, + onCertificateClick = { + courseOutlineViewModel.viewCertificateTappedEvent() + it.takeIfNotEmpty() + ?.let { url -> AndroidUriHandler(context).openUri(url) } } - } + ) } - -@OptIn(ExperimentalMaterialApi::class) @Composable -internal fun CourseOutlineScreen( +private fun CourseOutlineUI( windowSize: WindowSize, uiState: CourseOutlineUIState, - apiHostUrl: String, isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, uiMessage: UIMessage?, - refreshing: Boolean, - hasInternetConnection: Boolean, - onReloadClick: () -> Unit, - onSwipeRefresh: () -> 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() - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier @@ -322,17 +232,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( @@ -346,37 +245,13 @@ 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(), 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 - ) - } - } if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { item { Box( @@ -490,37 +365,9 @@ internal fun CourseOutlineScreen( } } } - } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - 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() - }) + CourseOutlineUIState.Loading -> {} + } } } } @@ -537,7 +384,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 ) @@ -562,14 +409,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 ) @@ -595,7 +442,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 ) @@ -621,14 +468,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 ) @@ -637,12 +484,21 @@ private fun ResumeCourseTablet( } } +fun getUnitBlockIcon(block: Block): Int { + return when (block.type) { + 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 + } +} + @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, @@ -659,21 +515,14 @@ private fun CourseOutlineScreenPreview() { hasEnded = false ) ), - apiHostUrl = "", isCourseNestedListEnabled = true, - isCourseBannerEnabled = true, uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onSwipeRefresh = {}, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, - onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onViewDates = {}, onCertificateClick = {}, ) } @@ -684,7 +533,7 @@ private fun CourseOutlineScreenPreview() { @Composable private fun CourseOutlineScreenTabletPreview() { OpenEdXTheme { - CourseOutlineScreen( + CourseOutlineUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseOutlineUIState.CourseData( mockCourseStructure, @@ -701,21 +550,14 @@ private fun CourseOutlineScreenTabletPreview() { hasEnded = false ) ), - apiHostUrl = "", isCourseNestedListEnabled = true, - isCourseBannerEnabled = true, uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onSwipeRefresh = {}, onItemClick = {}, onExpandClick = {}, onSubSectionClick = {}, onResumeClick = {}, - onReloadClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onViewDates = {}, onCertificateClick = {}, ) } 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 eeabab539..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 @@ -1,13 +1,15 @@ package org.openedx.course.presentation.outline -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.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 -import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -26,9 +28,11 @@ 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 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 @@ -38,10 +42,11 @@ import org.openedx.course.R as courseR class CourseOutlineViewModel( val courseId: String, + private val courseTitle: String, 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, @@ -55,48 +60,38 @@ class CourseOutlineViewModel( workerController, coreAnalytics ) { - - val apiHostUrl get() = config.getApiHostURL() - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - val isCourseBannerEnabled get() = config.isCourseBannerEnabled() - - private val _uiState = MutableLiveData(CourseOutlineUIState.Loading) - val uiState: LiveData - get() = _uiState - - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _uiState = MutableStateFlow(CourseOutlineUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() - var courseTitle = "" + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() 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) + courseNotifier.notifier.collect { event -> + when(event) { + is CourseStructureUpdated -> { + if (event.courseId == courseId) { + updateCourseData() + } + } + is CourseDataReady -> { + getCourseData() } } } @@ -120,34 +115,28 @@ class CourseOutlineViewModel( } } - init { - getCourseData() - } - - fun setIsUpdating() { - _isUpdating.value = true - } - override fun saveDownloadModels(folder: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { 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) } } - fun updateCourseData(withSwipeRefresh: Boolean) { - _isUpdating.value = withSwipeRefresh + fun updateCourseData() { getCourseDataInternal() } - private fun getCourseData() { - _uiState.value = CourseOutlineUIState.Loading + fun getCourseData() { + viewModelScope.launch { + courseNotifier.send(CourseLoading(true)) + } getCourseDataInternal() } @@ -222,18 +211,14 @@ class CourseOutlineViewModel( subSectionsDownloadsCount = subSectionsDownloadsCount, datesBannerInfo = datesBannerInfo, ) + courseNotifier.send(CourseLoading(false)) } 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 } } @@ -280,16 +265,14 @@ class CourseOutlineViewModel( viewModelScope.launch { try { interactor.resetCourseDates(courseId = courseId) - updateCourseData(false) - _uiMessage.value = DatesShiftedSnackBar() + updateCourseData() + courseNotifier.send(CourseDatesShifted) 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) } @@ -354,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/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 9523d99e5..f9f028c0f 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 @@ -104,7 +104,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 @@ -1014,7 +1014,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/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 8d5bc7172..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 @@ -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 @@ -21,10 +20,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,11 +38,9 @@ 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,12 +48,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.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 @@ -66,8 +66,9 @@ 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 import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -76,39 +77,124 @@ 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 -@OptIn(ExperimentalMaterialApi::class) @Composable fun CourseVideosScreen( + windowSize: WindowSize, + courseVideoViewModel: CourseVideoViewModel, + fragmentManager: FragmentManager, + courseRouter: CourseRouter +) { + 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 + + CourseVideosUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + courseTitle = courseVideoViewModel.courseTitle, + isCourseNestedListEnabled = courseVideoViewModel.isCourseNestedListEnabled, + videoSettings = videoSettings, + 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 +private fun CourseVideosUI( windowSize: WindowSize, uiState: CourseVideosUIState, uiMessage: UIMessage?, courseTitle: String, - apiHostUrl: String, isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, - isUpdating: Boolean, - hasInternetConnection: Boolean, videoSettings: VideoSettings, - onSwipeRefresh: () -> Unit, onItemClick: (Block) -> Unit, onExpandClick: (Block) -> Unit, onSubSectionClick: (Block) -> Unit, - onReloadClick: () -> Unit, onDownloadClick: (Block) -> Unit, onDownloadAllClick: (Boolean) -> Unit, onDownloadQueueClick: () -> Unit, onVideoDownloadQualityClick: () -> Unit ) { val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = isUpdating, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( modifier = Modifier @@ -169,7 +255,7 @@ fun CourseVideosScreen( modifier = screenWidth, color = MaterialTheme.appColors.background ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { Column( Modifier .fillMaxSize() @@ -177,7 +263,9 @@ fun CourseVideosScreen( when (uiState) { is CourseVideosUIState.Empty -> { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), contentAlignment = Alignment.Center ) { Text( @@ -190,35 +278,11 @@ fun CourseVideosScreen( } } - is CourseVideosUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - is CourseVideosUIState.CourseData -> { LazyColumn( 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 - ) - } - } - if (uiState.downloadModelsSize.allCount > 0) { item { AllVideosDownloadItem( @@ -230,7 +294,6 @@ fun CourseVideosScreen( onDownloadAllClick = { isSwitched -> if (isSwitched) { isDeleteDownloadsConfirmationShowed = true - } else { onDownloadAllClick(false) } @@ -243,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 { @@ -329,27 +390,10 @@ fun CourseVideosScreen( } } } + + CourseVideosUIState.Loading -> {} } } - PullRefreshIndicator( - isUpdating, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } } } } @@ -655,7 +699,7 @@ private fun AllVideosDownloadItem( @Composable private fun CourseVideosScreenPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideosUIState.CourseData( @@ -668,22 +712,16 @@ private fun CourseVideosScreenPreview() { isAllBlocksDownloadedOrDownloading = false, remainingCount = 0, remainingSize = 0, - allCount = 0, + allCount = 1, allSize = 0 ) ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - hasInternetConnection = true, - isUpdating = false, videoSettings = VideoSettings.default, - onSwipeRefresh = {}, - onReloadClick = {}, onDownloadClick = {}, onDownloadAllClick = {}, onDownloadQueueClick = {}, @@ -697,23 +735,17 @@ private fun CourseVideosScreenPreview() { @Composable private fun CourseVideosScreenEmptyPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideosUIState.Empty( "This course does not include any videos." ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - hasInternetConnection = true, - isUpdating = false, videoSettings = VideoSettings.default, onDownloadClick = {}, onDownloadAllClick = {}, @@ -728,7 +760,7 @@ private fun CourseVideosScreenEmptyPreview() { @Composable private fun CourseVideosScreenTabletPreview() { OpenEdXTheme { - CourseVideosScreen( + CourseVideosUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiMessage = null, uiState = CourseVideosUIState.CourseData( @@ -746,16 +778,10 @@ private fun CourseVideosScreenTabletPreview() { ) ), courseTitle = "", - apiHostUrl = "", isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, onItemClick = { }, onExpandClick = { }, onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - isUpdating = false, - hasInternetConnection = true, videoSettings = VideoSettings.default, onDownloadClick = {}, onDownloadAllClick = {}, 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..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.DiscussionTopicsFragment +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( - DiscussionTopicsFragment.TOPIC, + DiscussionTopicsViewModel.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..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 @@ -1,13 +1,14 @@ 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 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 @@ -19,6 +20,8 @@ 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 import org.openedx.core.system.notifier.VideoNotifier @@ -29,6 +32,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, @@ -48,32 +52,19 @@ class CourseVideoViewModel( coreAnalytics ) { - val apiHostUrl get() = config.getApiHostURL() - val isCourseNestedListEnabled get() = config.isCourseNestedListEnabled() - val isCourseBannerEnabled get() = config.isCourseBannerEnabled() - - private val _uiState = MutableLiveData() - val uiState: LiveData - get() = _uiState - - var courseTitle = "" - - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _uiState = MutableStateFlow(CourseVideosUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() - 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() - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() @@ -81,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() } } } @@ -116,8 +113,6 @@ class CourseVideoViewModel( } } - getVideos() - _videoSettings.value = preferencesManager.videoSettings } @@ -126,8 +121,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,21 +132,17 @@ 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 } super.saveAllDownloadModels(folder) } - fun setIsUpdating() { - _isUpdating.value = true - } - private fun updateVideos() { getVideos() - _isUpdating.value = false } fun getVideos() { @@ -177,6 +169,7 @@ class CourseVideoViewModel( courseSectionsState, subSectionsDownloadsCount, getDownloadModelsSize() ) } + courseNotifier.send(CourseLoading(false)) } } @@ -198,9 +191,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..3eb159dc3 100644 --- a/course/src/main/res/layout/fragment_course_container.xml +++ b/course/src/main/res/layout/fragment_course_container.xml @@ -6,45 +6,12 @@ android:background="@color/background"> - - - - - - - - - - - - - - - - - - - diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index e7799bdbf..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 002fc06a1..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. - Course - 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/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 0dc214f81..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 @@ -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 @@ -37,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 @@ -58,6 +61,9 @@ class CourseContainerViewModelTest { private val analytics = mockk() private val corePreferences = mockk() 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" @@ -112,6 +118,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(), any()) } returns Unit + every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap } @After @@ -134,6 +143,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws UnknownHostException() @@ -146,7 +157,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value == null) } @@ -165,6 +176,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } throws Exception() @@ -177,7 +190,7 @@ class CourseContainerViewModelTest { val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value == null) } @@ -196,6 +209,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns true coEvery { interactor.preloadCourseStructure(any()) } returns Unit @@ -208,7 +223,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(CourseAnalyticsEvent.DASHBOARD.eventName, any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value != null) } @@ -227,6 +242,8 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + courseRouter ) every { networkConnection.isOnline() } returns false coEvery { interactor.preloadCourseStructureFromCache(any()) } returns Unit @@ -240,7 +257,7 @@ class CourseContainerViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) assert(viewModel.dataReady.value != null) } @@ -259,17 +276,19 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + 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()) } val message = viewModel.errorMessage.value assertEquals(noInternet, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) } @Test @@ -287,17 +306,19 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + 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()) } val message = viewModel.errorMessage.value assertEquals(somethingWrong, message) - assert(viewModel.showProgress.value == false) + assert(!viewModel.refreshing.value) } @Test @@ -315,15 +336,17 @@ class CourseContainerViewModelTest { corePreferences, coursePreferences, analytics, + imageProcessor, + 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()) } assert(viewModel.errorMessage.value == null) - assert(viewModel.showProgress.value == false) + 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 82c3728e4..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 @@ -7,12 +7,16 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow +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 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 @@ -34,8 +38,9 @@ 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.CourseDataReady +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 @@ -54,7 +59,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() @@ -142,9 +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 @@ -153,119 +160,109 @@ class CourseDatesViewModelTest { } @Test - fun `getCourseDates no internet connection exception`() = runTest { + fun `getCourseDates no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { val viewModel = CourseDatesViewModel( - "", - "", - true, "", notifier, interactor, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - 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 + coVerify(exactly = 1) { interactor.getCourseDates(any()) } - Assert.assertEquals(noInternet, message?.message) - assert(viewModel.updating.value == false) + Assert.assertEquals(noInternet, message.await()?.message) 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, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - 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(viewModel.updating.value == false) + Assert.assertEquals(somethingWrong, message.await()?.message) 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, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - 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(viewModel.updating.value == false) + assert(message.await()?.message.isNullOrEmpty()) 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, calendarManager, - networkConnection, resourceManager, corePreferences, analytics, config ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( 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(viewModel.updating.value == false) + assert(message.await()?.message.isNullOrEmpty()) 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..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 @@ -12,13 +12,15 @@ import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay +import kotlinx.coroutines.async +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 +217,14 @@ 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 + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } 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) - assert(viewModel.isUpdating.value == false) + assertEquals(noInternet, message.await()?.message) 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 + every { downloadDao.readAllData() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatus(any()) } throws Exception() val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -264,19 +272,21 @@ 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) - assert(viewModel.isUpdating.value == false) + assertEquals(somethingWrong, message.await()?.message) 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 +302,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -305,18 +316,23 @@ 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(viewModel.isUpdating.value == false) + assert(message.await() == null) 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 +348,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -345,18 +362,23 @@ 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(viewModel.isUpdating.value == false) + assert(message.await() == null) 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 +394,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -385,20 +408,27 @@ class CourseOutlineViewModelTest { workerController ) - viewModel.updateCourseData(false) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + viewModel.getCourseData() + viewModel.updateCourseData() advanceUntilIdle() coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } coVerify(exactly = 2) { interactor.getCourseStatus(any()) } - assert(viewModel.uiMessage.value == null) - assert(viewModel.isUpdating.value == false) + assert(message.await() == null) 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( + "", "", config, interactor, @@ -411,14 +441,8 @@ 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 { downloadDao.readAllData() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } - } every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") @@ -427,15 +451,15 @@ class CourseOutlineViewModelTest { lifecycleRegistry.addObserver(viewModel) lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) - viewModel.setIsUpdating() + viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureFromCache() } + coVerify(exactly = 1) { interactor.getCourseStructureFromCache() } coVerify(exactly = 1) { 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 +476,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -464,7 +489,11 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() verify(exactly = 1) { @@ -474,23 +503,23 @@ 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 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 every { coreAnalytics.logEvent(any(), any()) } returns Unit val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -503,15 +532,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 +554,7 @@ class CourseOutlineViewModelTest { every { config.isCourseNestedListEnabled() } returns false val viewModel = CourseOutlineViewModel( + "", "", config, interactor, @@ -533,12 +567,15 @@ class CourseOutlineViewModelTest { downloadDao, workerController ) - + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } viewModel.saveDownloadModels("", "") advanceUntilIdle() - assert(viewModel.uiMessage.value != null) - assert(!viewModel.hasInternetConnection) + assert(message.await()?.message.isNullOrEmpty()) } } 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 a825345cf..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 @@ -11,19 +11,25 @@ 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.flow.flowOf 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 @@ -40,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 @@ -161,9 +168,10 @@ 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" + every { courseNotifier.notifier } returns flowOf(CourseDataReady(courseStructure)) } @After @@ -178,6 +186,7 @@ class CourseVideoViewModelTest { every { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -208,6 +217,7 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -235,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("", false)) } + coEvery { courseNotifier.notifier } returns flow { + emit(CourseStructureUpdated("")) + emit(CourseDataReady(courseStructure)) + } every { downloadDao.readAllData() } returns flow { repeat(5) { delay(10000) @@ -244,6 +257,7 @@ class CourseVideoViewModelTest { } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( + "", "", config, interactor, @@ -268,40 +282,23 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos() } assert(viewModel.uiState.value is CourseVideosUIState.CourseData) - assert(viewModel.isUpdating.value == false) } @Test 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)) } - viewModel.setIsUpdating() advanceUntilIdle() - - assert(viewModel.isUpdating.value == true) } @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 +318,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 +357,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,13 +394,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(!viewModel.hasInternetConnection) + assert(message.await()?.message.isNullOrEmpty()) } 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 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 2bd62980b..e1c4baa74 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/threads/DiscussionThreadsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModel.kt index 08709538b..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 @@ -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.DiscussionTopicsViewModel 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 -> { + DiscussionTopicsViewModel.ALL_POSTS -> { getAllThreads(orderBy) } - DiscussionTopicsFragment.FOLLOWING_POSTS -> { + DiscussionTopicsViewModel.FOLLOWING_POSTS -> { getFollowingThreads(orderBy) } - DiscussionTopicsFragment.TOPIC -> { + DiscussionTopicsViewModel.TOPIC -> { getThreads( topicId, orderBy @@ -129,15 +129,15 @@ class DiscussionThreadsViewModel( filter } when (threadType) { - DiscussionTopicsFragment.ALL_POSTS -> { + DiscussionTopicsViewModel.ALL_POSTS -> { getAllThreads(lastOrderBy) } - DiscussionTopicsFragment.FOLLOWING_POSTS -> { + DiscussionTopicsViewModel.FOLLOWING_POSTS -> { getFollowingThreads(lastOrderBy) } - DiscussionTopicsFragment.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/DiscussionTopicsScreen.kt similarity index 65% 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 index e6b1ddbee..2797ed1a6 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.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.Arrangement import androidx.compose.foundation.layout.Box @@ -18,49 +15,36 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.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.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 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.OfflineModeDialog import org.openedx.core.ui.StaticSearchBar 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.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -68,113 +52,57 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue 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.updateCourseTopics() - } - - 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, - hasInternetConnection = viewModel.hasInternetConnection, - onReloadClick = { - viewModel.updateCourseTopics() - }, - onSwipeRefresh = { - viewModel.updateCourseTopics(withSwipeRefresh = true) - }, - 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" +@Composable +fun DiscussionTopicsScreen( + discussionTopicsViewModel: DiscussionTopicsViewModel = koinViewModel(), + windowSize: WindowSize, + fragmentManager: FragmentManager +) { + val uiState by discussionTopicsViewModel.uiState.observeAsState(DiscussionTopicsUIState.Loading) + val uiMessage by discussionTopicsViewModel.uiMessage.collectAsState(null) - 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, + DiscussionTopicsUI( + windowSize = windowSize, + uiState = uiState, + uiMessage = uiMessage, + onSearchClick = { + discussionTopicsViewModel.discussionRouter.navigateToSearchThread( + fragmentManager, + discussionTopicsViewModel.courseId ) - return fragment - } - } + }, + onItemClick = { action, data, title -> + discussionTopicsViewModel.discussionClickedEvent( + action, + data, + title + ) + discussionTopicsViewModel.discussionRouter.navigateToDiscussionThread( + fragmentManager, + action, + discussionTopicsViewModel.courseId, + data, + title, + FragmentViewType.FULL_CONTENT + ) + }, + ) } -@OptIn(ExperimentalMaterialApi::class) @Composable -private fun DiscussionTopicsScreen( +private fun DiscussionTopicsUI( windowSize: WindowSize, uiState: DiscussionTopicsUIState, uiMessage: UIMessage?, - refreshing: Boolean, - hasInternetConnection: Boolean, - onReloadClick: () -> Unit, onSearchClick: () -> Unit, - onSwipeRefresh: () -> Unit, onItemClick: (String, String, String) -> Unit ) { val scaffoldState = rememberScaffoldState() val context = LocalContext.current - val pullRefreshState = - rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } Scaffold( scaffoldState = scaffoldState, @@ -243,7 +171,7 @@ private fun DiscussionTopicsScreen( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape ) { - Box(Modifier.pullRefresh(pullRefreshState)) { + Box { Column( modifier = Modifier .fillMaxSize() @@ -278,7 +206,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopicsFragment.ALL_POSTS, + DiscussionTopicsViewModel.ALL_POSTS, "", context.getString(discussionR.string.discussion_all_posts) ) @@ -291,7 +219,7 @@ private fun DiscussionTopicsScreen( .height(categoriesHeight), onClick = { onItemClick( - DiscussionTopicsFragment.FOLLOWING_POSTS, + DiscussionTopicsViewModel.FOLLOWING_POSTS, "", context.getString(discussionR.string.discussion_posts_following) ) @@ -311,7 +239,7 @@ private fun DiscussionTopicsScreen( } else { TopicItem(topic = topic, onClick = { id, title -> onItemClick( - DiscussionTopicsFragment.TOPIC, + DiscussionTopicsViewModel.TOPIC, id, title ) @@ -324,35 +252,9 @@ private fun DiscussionTopicsScreen( } } - DiscussionTopicsUIState.Loading -> { - Box( - Modifier - .fillMaxSize(), contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } + DiscussionTopicsUIState.Loading -> {} } } - PullRefreshIndicator( - refreshing, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } } } } @@ -360,7 +262,6 @@ private fun DiscussionTopicsScreen( } } - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) @@ -368,15 +269,11 @@ private fun DiscussionTopicsScreen( @Composable private fun DiscussionTopicsScreenPreview() { OpenEdXTheme { - DiscussionTopicsScreen( + DiscussionTopicsUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onReloadClick = {}, onItemClick = { _, _, _ -> }, - onSwipeRefresh = {}, onSearchClick = {} ) } @@ -387,15 +284,11 @@ private fun DiscussionTopicsScreenPreview() { @Composable private fun DiscussionTopicsScreenTabletPreview() { OpenEdXTheme { - DiscussionTopicsScreen( + DiscussionTopicsUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), uiMessage = null, - refreshing = false, - hasInternetConnection = true, - onReloadClick = {}, 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 72e26405d..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 @@ -3,65 +3,60 @@ 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 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.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.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.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 org.openedx.discussion.presentation.DiscussionRouter class DiscussionTopicsViewModel( private val interactor: DiscussionInteractor, private val resourceManager: ResourceManager, private val analytics: DiscussionAnalytics, - private val networkConnection: NetworkConnection, - val courseId: String + private val courseNotifier: CourseNotifier, + val discussionRouter: DiscussionRouter, ) : BaseViewModel() { + var courseId: String = "" + var courseName: String = "" + private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - - private val _isUpdating = MutableLiveData() - val isUpdating: LiveData - get() = _isUpdating + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - - var courseName = "" + init { + collectCourseNotifier() + } - fun updateCourseTopics(withSwipeRefresh: Boolean = false) { + private fun getCourseTopic() { viewModelScope.launch { try { - if (withSwipeRefresh) { - _isUpdating.value = true - } else { - _uiState.value = DiscussionTopicsUIState.Loading - } - val response = interactor.getCourseTopics(courseId) _uiState.value = DiscussionTopicsUIState.Topics(response) } catch (e: Exception) { - val errorMessage = if (e.isInternetError()) { - resourceManager.getString(R.string.core_error_no_connection) + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) } else { - resourceManager.getString(R.string.core_error_unknown_error) + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) } - _uiMessage.value = UIMessage.SnackBarMessage(errorMessage) } finally { - _isUpdating.value = false + courseNotifier.send(CourseLoading(false)) } } } @@ -81,4 +76,30 @@ class DiscussionTopicsViewModel( } } } + + private fun collectCourseNotifier() { + viewModelScope.launch { + courseNotifier.notifier.collect { event -> + when (event) { + is CourseDataReady -> { + courseId = event.courseStructure.id + courseName = event.courseStructure.name + getCourseTopic() + } + + is CourseRefresh -> { + if (event.courseContainerTab == CourseContainerTab.DISCUSSIONS) { + getCourseTopic() + } + } + } + } + } + } + + 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 e34df3ed8..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 @@ -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.DiscussionTopicsViewModel 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 + DiscussionTopicsViewModel.ALL_POSTS ) advanceUntilIdle() @@ -139,7 +147,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) coEvery { interactor.getAllThreads(any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -170,7 +178,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.ALL_POSTS + DiscussionTopicsViewModel.ALL_POSTS ) advanceUntilIdle() @@ -198,7 +206,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) advanceUntilIdle() @@ -218,7 +226,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) coEvery { interactor.getFollowingThreads( @@ -273,7 +281,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.FOLLOWING_POSTS + DiscussionTopicsViewModel.FOLLOWING_POSTS ) advanceUntilIdle() @@ -301,7 +309,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) advanceUntilIdle() @@ -321,7 +329,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } throws Exception() advanceUntilIdle() @@ -352,7 +360,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) advanceUntilIdle() @@ -371,7 +379,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -391,7 +399,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -411,7 +419,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) coEvery { interactor.getThreads(any(), any(), any(), any(), any()) } returns ThreadsData( threads, @@ -441,7 +449,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) viewModel.updateThread("") advanceUntilIdle() @@ -477,7 +485,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.TOPIC + DiscussionTopicsViewModel.TOPIC ) @@ -516,7 +524,7 @@ class DiscussionThreadsViewModelTest { notifier, "", "", - DiscussionTopicsFragment.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 48fc87e75..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 @@ -7,24 +7,38 @@ import io.mockk.every import io.mockk.mockk 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 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.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.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.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 { @@ -37,16 +51,93 @@ class DiscussionTopicsViewModelTest { private val resourceManager = mockk() private val interactor = mockk() private val analytics = mockk() - private val networkConnection = mockk() + private val router = mockk() + private val courseNotifier = mockk() 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 @@ -55,98 +146,106 @@ class DiscussionTopicsViewModelTest { } @Test - fun `getCourseTopics no internet exception`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.updateCourseTopics() + 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) - assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) + assertEquals(noInternet, message.await()?.message) } @Test - fun `getCourseTopics unknown exception`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.updateCourseTopics() + 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) - assert(viewModel.uiState.value is DiscussionTopicsUIState.Loading) + assertEquals(somethingWrong, message.await()?.message) } @Test - fun `getCourseTopics success`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.updateCourseTopics() 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, networkConnection, "") + fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() - viewModel.updateCourseTopics(withSwipeRefresh = true) + 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) - assert(viewModel.isUpdating.value == false) + assertEquals(noInternet, message.await()?.message) } @Test - fun `updateCourseTopics unknown exception`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } throws Exception() - viewModel.updateCourseTopics(withSwipeRefresh = true) + 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) - assert(viewModel.isUpdating.value == false) + assertEquals(somethingWrong, message.await()?.message) } @Test - fun `updateCourseTopics success`() = runTest { - val viewModel = - DiscussionTopicsViewModel(interactor, resourceManager, analytics, networkConnection, "") + fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { + val viewModel = DiscussionTopicsViewModel(interactor, resourceManager, analytics, courseNotifier, router) coEvery { interactor.getCourseTopics(any()) } returns mockk() - viewModel.updateCourseTopics(withSwipeRefresh = true) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } 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) } -} \ No newline at end of file +}