From fa827163c08b9256958bb7796e066badfc91dae5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:42:31 +0200 Subject: [PATCH 01/16] fix: assignment string (#466) --- .../presentation/assignments/CourseContentAssignmentScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index ae238e84e..3040a11d7 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -212,7 +212,7 @@ private fun AssignmentGroupSection( completed = assignments.filter { it.isCompleted() }.size ) val description = stringResource( - id = R.string.course_completed, + id = R.string.course_completed_of, progress.completed, progress.total ) From 74b79cfa09aecd7ab3f426a429cc976f1aa2d70f Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:41:01 +0200 Subject: [PATCH 02/16] fix: download tab UI fix (#467) --- .../offline/CourseOfflineScreen.kt | 57 +++++++++++-------- .../offline/CourseOfflineUIState.kt | 5 +- .../offline/CourseOfflineViewModel.kt | 38 ++++++++----- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index 0356b0164..c776502b6 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -155,13 +155,17 @@ private fun CourseOfflineUI( } else { NoDownloadableBlocksProgress() } - if (uiState.progressBarValue != 1f && !uiState.isDownloading && hasInternetConnection) { + if ( + uiState.progressBarValue != 1f && + !uiState.isDownloading && + hasInternetConnection && + !uiState.isAllDownloaded + ) { Spacer(modifier = Modifier.height(20.dp)) OpenEdXButton( text = stringResource(R.string.core_download_all), backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = onDownloadAllClick, - enabled = uiState.isHaveDownloadableBlocks, content = { val textColor = if (uiState.isHaveDownloadableBlocks) { MaterialTheme.appColors.primaryButtonText @@ -365,15 +369,17 @@ private fun DownloadProgress( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = uiState.downloadedSize, + text = uiState.downloadedSize.toFileSize(1, false), style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.successGreen ) - Text( - text = uiState.readyToDownloadSize, - style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textDark - ) + if (uiState.readyToDownloadSize > 0) { + Text( + text = uiState.readyToDownloadSize.toFileSize(1, false), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark + ) + } } Spacer(modifier = Modifier.height(4.dp)) Row( @@ -388,20 +394,22 @@ private fun DownloadProgress( color = MaterialTheme.appColors.successGreen, textStyle = MaterialTheme.appTypography.labelLarge ) - if (!uiState.isDownloading) { - IconText( - text = stringResource(R.string.core_ready_to_download), - icon = Icons.Outlined.CloudDownload, - color = MaterialTheme.appColors.textDark, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } else { - IconText( - text = stringResource(R.string.core_downloading), - icon = Icons.Outlined.CloudDownload, - color = MaterialTheme.appColors.textDark, - textStyle = MaterialTheme.appTypography.labelLarge - ) + if (uiState.readyToDownloadSize > 0) { + if (!uiState.isDownloading) { + IconText( + text = stringResource(R.string.core_ready_to_download), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } else { + IconText( + text = stringResource(R.string.core_downloading), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textDark, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } } } if (uiState.progressBarValue != 0f) { @@ -462,10 +470,11 @@ private fun CourseOfflineUIPreview() { hasInternetConnection = true, uiState = CourseOfflineUIState( isHaveDownloadableBlocks = true, - readyToDownloadSize = "159MB", - downloadedSize = "0MB", + readyToDownloadSize = 100000L, + downloadedSize = 0L, progressBarValue = 0f, isDownloading = true, + isAllDownloaded = true, largestDownloads = listOf( DownloadModel( "", diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt index 8abde204f..a441af9e0 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineUIState.kt @@ -4,9 +4,10 @@ import org.openedx.core.module.db.DownloadModel data class CourseOfflineUIState( val isHaveDownloadableBlocks: Boolean, + val isAllDownloaded: Boolean, val largestDownloads: List, val isDownloading: Boolean, - val readyToDownloadSize: String, - val downloadedSize: String, + val readyToDownloadSize: Long, + val downloadedSize: Long, val progressBarValue: Float, ) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 620b79012..d180640c6 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -18,6 +18,7 @@ import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper @@ -28,7 +29,6 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureGot import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.utils.FileUtil class CourseOfflineViewModel( @@ -56,8 +56,9 @@ class CourseOfflineViewModel( isHaveDownloadableBlocks = false, largestDownloads = emptyList(), isDownloading = false, - readyToDownloadSize = "", - downloadedSize = "", + isAllDownloaded = false, + readyToDownloadSize = 0L, + downloadedSize = 0L, progressBarValue = 0f, ) ) @@ -163,20 +164,24 @@ class CourseOfflineViewModel( viewModelScope.launch { val courseStructure = courseInteractor.getCourseStructureFromCache(courseId) val totalDownloadableSize = getFilesSize(courseStructure.blockData) - - if (totalDownloadableSize == 0L) return@launch - courseInteractor.getDownloadModels().collect { downloadModels -> + val courseDownloadModels = downloadModels.filter { it.courseId == courseId } val completedDownloads = - downloadModels.filter { it.downloadedState.isDownloaded && it.courseId == courseId } - val completedDownloadIds = completedDownloads.map { it.id } - val downloadedBlocks = - courseStructure.blockData.filter { it.id in completedDownloadIds } + courseDownloadModels.filter { it.downloadedState.isDownloaded } + val downloadedBlocks = courseStructure.blockData.filter { + it.id in completedDownloads.map { it.id } + } + val isAllDownloaded = + courseDownloadModels.all { it.downloadedState == DownloadedState.DOWNLOADED } && + courseDownloadModels.isNotEmpty() + val isHaveDownloadableBlocks = courseStructure.blockData.any { it.isDownloadable } updateUIState( totalDownloadableSize, completedDownloads, - downloadedBlocks + downloadedBlocks, + isAllDownloaded, + isHaveDownloadableBlocks ) } } @@ -185,7 +190,9 @@ class CourseOfflineViewModel( private fun updateUIState( totalDownloadableSize: Long, completedDownloads: List, - downloadedBlocks: List + downloadedBlocks: List, + isAllDownloaded: Boolean, + isHaveDownloadableBlocks: Boolean, ) { val downloadedSize = getFilesSize(downloadedBlocks).toFloat() val realDownloadedSize = completedDownloads.sumOf { it.size } @@ -200,10 +207,11 @@ class CourseOfflineViewModel( } _uiState.update { it.copy( - isHaveDownloadableBlocks = true, + isHaveDownloadableBlocks = isHaveDownloadableBlocks, + isAllDownloaded = isAllDownloaded, largestDownloads = largestDownloads, - readyToDownloadSize = readyToDownloadSize.toFileSize(1, false), - downloadedSize = realDownloadedSize.toFileSize(1, false), + readyToDownloadSize = readyToDownloadSize, + downloadedSize = realDownloadedSize, progressBarValue = progressBarValue ) } From c0fe884663c778c163597152ee6dfb2b83bc90d7 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:49:22 +0200 Subject: [PATCH 03/16] fix: download all button (#468) --- .../presentation/offline/CourseOfflineViewModel.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index d180640c6..58fd12af6 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -18,7 +18,6 @@ import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel -import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper @@ -171,10 +170,13 @@ class CourseOfflineViewModel( val downloadedBlocks = courseStructure.blockData.filter { it.id in completedDownloads.map { it.id } } - val isAllDownloaded = - courseDownloadModels.all { it.downloadedState == DownloadedState.DOWNLOADED } && - courseDownloadModels.isNotEmpty() - val isHaveDownloadableBlocks = courseStructure.blockData.any { it.isDownloadable } + val allDownloadableBlocks = courseStructure.blockData.filter { it.isDownloadable } + val courseDownloadModelsMap = courseDownloadModels.associateBy { it.id } + val isAllDownloaded = allDownloadableBlocks.isNotEmpty() && + allDownloadableBlocks.all { block -> + courseDownloadModelsMap[block.id]?.downloadedState?.isDownloaded == true + } + val isHaveDownloadableBlocks = allDownloadableBlocks.isNotEmpty() updateUIState( totalDownloadableSize, From a8ddde81a714faccec372a84f923d13b06e7b221 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:10:45 +0200 Subject: [PATCH 04/16] chore: reusing mocks (#469) --- .../test/java/org/openedx/AppViewModelTest.kt | 10 +- .../signin/SignInViewModelTest.kt | 20 +- .../signup/SignUpViewModelTest.kt | 12 +- .../openedx/core/{Mock.kt => CoreMocks.kt} | 168 +++++++++-- .../java/org/openedx/course/CourseMocks.kt | 83 ++++++ .../CourseContentAssignmentScreen.kt | 158 +--------- .../presentation/dates/CourseDatesScreen.kt | 121 +------- .../CourseCompletionHomePagerCardContent.kt | 20 +- .../presentation/home/CourseHomeScreen.kt | 27 +- .../offline/CourseOfflineScreen.kt | 14 +- .../outline/CourseContentAllScreen.kt | 29 +- .../progress/CourseProgressScreen.kt | 38 +++ .../section/CourseSectionFragment.kt | 42 +-- .../course/presentation/ui/CourseUI.kt | 33 +-- .../videos/CourseContentVideoScreen.kt | 102 +------ .../download/DownloadQueueFragment.kt | 32 +- .../container/CourseContainerViewModelTest.kt | 159 ++-------- .../dates/CourseDatesViewModelTest.kt | 92 +----- .../home/CourseHomeViewModelTest.kt | 135 +++++---- .../outline/CourseOutlineViewModelTest.kt | 188 ++---------- .../section/CourseSectionViewModelTest.kt | 131 +------- .../CourseUnitContainerViewModelTest.kt | 152 ++-------- .../videos/CourseVideoViewModelTest.kt | 137 +-------- .../presentation/AllEnrolledCoursesView.kt | 60 +--- .../presentation/DashboardGalleryView.kt | 74 +---- .../org/openedx/dashboard/DashboardMocks.kt | 105 +++++++ .../presentation/DashboardListFragment.kt | 69 +---- .../org/openedx/discovery/DiscoveryMocks.kt | 32 ++ .../presentation/NativeDiscoveryFragment.kt | 52 +--- .../detail/CourseDetailsFragment.kt | 30 +- .../search/CourseSearchFragment.kt | 31 +- .../detail/CourseDetailsViewModelTest.kt | 33 +-- .../search/CourseSearchViewModelTest.kt | 41 +-- .../org/openedx/discussion/DiscussionMocks.kt | 76 +++++ .../comments/DiscussionCommentsFragment.kt | 76 +---- .../responses/DiscussionResponsesFragment.kt | 39 +-- .../search/DiscussionSearchThreadFragment.kt | 53 +--- .../threads/DiscussionThreadsFragment.kt | 52 +--- .../topics/DiscussionTopicsScreen.kt | 23 +- .../presentation/ui/DiscussionUI.kt | 76 +---- .../DiscussionCommentsViewModelTest.kt | 280 +++++++----------- .../DiscussionResponsesViewModelTest.kt | 105 +++---- .../DiscussionSearchThreadViewModelTest.kt | 56 +--- .../DiscussionAddThreadViewModelTest.kt | 53 +--- .../threads/DiscussionThreadsViewModelTest.kt | 54 +--- .../topics/DiscussionTopicsViewModelTest.kt | 19 +- .../presentation/download/DownloadsScreen.kt | 5 +- .../downloads/DownloadsViewModelTest.kt | 121 +------- .../java/org/openedx/profile/ProfileMocks.kt | 50 ++++ .../AnothersProfileFragment.kt | 27 +- .../presentation/edit/EditProfileFragment.kt | 25 +- .../compose/ManageAccountView.kt | 14 +- .../profile/compose/ProfileView.kt | 14 +- .../presentation/settings/SettingsScreenUI.kt | 31 +- .../profile/presentation/ui/ProfileUI.kt | 30 +- .../edit/EditProfileViewModelTest.kt | 93 ++++-- .../profile/AnothersProfileViewModelTest.kt | 26 +- .../profile/ProfileViewModelTest.kt | 29 +- 58 files changed, 1172 insertions(+), 2685 deletions(-) rename core/src/main/java/org/openedx/core/{Mock.kt => CoreMocks.kt} (60%) create mode 100644 course/src/main/java/org/openedx/course/CourseMocks.kt create mode 100644 dashboard/src/main/java/org/openedx/dashboard/DashboardMocks.kt create mode 100644 discovery/src/main/java/org/openedx/discovery/DiscoveryMocks.kt create mode 100644 discussion/src/main/java/org/openedx/discussion/DiscussionMocks.kt create mode 100644 profile/src/main/java/org/openedx/profile/ProfileMocks.kt diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 23b1c4120..0271aace3 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -26,9 +26,9 @@ import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase +import org.openedx.core.CoreMocks import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig -import org.openedx.core.data.model.User import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -52,8 +52,6 @@ class AppViewModelTest { private val context = mockk() private val downloadNotifier = mockk() - private val user = User(0, "", "", "") - @Before fun before() { Dispatchers.setMain(dispatcher) @@ -68,7 +66,7 @@ class AppViewModelTest { @Test fun setIdSuccess() = runTest { every { analytics.setUserIdForSession(any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { notifier.notifier } returns flow { } every { preferencesManager.canResetAppDirectory } returns false every { preferencesManager.pushToken } returns "" @@ -102,7 +100,7 @@ class AppViewModelTest { } every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false @@ -140,7 +138,7 @@ class AppViewModelTest { } every { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index 52c9e96a7..4a0db245c 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -28,12 +28,12 @@ import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper +import org.openedx.core.CoreMocks import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig -import org.openedx.core.data.model.User import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor @@ -75,8 +75,6 @@ class SignInViewModelTest { private val invalidEmailOrUsername = "Invalid email or username" private val invalidPassword = "Password too short" - private val user = User(0, "", "", "") - @Before fun before() { Dispatchers.setMain(dispatcher) @@ -109,7 +107,7 @@ class SignInViewModelTest { @Test fun `login empty credentials validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -147,7 +145,7 @@ class SignInViewModelTest { @Test fun `login invalid email validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -184,7 +182,7 @@ class SignInViewModelTest { fun `login empty password validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -222,7 +220,7 @@ class SignInViewModelTest { fun `login invalid password validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -262,7 +260,7 @@ class SignInViewModelTest { fun `login success`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit coEvery { appNotifier.send(any()) } returns Unit @@ -304,7 +302,7 @@ class SignInViewModelTest { fun `login network error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -347,7 +345,7 @@ class SignInViewModelTest { fun `login invalid grant error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -390,7 +388,7 @@ class SignInViewModelTest { fun `login unknown exception`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index 7426f752b..933c57234 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -32,12 +32,12 @@ import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants +import org.openedx.core.CoreMocks import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig -import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.RegistrationField @@ -98,8 +98,6 @@ class SignUpViewModelTest { ) ) - private val user = User(0, "", "", "") - //endregion private val noInternet = "Slow or no internet connection" @@ -149,7 +147,7 @@ class SignUpViewModelTest { every { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() advanceUntilIdle() @@ -198,7 +196,7 @@ class SignUpViewModelTest { ) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() advanceUntilIdle() @@ -242,7 +240,7 @@ class SignUpViewModelTest { coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { analytics.logEvent(any(), any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.register() advanceUntilIdle() @@ -288,7 +286,7 @@ class SignUpViewModelTest { parametersMap.getValue(ApiConstants.PASSWORD) ) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() advanceUntilIdle() diff --git a/core/src/main/java/org/openedx/core/Mock.kt b/core/src/main/java/org/openedx/core/CoreMocks.kt similarity index 60% rename from core/src/main/java/org/openedx/core/Mock.kt rename to core/src/main/java/org/openedx/core/CoreMocks.kt index 445fc7a05..e1638d235 100644 --- a/core/src/main/java/org/openedx/core/Mock.kt +++ b/core/src/main/java/org/openedx/core/CoreMocks.kt @@ -1,17 +1,25 @@ package org.openedx.core +import org.openedx.core.data.model.User import org.openedx.core.data.model.room.VideoProgressEntity +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.domain.model.EncodedVideos -import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.ResetCourseDates import org.openedx.core.domain.model.StudentViewData @@ -19,15 +27,32 @@ import org.openedx.core.domain.model.VideoInfo import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadModelsSize import java.util.Date -object Mock { - private val mockAssignmentProgress = AssignmentProgress( +object CoreMocks { + val mockAssignmentProgress = AssignmentProgress( assignmentType = "Home", numPointsEarned = 1f, numPointsPossible = 3f, shortLabel = "HM1" ) + + val mockUser = User( + id = 0, + username = "", + email = "", + name = "" + ) + + val mockAppConfig = AppConfig( + courseDatesCalendarSync = CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) val mockChapterBlock = Block( id = "id", blockId = "blockId", @@ -40,7 +65,7 @@ object Mock { studentViewData = null, studentViewMultiDevice = false, blockCounts = BlockCounts(1), - descendants = emptyList(), + descendants = listOf("1"), descendantsType = BlockType.CHAPTER, completion = 0.0, containsGatedContent = false, @@ -48,30 +73,61 @@ object Mock { due = Date(), offlineDownload = null ) - private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = OfflineDownload("fileUrl", "", 1), + + val mockBlockData = listOf( + mockChapterBlock.copy( + id = "id", + type = BlockType.HTML, + blockCounts = BlockCounts(0), + descendants = listOf("id2", "id1"), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ), + mockChapterBlock.copy( + id = "id1", + type = BlockType.VERTICAL, + blockCounts = BlockCounts(0), + descendants = listOf("id2", "id"), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ), + mockChapterBlock.copy( + id = "id2", + type = BlockType.SEQUENTIAL, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ), + mockChapterBlock.copy( + id = "id3", + type = BlockType.HTML, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ) ) val mockCourseStructure = CourseStructure( root = "", - blockData = listOf(mockSequentialBlock, mockSequentialBlock), + blockData = mockBlockData, id = "id", name = "Course name", number = "", @@ -111,6 +167,15 @@ object Mock { courseBanner = mockCourseDatesBannerInfo ) + val mockCoursewareAccess = CoursewareAccess( + hasAccess = true, + errorCode = "", + developerMessage = "", + userMessage = "", + userFragment = "", + additionalContextUserMessage = "" + ) + val mockCourseProgress = CourseProgress( verifiedMode = "audit", accessExpiration = "", @@ -130,6 +195,46 @@ object Mock { disableProgressGraph = false ) + val mockCourseAccessDetails = CourseAccessDetails( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = null, + coursewareAccess = mockCoursewareAccess + ) + + val mockEnrollmentDetails = EnrollmentDetails( + created = Date(), + mode = "audit", + isActive = true, + upgradeDeadline = Date() + ) + + val mockCourseInfoOverview = CourseInfoOverview( + name = "Open edX Demo Course", + number = "DemoX", + org = "edX", + start = Date(), + startDisplay = "Today", + startType = "", + end = null, + isSelfPaced = false, + media = null, + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseAbout = "About course" + ) + + val mockCourseEnrollmentDetails = CourseEnrollmentDetails( + id = "course-id", + courseUpdates = "Course updates", + courseHandouts = "Course handouts", + discussionUrl = "https://example.com/discussion", + courseAccessDetails = mockCourseAccessDetails, + certificate = null, + enrollmentDetails = mockEnrollmentDetails, + courseInfoOverview = mockCourseInfoOverview + ) + val mockVideoProgress = VideoProgressEntity( blockId = "video1", videoUrl = "test-video-url", @@ -260,4 +365,19 @@ object Mock { isSelfPaced = false, progress = null ) + + val coursePreview = DownloadCoursePreview( + id = "course-id", + name = "Preview Course", + image = "", + totalSize = 100L + ) + + val mockDownloadModelsSize = DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 1, + allSize = 0 + ) } diff --git a/course/src/main/java/org/openedx/course/CourseMocks.kt b/course/src/main/java/org/openedx/course/CourseMocks.kt new file mode 100644 index 000000000..cd7a56383 --- /dev/null +++ b/course/src/main/java/org/openedx/course/CourseMocks.kt @@ -0,0 +1,83 @@ +package org.openedx.course + +import org.openedx.core.BlockType +import org.openedx.core.data.model.DateType +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.domain.model.Progress +import java.util.Date + +object CourseMocks { + + val sequentialBlock: Block = Block( + id = "sequential-id", + blockId = "sequential-id", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" + ), + due = Date(), + offlineDownload = null + ) + + val coursewareAccess = CoursewareAccess( + hasAccess = true, + errorCode = "", + developerMessage = "", + userMessage = "", + additionalContextUserMessage = "", + userFragment = "" + ) + + val progress: Progress = Progress.DEFAULT_PROGRESS + + val assignmentProgress = Progress(1, 3) + val assignmentProgressTablet = Progress(2, 3) + + val courseDateBlock = CourseDateBlock( + complete = false, + date = Date(), + dateType = DateType.TODAY_DATE, + description = "Mocked Course Date Description" + ) + + val courseDateBlocks = linkedMapOf( + Pair(DatesSection.COMPLETED, listOf(courseDateBlock, courseDateBlock)), + Pair(DatesSection.PAST_DUE, listOf(courseDateBlock, courseDateBlock)), + Pair(DatesSection.TODAY, listOf(courseDateBlock, courseDateBlock)) + ) + + val courseDatesBannerInfoWithData = CourseDatesBannerInfo( + missedDeadlines = true, + missedGatedContent = false, + verifiedUpgradeLink = "", + contentTypeGatingEnabled = false, + hasEnded = true, + ) + + val courseDatesResultWithData = CourseDatesResult( + datesSection = courseDateBlocks, + courseBanner = courseDatesBannerInfoWithData, + ) +} diff --git a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt index 3040a11d7..12c500f8f 100644 --- a/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/assignments/CourseContentAssignmentScreen.kt @@ -52,17 +52,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import org.openedx.core.BlockType -import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.CoreMocks import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.Progress import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils +import org.openedx.course.CourseMocks import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentAssignmentEmptyState import org.openedx.course.presentation.ui.CourseProgress @@ -78,9 +76,6 @@ private const val POINTER_ICON_SIZE_DP = 10 private const val POINTER_ICON_PADDING_TOP_DP = 4 private const val PROGRESS_HEIGHT_DP = 6 private const val ASSIGNMENT_BUTTON_CARD_BACKGROUND_ALPHA = 0.5f -private const val COMPLETED_ASSIGNMENTS_COUNT = 1 -private const val COMPLETED_ASSIGNMENTS_COUNT_TABLET = 2 -private const val TOTAL_ASSIGNMENTS_COUNT = 3 @Composable fun CourseContentAssignmentScreen( @@ -524,11 +519,11 @@ private fun CourseContentAssignmentScreenPreview() { CourseContentAssignmentScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseAssignmentUIState.CourseData( - progress = Progress(COMPLETED_ASSIGNMENTS_COUNT, TOTAL_ASSIGNMENTS_COUNT), + progress = CourseMocks.assignmentProgress, groupedAssignments = mapOf( - "Homework" to listOf(mockChapterBlock, mockSequentialBlock) + "Homework" to listOf(CoreMocks.mockChapterBlock, CourseMocks.sequentialBlock) ), - courseProgress = mockCourseProgress, + courseProgress = CoreMocks.mockCourseProgress, sectionNames = mapOf() ), onAssignmentClick = {}, @@ -557,12 +552,12 @@ private fun CourseContentAssignmentScreenTabletPreview() { CourseContentAssignmentScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseAssignmentUIState.CourseData( - progress = Progress(COMPLETED_ASSIGNMENTS_COUNT_TABLET, TOTAL_ASSIGNMENTS_COUNT), + progress = CourseMocks.assignmentProgressTablet, groupedAssignments = mapOf( - "Homework" to listOf(mockChapterBlock), - "Quiz" to listOf(mockSequentialBlock) + "Homework" to listOf(CoreMocks.mockChapterBlock), + "Quiz" to listOf(CourseMocks.sequentialBlock) ), - courseProgress = mockCourseProgress, + courseProgress = CoreMocks.mockCourseProgress, sectionNames = mapOf() ), onAssignmentClick = {}, @@ -570,138 +565,3 @@ private fun CourseContentAssignmentScreenTabletPreview() { ) } } - -private val mockCourseProgress = CourseProgress( - verifiedMode = "verified", - accessExpiration = "2024-12-31", - certificateData = CourseProgress.CertificateData( - certStatus = "downloadable", - certWebViewUrl = "https://example.com/cert", - downloadUrl = "https://example.com/cert.pdf", - certificateAvailableDate = "2024-06-01" - ), - completionSummary = CourseProgress.CompletionSummary( - completeCount = 5, - incompleteCount = 3, - lockedCount = 1 - ), - courseGrade = CourseProgress.CourseGrade( - letterGrade = "B+", - percent = 85.5, - isPassing = true - ), - creditCourseRequirements = "Complete all assignments", - end = "2024-12-31", - enrollmentMode = "verified", - gradingPolicy = CourseProgress.GradingPolicy( - assignmentPolicies = listOf( - CourseProgress.GradingPolicy.AssignmentPolicy( - numDroppable = 1, - numTotal = 5, - shortLabel = "HW", - type = "Homework", - weight = 0.4 - ), - CourseProgress.GradingPolicy.AssignmentPolicy( - numDroppable = 0, - numTotal = 3, - shortLabel = "Quiz", - type = "Quiz", - weight = 0.6 - ) - ), - gradeRange = mapOf( - "A" to 0.9f, - "B" to 0.8f, - "C" to 0.7f, - "D" to 0.6f - ), - assignmentColors = listOf(Color(0xFF2196F3), Color(0xFF4CAF50)) - ), - hasScheduledContent = false, - sectionScores = listOf( - CourseProgress.SectionScore( - displayName = "Week 1", - subsections = listOf( - CourseProgress.SectionScore.Subsection( - assignmentType = "Homework", - blockKey = "block1", - displayName = "Homework 1", - hasGradedAssignment = true, - override = "", - learnerHasAccess = true, - numPointsEarned = 8f, - numPointsPossible = 10f, - percentGraded = 80.0, - problemScores = listOf( - CourseProgress.SectionScore.Subsection.ProblemScore( - earned = 8.0, - possible = 10.0 - ) - ), - showCorrectness = "always", - showGrades = true, - url = "https://example.com/hw1" - ) - ) - ) - ), - studioUrl = "https://studio.example.com", - username = "testuser", - userHasPassingGrade = true, - verificationData = CourseProgress.VerificationData( - link = "https://example.com/verify", - status = "approved", - statusDate = "2024-01-15" - ), - disableProgressGraph = false -) - -private val mockAssignmentProgress = AssignmentProgress( - assignmentType = "Home", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HM1" -) - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) - -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.SEQUENTIAL, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 326ff8839..31541459b 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -61,10 +61,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.NoContentScreenType -import org.openedx.core.data.model.DateType import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.DatesSection import org.openedx.core.presentation.CoreAnalyticsScreen import org.openedx.core.presentation.dialog.alert.ActionDialogFragment @@ -76,9 +73,9 @@ 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.core.utils.TimeUtils import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime +import org.openedx.course.CourseMocks import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.unit.container.CourseViewMode @@ -87,7 +84,6 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue -import java.util.Date import org.openedx.core.R as CoreR @Composable @@ -473,7 +469,11 @@ private fun CourseDateBlockSection( if (sectionKey != DatesSection.COMPLETED) { DateBullet(section = sectionKey) } - DateBlock(dateBlocks = sectionDates, onItemClick = onItemClick, useRelativeDates = useRelativeDates) + DateBlock( + dateBlocks = sectionDates, + onItemClick = onItemClick, + useRelativeDates = useRelativeDates + ) } } } @@ -645,7 +645,7 @@ private fun CourseDatesScreenPreview() { CourseDatesUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseDatesUIState.CourseDates( - CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CourseMocks.courseDatesResultWithData, CalendarSyncState.SYNCED ), uiMessage = null, @@ -667,7 +667,7 @@ private fun CourseDatesScreenTabletPreview() { CourseDatesUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseDatesUIState.CourseDates( - CourseDatesResult(mockedResponse, mockedCourseBannerInfo), + CourseMocks.courseDatesResultWithData, CalendarSyncState.SYNCED ), uiMessage = null, @@ -680,108 +680,3 @@ private fun CourseDatesScreenTabletPreview() { ) } } - -val mockedCourseBannerInfo = CourseDatesBannerInfo( - missedDeadlines = true, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false, -) - -private val mockedResponse: LinkedHashMap> = - linkedMapOf( - Pair( - DatesSection.COMPLETED, - listOf( - CourseDateBlock( - title = "Homework 1: ABCD", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, - ) - ) - ), - - Pair( - DatesSection.COMPLETED, - listOf( - CourseDateBlock( - title = "Homework 1: ABCD", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, - ) - ) - ), - - Pair( - DatesSection.PAST_DUE, - listOf( - CourseDateBlock( - title = "Homework 1: ABCD", - description = "After this date, course content will be archived", - date = Date(), - dateType = DateType.ASSIGNMENT_DUE_DATE, - ) - ) - ), - - Pair( - DatesSection.TODAY, - listOf( - CourseDateBlock( - title = "Homework 2: ABCD", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-21T15:08:07Z")!!, - ) - ) - ), - - Pair( - DatesSection.THIS_WEEK, - listOf( - CourseDateBlock( - title = "Assignment Due: ABCD", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-22T15:08:07Z")!!, - dateType = DateType.ASSIGNMENT_DUE_DATE, - ), - - CourseDateBlock( - title = "Assignment Due", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-23T15:08:07Z")!!, - dateType = DateType.ASSIGNMENT_DUE_DATE, - ), - - CourseDateBlock( - title = "Surprise Assignment", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-24T15:08:07Z")!!, - ) - ) - ), - - Pair( - DatesSection.NEXT_WEEK, - listOf( - CourseDateBlock( - title = "Homework 5: ABCD", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-25T15:08:07Z")!!, - ) - ) - ), - - Pair( - DatesSection.UPCOMING, - listOf( - CourseDateBlock( - title = "Last Assignment", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2023-10-26T15:08:07Z")!!, - assignmentType = "Module 1", - dateType = DateType.VERIFICATION_DEADLINE_DATE, - ) - ) - ) - ) diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index 031a3a145..8fcc08e07 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -17,9 +17,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.openedx.core.Mock +import org.openedx.core.CoreMocks import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography @@ -132,21 +131,18 @@ private fun CourseCompletionHomePagerCardContentPreview() { OpenEdXTheme { CourseCompletionHomePagerCardContent( uiState = CourseHomeUIState.CourseData( - courseStructure = Mock.mockCourseStructure, + courseStructure = CoreMocks.mockCourseStructure, courseProgress = null, // No course progress for preview - next = Pair(Mock.mockChapterBlock, Mock.mockChapterBlock), // Mock next section + next = Pair( + CoreMocks.mockChapterBlock, + CoreMocks.mockChapterBlock + ), // Mock next section downloadedState = mapOf(), - resumeComponent = Mock.mockChapterBlock, + resumeComponent = CoreMocks.mockChapterBlock, resumeUnitTitle = "Resumed Unit", courseSubSections = mapOf(), subSectionsDownloadsCount = mapOf(), - datesBannerInfo = CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ), + datesBannerInfo = CoreMocks.mockCourseDatesBannerInfo, useRelativeDates = true, courseVideos = mapOf(), courseAssignments = emptyList(), diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index 241d51f31..48e449625 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -49,10 +49,9 @@ 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.Mock +import org.openedx.core.CoreMocks import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen @@ -452,21 +451,15 @@ private fun CourseHomeScreenPreview() { CourseHomeUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseHomeUIState.CourseData( - courseStructure = Mock.mockCourseStructure, + courseStructure = CoreMocks.mockCourseStructure, courseProgress = null, // No course progress for preview next = null, // No next section for preview downloadedState = mapOf(), - resumeComponent = Mock.mockChapterBlock, + resumeComponent = CoreMocks.mockChapterBlock, resumeUnitTitle = "Resumed Unit", courseSubSections = mapOf(), subSectionsDownloadsCount = mapOf(), - datesBannerInfo = CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ), + datesBannerInfo = CoreMocks.mockCourseDatesBannerInfo, useRelativeDates = true, courseVideos = mapOf(), courseAssignments = emptyList(), @@ -505,21 +498,15 @@ private fun CourseHomeScreenTabletPreview() { CourseHomeUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseHomeUIState.CourseData( - courseStructure = Mock.mockCourseStructure, + courseStructure = CoreMocks.mockCourseStructure, courseProgress = null, // No course progress for preview next = null, // No next section for preview downloadedState = mapOf(), - resumeComponent = Mock.mockChapterBlock, + resumeComponent = CoreMocks.mockChapterBlock, resumeUnitTitle = "Resumed Unit", courseSubSections = mapOf(), subSectionsDownloadsCount = mapOf(), - datesBannerInfo = CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ), + datesBannerInfo = CoreMocks.mockCourseDatesBannerInfo, useRelativeDates = true, courseVideos = mapOf(), courseAssignments = emptyList(), diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index c776502b6..b913e979f 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -49,9 +49,9 @@ 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.CoreMocks import org.openedx.core.R import org.openedx.core.module.db.DownloadModel -import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton @@ -476,17 +476,7 @@ private fun CourseOfflineUIPreview() { isDownloading = true, isAllDownloaded = true, largestDownloads = listOf( - DownloadModel( - "", - "", - "", - 0, - "", - "", - FileType.X_BLOCK, - DownloadedState.DOWNLOADED, - null - ) + CoreMocks.mockDownloadModel ), ), onDownloadAllClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index e8355387b..751033ca2 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -36,9 +36,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import org.openedx.core.BlockType -import org.openedx.core.Mock +import org.openedx.core.CoreMocks import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.Progress import org.openedx.core.extension.getChapterBlocks import org.openedx.core.ui.CircularProgress @@ -357,20 +356,14 @@ private fun CourseOutlineScreenPreview() { CourseContentAllUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseContentAllUIState.CourseData( - Mock.mockCourseStructure, + CoreMocks.mockCourseStructure, mapOf(), - Mock.mockChapterBlock, + CoreMocks.mockChapterBlock, "Resumed Unit", mapOf(), mapOf(), mapOf(), - CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ), + CoreMocks.mockCourseDatesBannerInfo, true ), uiMessage = null, @@ -393,20 +386,14 @@ private fun CourseContentAllScreenTabletPreview() { CourseContentAllUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseContentAllUIState.CourseData( - Mock.mockCourseStructure, + CoreMocks.mockCourseStructure, mapOf(), - Mock.mockChapterBlock, + CoreMocks.mockChapterBlock, "Resumed Unit", mapOf(), mapOf(), mapOf(), - CourseDatesBannerInfo( - missedDeadlines = false, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = false - ), + CoreMocks.mockCourseDatesBannerInfo, true ), uiMessage = null, @@ -426,6 +413,6 @@ private fun CourseContentAllScreenTabletPreview() { @Composable private fun ResumeCoursePreview() { OpenEdXTheme { - ResumeCourseButton(block = Mock.mockChapterBlock, displayName = "Resumed Unit") {} + ResumeCourseButton(block = CoreMocks.mockChapterBlock, displayName = "Resumed Unit") {} } } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt index c2954c84a..2c3f8ca76 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressScreen.kt @@ -1,5 +1,6 @@ package org.openedx.course.presentation.progress +import android.content.res.Configuration import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -51,20 +52,25 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle +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 org.openedx.core.CoreMocks import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.CourseProgress import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.NoContentScreen 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.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue @Composable @@ -587,3 +593,35 @@ fun CurrentOverallGradeText( style = MaterialTheme.appTypography.labelMedium, ) } + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseProgressScreenPreview() { + OpenEdXTheme { + CourseProgressContent( + uiState = CourseProgressUIState.Data( + progress = CoreMocks.mockCourseProgress, + courseStructure = CoreMocks.mockCourseStructure + ), + uiMessage = null, + windowSize = WindowSize(WindowType.Compact, WindowType.Compact) + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseProgressScreenTabletPreview() { + OpenEdXTheme { + CourseProgressContent( + uiState = CourseProgressUIState.Data( + progress = CoreMocks.mockCourseProgress, + courseStructure = CoreMocks.mockCourseStructure + ), + uiMessage = null, + windowSize = WindowSize(WindowType.Medium, WindowType.Medium) + ) + } +} diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 36e20ce2c..7bfe8a24c 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -54,10 +54,8 @@ import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.BlockType -import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.CoreMocks import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -76,7 +74,6 @@ import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue -import java.util.Date import org.openedx.core.R as CoreR class CourseSectionFragment : Fragment() { @@ -350,10 +347,10 @@ private fun CourseSectionScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = CourseSectionUIState.Blocks( listOf( - mockBlock, - mockBlock, - mockBlock, - mockBlock + CoreMocks.mockChapterBlock, + CoreMocks.mockChapterBlock, + CoreMocks.mockChapterBlock, + CoreMocks.mockChapterBlock, ), "", "Course default" @@ -374,10 +371,10 @@ private fun CourseSectionScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = CourseSectionUIState.Blocks( listOf( - mockBlock, - mockBlock, - mockBlock, - mockBlock + CoreMocks.mockChapterBlock, + CoreMocks.mockChapterBlock, + CoreMocks.mockChapterBlock, + CoreMocks.mockChapterBlock, ), "", "Course default", @@ -388,24 +385,3 @@ private fun CourseSectionScreenTabletPreview() { ) } } - -private val mockBlock = Block( - id = "id", - 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, - containsGatedContent = false, - assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), - due = Date(), - offlineDownload = null -) 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 19d3bb4b5..68e6c887a 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 @@ -93,10 +93,8 @@ import androidx.compose.ui.zIndex import coil.compose.AsyncImage import coil.request.ImageRequest import org.jsoup.Jsoup -import org.openedx.core.BlockType -import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.CoreMocks import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.Progress import org.openedx.core.extension.safeDivBy @@ -116,8 +114,8 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.VideoPreview +import org.openedx.course.CourseMocks import org.openedx.course.R -import org.openedx.course.presentation.dates.mockedCourseBannerInfo import org.openedx.course.presentation.outline.getUnitBlockIcon import org.openedx.foundation.extension.nonZero import org.openedx.foundation.extension.toFileSize @@ -1700,7 +1698,7 @@ private fun CourseSectionCardPreview() { OpenEdXTheme { Surface(color = MaterialTheme.appColors.background) { CourseSectionCard( - mockChapterBlock, + CoreMocks.mockChapterBlock, DownloadedState.DOWNLOADED, onItemClick = {}, onDownloadClick = {} @@ -1716,7 +1714,7 @@ private fun CourseDatesBannerPreview() { OpenEdXTheme { CourseDatesBanner( modifier = Modifier, - banner = mockedCourseBannerInfo, + banner = CourseMocks.courseDatesBannerInfoWithData, resetDates = {} ) } @@ -1729,7 +1727,7 @@ private fun CourseDatesBannerTabletPreview() { OpenEdXTheme { CourseDatesBannerTablet( modifier = Modifier, - banner = mockedCourseBannerInfo, + banner = CourseMocks.courseDatesBannerInfoWithData, resetDates = {} ) } @@ -1780,24 +1778,3 @@ private fun CourseMessagePreview() { } } } - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = AssignmentProgress("", 1f, 2f, "HM1"), - due = Date(), - offlineDownload = null -) diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt index f482596ec..7da1bb59b 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseContentVideoScreen.kt @@ -29,14 +29,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager -import org.openedx.core.BlockType -import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.CoreMocks 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.domain.model.Progress -import org.openedx.core.module.download.DownloadModelsSize import org.openedx.core.ui.CircularProgress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -51,7 +46,6 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue -import java.util.Date @Composable fun CourseContentVideoScreen( @@ -246,17 +240,11 @@ private fun CourseVideosScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiMessage = null, uiState = CourseVideoUIState.CourseData( - mockCourseStructure, + CoreMocks.mockCourseStructure, emptyMap(), mapOf(), mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 1, - allSize = 0 - ), + CoreMocks.mockDownloadModelsSize, isCompletedSectionsShown = false, videoPreview = mapOf(), videoProgress = mapOf(), @@ -295,16 +283,12 @@ private fun CourseVideosScreenTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiMessage = null, uiState = CourseVideoUIState.CourseData( - mockCourseStructure, + CoreMocks.mockCourseStructure, emptyMap(), mapOf(), mapOf(), - DownloadModelsSize( - isAllBlocksDownloadedOrDownloading = false, - remainingCount = 0, - remainingSize = 0, - allCount = 0, - allSize = 0 + CoreMocks.mockDownloadModelsSize.copy( + allCount = 0 ), isCompletedSectionsShown = true, videoPreview = mapOf(), @@ -317,77 +301,3 @@ private fun CourseVideosScreenTabletPreview() { ) } } - -private val mockAssignmentProgress = AssignmentProgress( - assignmentType = "Home", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HM1" -) - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) - -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.SEQUENTIAL, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = null -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockChapterBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = Progress(1, 3), -) diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index 612056392..d2ab1b354 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -42,9 +42,9 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.CoreMocks import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState -import org.openedx.core.module.db.FileType import org.openedx.core.ui.BackBtn import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -226,30 +226,18 @@ private fun DownloadQueueScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DownloadQueueUIState.Models( listOf( - DownloadModel( - courseId = "", - id = "", - title = "1", - size = 0, - path = "", - url = "", - type = FileType.VIDEO, - downloadedState = DownloadedState.DOWNLOADING, + CoreMocks.mockDownloadModel.copy( + title = "Video 1", + downloadedState = DownloadedState.DOWNLOADING ), - DownloadModel( - courseId = "", - id = "", - title = "2", - size = 0, - path = "", - url = "", - type = FileType.VIDEO, - downloadedState = DownloadedState.DOWNLOADING, + CoreMocks.mockDownloadModel.copy( + title = "Video 2", + downloadedState = DownloadedState.DOWNLOADING ) ), - currentProgressId = "", - currentProgressValue = 0, - currentProgressSize = 1 + currentProgressId = CoreMocks.mockDownloadModel.id, + currentProgressValue = 50, + currentProgressSize = 100 ), onBackClick = {}, onDownloadClick = {} 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 f9b17792c..c64ce59a3 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 @@ -24,21 +24,12 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.core.CoreMocks import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.AppConfig -import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseAccessError -import org.openedx.core.domain.model.CourseDatesCalendarSync -import org.openedx.core.domain.model.CourseEnrollmentDetails -import org.openedx.core.domain.model.CourseInfoOverview -import org.openedx.core.domain.model.CourseSharingUtmParameters -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated @@ -49,7 +40,6 @@ import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseRouter import org.openedx.course.utils.ImageProcessor import org.openedx.foundation.system.ResourceManager -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseContainerViewModelTest { @@ -76,138 +66,17 @@ class CourseContainerViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - private val user = User( - id = 0, - username = "", - email = "", - name = "", - ) - private val appConfig = AppConfig( - CourseDatesCalendarSync( - isEnabled = true, - isSelfPacedEnabled = true, - isInstructorPacedEnabled = true, - isDeepLinkEnabled = false, - ) - ) - private val courseDetails = CourseEnrollmentDetails( - id = "id", - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - courseAccessDetails = CourseAccessDetails( - false, - false, - false, - null, - coursewareAccess = CoursewareAccess( - false, - "", - "", - "", - "", - "" - ) - ), - certificate = null, - enrollmentDetails = EnrollmentDetails( - null, - "audit", - false, - Date() - ), - courseInfoOverview = CourseInfoOverview( - "Open edX Demo Course", - "", - "OpenedX", - Date(), - "", - "", - null, - false, - null, - CourseSharingUtmParameters("", ""), - "", - ) - ) - - private val courseStructure = CourseStructure( - root = "", - blockData = listOf(), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(0), - startDisplay = "", - startType = "", - end = null, - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false, - progress = null - ) - - private val enrollmentDetails = CourseEnrollmentDetails( - id = "", - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - courseAccessDetails = CourseAccessDetails( - false, - false, - false, - null, - CoursewareAccess( - false, - "", - "", - "", - "", - "" - ) - ), - certificate = null, - enrollmentDetails = EnrollmentDetails( - null, - "", - false, - null - ), - courseInfoOverview = CourseInfoOverview( - "Open edX Demo Course", - "", - "OpenedX", - null, - "", - "", - null, - false, - null, - CourseSharingUtmParameters("", ""), - "", - ) - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(id = R.string.platform_name) } returns openEdx every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { corePreferences.user } returns user - every { corePreferences.appConfig } returns appConfig + every { corePreferences.user } returns CoreMocks.mockUser + every { corePreferences.appConfig } returns CoreMocks.mockAppConfig every { courseNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "baseUrl" - coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails + coEvery { interactor.getEnrollmentDetails(any()) } returns CoreMocks.mockCourseEnrollmentDetails every { imageProcessor.loadImage(any(), any(), any()) } returns Unit every { imageProcessor.applyBlur(any(), any()) } returns mockBitmap } @@ -292,8 +161,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) - coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + CoreMocks.mockCourseStructure + ) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf( + CoreMocks.mockCourseEnrollmentDetails + ) every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -345,8 +218,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) - coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + CoreMocks.mockCourseStructure + ) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf( + CoreMocks.mockCourseEnrollmentDetails + ) every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -426,8 +303,8 @@ class CourseContainerViewModelTest { calendarSyncScheduler, courseRouter ) - coEvery { interactor.getEnrollmentDetails(any()) } returns courseDetails - coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure + coEvery { interactor.getEnrollmentDetails(any()) } returns CoreMocks.mockCourseEnrollmentDetails + coEvery { interactor.getCourseStructure(any(), true) } returns CoreMocks.mockCourseStructure coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index a8d4466dd..ca9b996a3 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 @@ -24,34 +24,26 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.CalendarRouter +import org.openedx.core.CoreMocks import org.openedx.core.R import org.openedx.core.config.Config -import org.openedx.core.data.model.DateType -import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor -import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CourseCalendarState -import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseDatesResult -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.DatesSection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.calendar.CalendarEvent import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.system.notifier.calendar.CalendarSynced +import org.openedx.course.CourseMocks import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseDatesViewModelTest { @@ -76,85 +68,15 @@ class CourseDatesViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - private val user = User( - id = 0, - username = "", - email = "", - name = "", - ) - private val appConfig = AppConfig( - CourseDatesCalendarSync( - isEnabled = true, - isSelfPacedEnabled = true, - isInstructorPacedEnabled = true, - isDeepLinkEnabled = false, - ) - ) - private val dateBlock = CourseDateBlock( - complete = false, - date = Date(), - dateType = DateType.TODAY_DATE, - description = "Mocked Course Date Description" - ) - private val mockDateBlocks = linkedMapOf( - Pair( - DatesSection.COMPLETED, - listOf(dateBlock, dateBlock) - ), - Pair( - DatesSection.PAST_DUE, - listOf(dateBlock, dateBlock) - ), - Pair( - DatesSection.TODAY, - listOf(dateBlock, dateBlock) - ) - ) - private val mockCourseDatesBannerInfo = CourseDatesBannerInfo( - missedDeadlines = true, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = true, - ) - private val mockedCourseDatesResult = CourseDatesResult( - datesSection = mockDateBlocks, - courseBanner = mockCourseDatesBannerInfo, - ) - private val courseStructure = CourseStructure( - root = "", - blockData = listOf(), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(0), - startDisplay = "", - startType = "", - end = null, - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = true, - progress = null - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(id = R.string.platform_name) } returns openEdx every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { corePreferences.user } returns user - every { corePreferences.appConfig } returns appConfig + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + every { corePreferences.user } returns CoreMocks.mockUser + every { corePreferences.appConfig } returns CoreMocks.mockAppConfig every { notifier.notifier } returns flowOf(CourseLoading(false)) coEvery { notifier.send(any()) } returns Unit coEvery { notifier.send(any()) } returns Unit @@ -249,7 +171,7 @@ class CourseDatesViewModelTest { courseRouter, calendarRouter, ) - coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult + coEvery { interactor.getCourseDates(any()) } returns CourseMocks.courseDatesResultWithData val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage @@ -281,7 +203,7 @@ class CourseDatesViewModelTest { ) coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), - courseBanner = mockCourseDatesBannerInfo, + courseBanner = CoreMocks.mockCourseDatesBannerInfo, ) val message = async { withTimeoutOrNull(5000) { diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt index 7196d7df3..5387d7965 100644 --- a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -21,7 +21,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.Mock +import org.openedx.core.CoreMocks import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences @@ -76,16 +76,6 @@ class CourseHomeViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" - private val courseStructure = Mock.mockCourseStructure.copy( - id = courseId, - name = courseTitle - ) - private val courseComponentStatus = Mock.mockCourseComponentStatus - private val courseDatesResult = Mock.mockCourseDatesResult - private val courseProgress = Mock.mockCourseProgress - private val videoProgress = Mock.mockVideoProgress - private val resetCourseDates = Mock.mockResetCourseDates - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -143,23 +133,26 @@ class CourseHomeViewModelTest { fun `getCourseData success`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure.copy( + id = courseId, + name = courseTitle + ) ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } - coEvery { interactor.getVideoProgress("video1") } returns videoProgress + } returns flow { emit(CoreMocks.mockCourseProgress) } + coEvery { interactor.getVideoProgress("video1") } returns CoreMocks.mockVideoProgress val viewModel = CourseHomeViewModel( courseId = courseId, @@ -192,7 +185,7 @@ class CourseHomeViewModelTest { val courseData = viewModel.uiState.value as CourseHomeUIState.CourseData assertEquals(courseId, courseData.courseStructure.id) assertEquals(courseTitle, courseData.courseStructure.name) - assertEquals(courseProgress, courseData.courseProgress) + assertEquals(CoreMocks.mockCourseProgress, courseData.courseProgress) } @Test @@ -205,17 +198,17 @@ class CourseHomeViewModelTest { } returns flow { throw UnknownHostException() } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -253,17 +246,17 @@ class CourseHomeViewModelTest { } returns flow { throw Exception() } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -297,22 +290,22 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -343,25 +336,25 @@ class CourseHomeViewModelTest { @Test fun `resetCourseDatesBanner success`() = runTest { - coEvery { interactor.resetCourseDates(courseId) } returns resetCourseDates + coEvery { interactor.resetCourseDates(courseId) } returns CoreMocks.mockResetCourseDates coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -403,22 +396,22 @@ class CourseHomeViewModelTest { coEvery { interactor.resetCourseDates(courseId) } throws UnknownHostException() coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -459,22 +452,25 @@ class CourseHomeViewModelTest { fun `logVideoClick analytics event`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure.copy( + id = courseId, + name = courseTitle + ) ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -517,22 +513,25 @@ class CourseHomeViewModelTest { fun `logAssignmentClick analytics event`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure.copy( + id = courseId, + name = courseTitle + ) ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -575,22 +574,22 @@ class CourseHomeViewModelTest { fun `viewCertificateTappedEvent analytics event`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -631,22 +630,22 @@ class CourseHomeViewModelTest { fun `getCourseProgress success`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, @@ -679,22 +678,22 @@ class CourseHomeViewModelTest { fun `CourseStructureUpdated notifier event`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } every { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated(courseId)) } @@ -727,22 +726,22 @@ class CourseHomeViewModelTest { fun `CourseOpenBlock notifier event`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } every { courseNotifier.notifier } returns flow { emit(CourseOpenBlock("test-block-id")) } @@ -773,22 +772,22 @@ class CourseHomeViewModelTest { fun `CourseProgressLoaded notifier event`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } every { courseNotifier.notifier } returns flow { emit(CourseProgressLoaded) } @@ -823,22 +822,22 @@ class CourseHomeViewModelTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { emit( - courseStructure + CoreMocks.mockCourseStructure ) } coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { emit( - courseComponentStatus + CoreMocks.mockCourseComponentStatus ) } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } coEvery { interactor.getCourseProgress( courseId, false, true ) - } returns flow { emit(courseProgress) } + } returns flow { emit(CoreMocks.mockCourseProgress) } val viewModel = CourseHomeViewModel( courseId = courseId, 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 62fc097b7..381e09948 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 @@ -28,27 +28,14 @@ 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.CoreMocks import org.openedx.core.R import org.openedx.core.config.Config -import org.openedx.core.data.model.DateType import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.AssignmentProgress -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseComponentStatus -import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.domain.model.CourseDatesBannerInfo -import org.openedx.core.domain.model.CourseDatesResult -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.DatesSection import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao -import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity -import org.openedx.core.module.db.DownloadedState -import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent @@ -63,7 +50,6 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseOutlineViewModelTest { @@ -92,142 +78,6 @@ class CourseOutlineViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" - private val assignmentProgress = AssignmentProgress( - assignmentType = "Homework", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HW1", - ) - - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ) - ) - - 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, - progress = null - ) - - private val dateBlock = CourseDateBlock( - complete = false, - date = Date(), - dateType = DateType.TODAY_DATE, - description = "Mocked Course Date Description" - ) - private val mockDateBlocks = linkedMapOf( - Pair( - DatesSection.COMPLETED, - listOf(dateBlock, dateBlock) - ), - Pair( - DatesSection.PAST_DUE, - listOf(dateBlock, dateBlock) - ), - Pair( - DatesSection.TODAY, - listOf(dateBlock, dateBlock) - ) - ) - private val mockCourseDatesBannerInfo = CourseDatesBannerInfo( - missedDeadlines = true, - missedGatedContent = false, - verifiedUpgradeLink = "", - contentTypeGatingEnabled = false, - hasEnded = true, - ) - private val mockedCourseDatesResult = CourseDatesResult( - datesSection = mockDateBlocks, - courseBanner = mockCourseDatesBannerInfo, - ) - - private val downloadModel = DownloadModel( - "id", - "title", - "", - 0, - "", - "url", - FileType.VIDEO, - DownloadedState.NOT_DOWNLOADED, - null - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -240,8 +90,8 @@ class CourseOutlineViewModelTest { every { downloadDialogManager.showDownloadFailedPopup(any(), any()) } returns Unit every { preferencesManager.isRelativeDatesEnabled } returns true - coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult - coEvery { interactor.getCourseDatesFlow(any()) } returns flowOf(mockedCourseDatesResult) + coEvery { interactor.getCourseDates(any()) } returns CoreMocks.mockCourseDatesResult + coEvery { interactor.getCourseDatesFlow(any()) } returns flowOf(CoreMocks.mockCourseDatesResult) } @After @@ -253,7 +103,7 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( - courseStructure + CoreMocks.mockCourseStructure ) every { networkConnection.isOnline() } returns true every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } @@ -307,7 +157,9 @@ class CourseOutlineViewModelTest { @Suppress("TooGenericExceptionThrown") @Test fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + CoreMocks.mockCourseStructure + ) every { networkConnection.isOnline() } returns true every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw Exception() } @@ -347,14 +199,14 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( - courseStructure + CoreMocks.mockCourseStructure ) every { networkConnection.isOnline() } returns true coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( - downloadModel + CoreMocks.mockDownloadModel ) ) ) @@ -401,14 +253,14 @@ class CourseOutlineViewModelTest { fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( - courseStructure + CoreMocks.mockCourseStructure ) every { networkConnection.isOnline() } returns false coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( - downloadModel + CoreMocks.mockDownloadModel ) ) ) @@ -454,14 +306,14 @@ class CourseOutlineViewModelTest { fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( - courseStructure + CoreMocks.mockCourseStructure ) every { networkConnection.isOnline() } returns true coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( DownloadModelEntity.createFrom( - downloadModel + CoreMocks.mockDownloadModel ) ) ) @@ -506,7 +358,9 @@ class CourseOutlineViewModelTest { @Test fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + CoreMocks.mockCourseStructure + ) coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } every { networkConnection.isOnline() } returns true coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) @@ -545,8 +399,10 @@ class CourseOutlineViewModelTest { @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + CoreMocks.mockCourseStructure + ) every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true every { @@ -599,9 +455,9 @@ class CourseOutlineViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( - courseStructure + CoreMocks.mockCourseStructure ) coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) 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 685311e9e..3f08ae795 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 @@ -23,20 +23,12 @@ 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.CoreMocks import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.AssignmentProgress -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.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao -import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity -import org.openedx.core.module.db.DownloadedState -import org.openedx.core.module.db.FileType import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier @@ -46,7 +38,6 @@ import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseSectionViewModelTest { @@ -70,110 +61,6 @@ class CourseSectionViewModelTest { private val somethingWrong = "Something went wrong" private val cantDownload = "You can download content only from Wi-fi" - private val assignmentProgress = AssignmentProgress( - assignmentType = "Homework", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HW1", - ) - - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id1", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = listOf("id2"), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id2", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.VERTICAL, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = emptyList(), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ) - ) - - 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, - progress = null - ) - - private val downloadModel = DownloadModel( - "id", - "title", - "", - 0, - "", - "url", - FileType.VIDEO, - DownloadedState.NOT_DOWNLOADED, - null - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -242,7 +129,7 @@ class CourseSectionViewModelTest { @Test fun `getBlocks success`() = runTest { coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + emit(listOf(DownloadModelEntity.createFrom(CoreMocks.mockDownloadModel))) } val viewModel = CourseSectionViewModel( "", @@ -253,10 +140,10 @@ class CourseSectionViewModelTest { ) coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + emit(listOf(DownloadModelEntity.createFrom(CoreMocks.mockDownloadModel))) } - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.getBlocks("id", CourseViewMode.VIDEOS) advanceUntilIdle() @@ -271,7 +158,7 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels test`() = runTest { coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + emit(listOf(DownloadModelEntity.createFrom(CoreMocks.mockDownloadModel))) } val viewModel = CourseSectionViewModel( "", @@ -293,7 +180,7 @@ class CourseSectionViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + emit(listOf(DownloadModelEntity.createFrom(CoreMocks.mockDownloadModel))) } val viewModel = CourseSectionViewModel( "", @@ -329,8 +216,8 @@ class CourseSectionViewModelTest { ) coEvery { notifier.notifier } returns flow { } - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index fb8ac2920..9c4f71685 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -18,20 +18,14 @@ 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.CoreMocks import org.openedx.core.config.Config import org.openedx.core.domain.helper.VideoPreviewHelper -import org.openedx.core.domain.model.AssignmentProgress -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.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import java.net.UnknownHostException -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseUnitContainerViewModelTest { @@ -48,118 +42,6 @@ class CourseUnitContainerViewModelTest { private val networkConnection = mockk() private val videoPreviewHelper = mockk() - private val assignmentProgress = AssignmentProgress( - assignmentType = "Homework", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HW1", - ) - - private val blocks = listOf( - Block( - id = "id", - 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", "id1"), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id1", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.VERTICAL, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = listOf("id2", "id"), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id2", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Block", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(0), - descendants = emptyList(), - descendantsType = BlockType.HTML, - completion = 0.0, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - Block( - id = "id3", - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ) - - ) - - 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, - progress = null - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -234,8 +116,8 @@ class CourseUnitContainerViewModelTest { videoPreviewHelper ) - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.loadBlocks() @@ -259,8 +141,8 @@ class CourseUnitContainerViewModelTest { networkConnection, videoPreviewHelper ) - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.loadBlocks("id") advanceUntilIdle() @@ -283,8 +165,8 @@ class CourseUnitContainerViewModelTest { networkConnection, videoPreviewHelper ) - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.loadBlocks("id") @@ -309,8 +191,8 @@ class CourseUnitContainerViewModelTest { networkConnection, videoPreviewHelper ) - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.loadBlocks("id3") @@ -335,8 +217,8 @@ class CourseUnitContainerViewModelTest { networkConnection, videoPreviewHelper ) - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.loadBlocks("id1") @@ -361,8 +243,8 @@ class CourseUnitContainerViewModelTest { networkConnection, videoPreviewHelper ) - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.loadBlocks("id3") @@ -387,8 +269,8 @@ class CourseUnitContainerViewModelTest { networkConnection, videoPreviewHelper ) - coEvery { interactor.getCourseStructure("") } returns courseStructure - coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure + coEvery { interactor.getCourseStructure("") } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos("") } returns CoreMocks.mockCourseStructure viewModel.loadBlocks("id") @@ -413,8 +295,8 @@ class CourseUnitContainerViewModelTest { networkConnection, videoPreviewHelper ) - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure viewModel.loadBlocks("id3") 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 e8a16c151..7e546dea9 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 @@ -27,23 +27,15 @@ 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.CoreMocks import org.openedx.core.config.Config import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.helper.VideoPreviewHelper -import org.openedx.core.domain.model.AssignmentProgress -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.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao -import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity -import org.openedx.core.module.db.DownloadedState -import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager @@ -57,7 +49,6 @@ import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil -import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseVideoViewModelTest { @@ -84,113 +75,9 @@ class CourseVideoViewModelTest { private val cantDownload = "You can download content only from Wi-fi" - private val assignmentProgress = AssignmentProgress( - assignmentType = "Homework", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HW1", - ) - - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ) - ) - - 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, - progress = null - ) - private val downloadModelEntity = DownloadModelEntity("", "", "", 1, "", "", "VIDEO", "DOWNLOADED", null) - private val downloadModel = DownloadModel( - "id", - "title", - "", - 0, - "", - "url", - FileType.VIDEO, - DownloadedState.NOT_DOWNLOADED, - null - ) - @Before fun setUp() { every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload @@ -228,7 +115,7 @@ class CourseVideoViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) - } returns courseStructure.copy(blockData = emptyList()) + } returns CoreMocks.mockCourseStructure.copy(blockData = emptyList()) every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( @@ -261,7 +148,7 @@ class CourseVideoViewModelTest { @Test fun `getVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure every { downloadDao.getAllDataFlow() } returns flow { repeat(5) { delay(10000) @@ -303,7 +190,7 @@ class CourseVideoViewModelTest { @Test fun `updateVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } @@ -348,7 +235,7 @@ class CourseVideoViewModelTest { fun `setIsUpdating success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } advanceUntilIdle() } @@ -357,7 +244,7 @@ class CourseVideoViewModelTest { fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", @@ -377,7 +264,7 @@ class CourseVideoViewModelTest { workerController, downloadHelper, ) - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true @@ -399,7 +286,7 @@ class CourseVideoViewModelTest { runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", @@ -419,13 +306,13 @@ class CourseVideoViewModelTest { workerController, downloadHelper, ) - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + emit(listOf(DownloadModelEntity.createFrom(CoreMocks.mockDownloadModel))) } every { coreAnalytics.logEvent(any(), any()) } returns Unit val message = async { @@ -446,7 +333,7 @@ class CourseVideoViewModelTest { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure val viewModel = CourseVideoViewModel( "", config, @@ -468,7 +355,7 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } coEvery { workerController.saveModels(any()) } returns Unit val message = async { diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index c0967b5d0..7fd50fa79 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -72,14 +72,7 @@ import coil.request.ImageRequest import org.koin.androidx.compose.koinViewModel import org.openedx.Lock import org.openedx.core.R -import org.openedx.core.domain.model.Certificate -import org.openedx.core.domain.model.CourseAssignments -import org.openedx.core.domain.model.CourseSharingUtmParameters -import org.openedx.core.domain.model.CourseStatus -import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse -import org.openedx.core.domain.model.EnrolledCourseData -import org.openedx.core.domain.model.Progress import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog @@ -95,6 +88,7 @@ import org.openedx.core.utils.TimeUtils import org.openedx.courses.presentation.AllEnrolledCoursesFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.courses.presentation.AllEnrolledCoursesFragment.Companion.MOBILE_GRID_COLUMNS import org.openedx.courses.presentation.AllEnrolledCoursesFragment.Companion.TABLET_GRID_COLUMNS +import org.openedx.dashboard.DashboardMocks import org.openedx.dashboard.domain.CourseStatusFilter import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.UIMessage @@ -553,7 +547,7 @@ fun EmptyState( private fun CourseItemPreview() { OpenEdXTheme { CourseItem( - course = mockCourseEnrolled, + course = DashboardMocks.enrolledCourse, apiHostUrl = "", onClick = {} ) @@ -580,14 +574,7 @@ private fun AllEnrolledCoursesPreview() { AllEnrolledCoursesView( apiHostUrl = "http://localhost:8000", state = AllEnrolledCoursesUIState( - courses = listOf( - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled - ) + courses = DashboardMocks.enrolledCourses(1) ), uiMessage = null, hasInternetConnection = true, @@ -595,44 +582,3 @@ private fun AllEnrolledCoursesPreview() { ) } } - -private val mockCourseAssignments = CourseAssignments(null, emptyList()) -private val mockCourseEnrolled = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - progress = Progress.DEFAULT_PROGRESS, - courseStatus = CourseStatus("", emptyList(), "", ""), - courseAssignments = mockCourseAssignments, - course = EnrolledCourseData( - id = "id", - name = "name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - false, - "204", - "", - "", - "", - "" - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) -) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index c7108405a..78019e8fa 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -75,20 +75,8 @@ import coil.request.ImageRequest import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import org.openedx.Lock -import org.openedx.core.domain.model.AppConfig -import org.openedx.core.domain.model.Certificate -import org.openedx.core.domain.model.CourseAssignments -import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseEnrollments -import org.openedx.core.domain.model.CourseSharingUtmParameters -import org.openedx.core.domain.model.CourseStatus -import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.EnrolledCourse -import org.openedx.core.domain.model.EnrolledCourseData -import org.openedx.core.domain.model.Pagination -import org.openedx.core.domain.model.Progress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton @@ -101,6 +89,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils import org.openedx.courses.presentation.DashboardGalleryFragment.Companion.MOBILE_COURSE_LIST_ITEM_COUNT import org.openedx.courses.presentation.DashboardGalleryFragment.Companion.TABLET_COURSE_LIST_ITEM_COUNT +import org.openedx.dashboard.DashboardMocks import org.openedx.dashboard.R import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.UIMessage @@ -909,65 +898,6 @@ private fun NoCoursesInfo( } } -private val mockCourseDateBlock = CourseDateBlock( - title = "Homework 1: ABCD", - description = "After this date, course content will be archived", - date = TimeUtils.iso8601ToDate("2024-05-31T15:08:07Z")!!, - assignmentType = "Homework" -) -private val mockCourseAssignments = - CourseAssignments(listOf(mockCourseDateBlock), listOf(mockCourseDateBlock, mockCourseDateBlock)) -private val mockCourse = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - progress = Progress.DEFAULT_PROGRESS, - courseStatus = CourseStatus("", emptyList(), "", "Unit name"), - courseAssignments = mockCourseAssignments, - course = EnrolledCourseData( - id = "id", - name = "Looooooooooooooooooooong Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "", - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) -) -private val mockPagination = Pagination(10, "", 4, "1") -private val mockDashboardCourseList = DashboardCourseList( - pagination = mockPagination, - courses = listOf(mockCourse, mockCourse, mockCourse, mockCourse, mockCourse, mockCourse) -) - -private val mockUserCourses = CourseEnrollments( - enrollments = mockDashboardCourseList, - configs = AppConfig(CourseDatesCalendarSync(true, true, true, true)), - primary = mockCourse -) - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -987,7 +917,7 @@ private fun ViewAllItemPreview() { private fun DashboardGalleryViewPreview() { OpenEdXTheme { DashboardGalleryView( - uiState = DashboardGalleryUIState.Courses(mockUserCourses, true), + uiState = DashboardGalleryUIState.Courses(DashboardMocks.courseEnrollments, true), apiHostUrl = "", uiMessage = null, updating = false, diff --git a/dashboard/src/main/java/org/openedx/dashboard/DashboardMocks.kt b/dashboard/src/main/java/org/openedx/dashboard/DashboardMocks.kt new file mode 100644 index 000000000..3585f1cb8 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/DashboardMocks.kt @@ -0,0 +1,105 @@ +package org.openedx.dashboard + +import org.openedx.core.data.model.DateType +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseEnrollments +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DashboardCourseList +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Pagination +import org.openedx.core.domain.model.Progress +import java.util.Date + +object DashboardMocks { + private val courseDateBlock = CourseDateBlock( + complete = false, + date = Date(), + dateType = DateType.NONE, + description = "Assignment due" + ) + + private val courseAssignments = CourseAssignments( + futureAssignments = listOf(courseDateBlock, courseDateBlock), + pastAssignments = listOf(courseDateBlock) + ) + + private val courseData = EnrolledCourseData( + id = "courseId", + name = "Introduction to Testing", + number = "CS101", + org = "OpenEdX", + start = Date(), + startDisplay = "Jan 01", + startType = "", + end = Date(), + dynamicUpgradeDeadline = "", + subscriptionId = "", + coursewareAccess = CoursewareAccess( + hasAccess = true, + errorCode = "", + developerMessage = "", + userMessage = "", + userFragment = "", + additionalContextUserMessage = "" + ), + media = null, + courseImage = "", + courseAbout = "", + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + videoOutline = "", + isSelfPaced = false + ) + + val enrolledCourse = EnrolledCourse( + auditAccessExpires = Date(), + created = "created", + mode = "audit", + isActive = true, + course = courseData, + certificate = null, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus( + lastVisitedModuleId = "", + lastVisitedModulePath = emptyList(), + lastVisitedBlockId = "", + lastVisitedUnitDisplayName = "Unit name" + ), + courseAssignments = courseAssignments + ) + + fun enrolledCourses(count: Int) = List(count) { enrolledCourse } + + private val pagination = Pagination( + count = 10, + next = "", + numPages = 4, + previous = "1" + ) + + val dashboardCourseList = DashboardCourseList( + pagination = pagination, + courses = enrolledCourses(6) + ) + + val courseEnrollments = CourseEnrollments( + enrollments = dashboardCourseList, + configs = AppConfig( + courseDatesCalendarSync = CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = true + ) + ), + primary = enrolledCourse + ) +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 55f995a01..780d52569 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -73,14 +73,7 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.domain.model.Certificate -import org.openedx.core.domain.model.CourseAssignments -import org.openedx.core.domain.model.CourseSharingUtmParameters -import org.openedx.core.domain.model.CourseStatus -import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse -import org.openedx.core.domain.model.EnrolledCourseData -import org.openedx.core.domain.model.Progress import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.displayCutoutForLandscape @@ -90,6 +83,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils +import org.openedx.dashboard.DashboardMocks import org.openedx.dashboard.R import org.openedx.dashboard.presentation.DashboardListFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.foundation.extension.toImageLink @@ -513,7 +507,7 @@ private fun CourseItemPreview() { OpenEdXTheme { CourseItem( "http://localhost:8000", - mockCourseEnrolled, + DashboardMocks.enrolledCourse, WindowSize(WindowType.Compact, WindowType.Compact), onClick = {} ) @@ -529,14 +523,7 @@ private fun DashboardListViewPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( - listOf( - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled - ) + DashboardMocks.enrolledCourses(1) ), uiMessage = null, onSwipeRefresh = {}, @@ -559,14 +546,7 @@ private fun DashboardListViewTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( - listOf( - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled, - mockCourseEnrolled - ) + DashboardMocks.enrolledCourses(1) ), uiMessage = null, onSwipeRefresh = {}, @@ -600,44 +580,3 @@ private fun EmptyStatePreview() { ) } } - -private val mockCourseAssignments = CourseAssignments(null, emptyList()) -private val mockCourseEnrolled = EnrolledCourse( - auditAccessExpires = Date(), - created = "created", - certificate = Certificate(""), - mode = "mode", - isActive = true, - progress = Progress.DEFAULT_PROGRESS, - courseStatus = CourseStatus("", emptyList(), "", ""), - courseAssignments = mockCourseAssignments, - course = EnrolledCourseData( - id = "id", - name = "name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - dynamicUpgradeDeadline = "", - subscriptionId = "", - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - courseImage = "", - courseAbout = "", - courseSharingUtmParameters = CourseSharingUtmParameters("", ""), - courseUpdates = "", - courseHandouts = "", - discussionUrl = "", - videoOutline = "", - isSelfPaced = false - ) -) diff --git a/discovery/src/main/java/org/openedx/discovery/DiscoveryMocks.kt b/discovery/src/main/java/org/openedx/discovery/DiscoveryMocks.kt new file mode 100644 index 000000000..f2e543aa1 --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/DiscoveryMocks.kt @@ -0,0 +1,32 @@ +package org.openedx.discovery + +import org.openedx.core.domain.model.Media +import org.openedx.discovery.domain.model.Course + +object DiscoveryMocks { + val course = Course( + id = "id", + blocksUrl = "blocksUrl", + courseId = "courseId", + effort = "effort", + enrollmentStart = null, + enrollmentEnd = null, + hidden = false, + invitationOnly = false, + media = Media(), + mobileAvailable = true, + name = "Test course", + number = "number", + org = "EdX", + pacing = "pacing", + shortDescription = "shortDescription", + start = "start", + end = "end", + startDisplay = "startDisplay", + startType = "startType", + overview = "", + isEnrolled = false + ) + + fun courses(count: Int) = List(count) { course } +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 2212849b5..6f0337d09 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -57,7 +57,6 @@ 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.openedx.core.domain.model.Media import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -70,6 +69,7 @@ import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography +import org.openedx.discovery.DiscoveryMocks import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.NativeDiscoveryFragment.Companion.LOAD_MORE_THRESHOLD @@ -440,7 +440,7 @@ private fun CourseItemPreview() { OpenEdXTheme { DiscoveryCourseItem( apiHostUrl = "", - course = mockCourse, + course = DiscoveryMocks.course, windowSize = WindowSize(WindowType.Compact, WindowType.Compact), onClick = {} ) @@ -455,17 +455,7 @@ private fun DiscoveryScreenPreview() { DiscoveryScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), state = DiscoveryUIState.Courses( - listOf( - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - ) + DiscoveryMocks.courses(1) ), uiMessage = null, apiHostUrl = "", @@ -496,17 +486,7 @@ private fun DiscoveryScreenTabletPreview() { DiscoveryScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), state = DiscoveryUIState.Courses( - listOf( - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - mockCourse, - ) + DiscoveryMocks.courses(1) ), uiMessage = null, apiHostUrl = "", @@ -528,27 +508,3 @@ private fun DiscoveryScreenTabletPreview() { ) } } - -private val mockCourse = Course( - id = "id", - blocksUrl = "blocksUrl", - courseId = "courseId", - effort = "effort", - enrollmentStart = null, - enrollmentEnd = null, - hidden = false, - invitationOnly = false, - media = Media(), - mobileAvailable = true, - name = "Test course", - number = "number", - org = "EdX", - pacing = "pacing", - shortDescription = "shortDescription", - start = "start", - end = "end", - startDisplay = "startDisplay", - startType = "startType", - overview = "", - isEnrolled = false -) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index d49f9e1c4..8e4ba7fb9 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -79,7 +79,6 @@ 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.domain.model.Media import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog @@ -92,6 +91,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.EmailUtil +import org.openedx.discovery.DiscoveryMocks import org.openedx.discovery.R import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryRouter @@ -690,7 +690,7 @@ private fun CourseDetailNativeContentPreview() { OpenEdXTheme { CourseDetailsScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = CourseDetailsUIState.CourseData(mockCourse), + uiState = CourseDetailsUIState.CourseData(DiscoveryMocks.course), uiMessage = null, apiHostUrl = "http://localhost:8000", hasInternetConnection = false, @@ -713,7 +713,7 @@ private fun CourseDetailNativeContentTabletPreview() { OpenEdXTheme { CourseDetailsScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = CourseDetailsUIState.CourseData(mockCourse), + uiState = CourseDetailsUIState.CourseData(DiscoveryMocks.course), uiMessage = null, apiHostUrl = "http://localhost:8000", hasInternetConnection = false, @@ -728,27 +728,3 @@ private fun CourseDetailNativeContentTabletPreview() { ) } } - -private val mockCourse = Course( - id = "id", - blocksUrl = "blocksUrl", - courseId = "courseId", - effort = "effort", - enrollmentStart = null, - enrollmentEnd = null, - hidden = false, - invitationOnly = false, - media = Media(), - mobileAvailable = true, - name = "Test course", - number = "number", - org = "EdX", - pacing = "pacing", - shortDescription = "shortDescription", - start = "start", - end = "end", - startDisplay = "startDisplay", - startType = "startType", - overview = "", - isEnrolled = false -) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt index a38420a5e..77f6aec83 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt @@ -63,7 +63,6 @@ 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.openedx.core.domain.model.Media import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -73,7 +72,7 @@ import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.discovery.domain.model.Course +import org.openedx.discovery.DiscoveryMocks import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.search.CourseSearchFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.discovery.presentation.ui.DiscoveryCourseItem @@ -430,7 +429,7 @@ fun CourseSearchScreenPreview() { OpenEdXTheme { CourseSearchScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - state = CourseSearchUIState.Courses(listOf(mockCourse, mockCourse), 2), + state = CourseSearchUIState.Courses(DiscoveryMocks.courses(2), 2), uiMessage = null, apiHostUrl = "", canLoadMore = false, @@ -456,7 +455,7 @@ fun CourseSearchScreenTabletPreview() { OpenEdXTheme { CourseSearchScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - state = CourseSearchUIState.Courses(listOf(mockCourse, mockCourse), 2), + state = CourseSearchUIState.Courses(DiscoveryMocks.courses(2), 2), uiMessage = null, apiHostUrl = "", canLoadMore = false, @@ -474,27 +473,3 @@ fun CourseSearchScreenTabletPreview() { ) } } - -private val mockCourse = Course( - id = "id", - blocksUrl = "blocksUrl", - courseId = "courseId", - effort = "effort", - enrollmentStart = null, - enrollmentEnd = null, - hidden = false, - invitationOnly = false, - media = Media(), - mobileAvailable = true, - name = "Test course", - number = "number", - org = "EdX", - pacing = "pacing", - shortDescription = "shortDescription", - start = "start", - end = "end", - startDisplay = "startDisplay", - startType = "startType", - overview = "", - isEnrolled = false -) diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt index 13c1f3895..2c9f282b3 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt @@ -24,13 +24,12 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.Media import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.discovery.DiscoveryMocks import org.openedx.discovery.domain.interactor.DiscoveryInteractor -import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import org.openedx.foundation.presentation.UIMessage @@ -57,30 +56,6 @@ class CourseDetailsViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - private val mockCourse = Course( - id = "id", - blocksUrl = "blocksUrl", - courseId = "courseId", - effort = "effort", - enrollmentStart = null, - enrollmentEnd = null, - hidden = false, - invitationOnly = false, - media = Media(), - mobileAvailable = true, - name = "Test course", - number = "number", - org = "EdX", - pacing = "pacing", - shortDescription = "shortDescription", - start = "start", - end = "end", - startDisplay = "startDisplay", - startType = "startType", - overview = "", - isEnrolled = false - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -216,7 +191,7 @@ class CourseDetailsViewModelTest { coEvery { interactor.enrollInACourse(any()) } throws UnknownHostException() coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseDetails(any()) } returns mockCourse + coEvery { interactor.getCourseDetails(any()) } returns DiscoveryMocks.course every { analytics.logEvent(any(), any()) } returns Unit viewModel.enrollInACourse("", "") @@ -248,7 +223,7 @@ class CourseDetailsViewModelTest { coEvery { interactor.enrollInACourse(any()) } throws Exception() coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseDetails(any()) } returns mockCourse + coEvery { interactor.getCourseDetails(any()) } returns DiscoveryMocks.course every { analytics.logEvent( DiscoveryAnalyticsEvent.COURSE_ENROLL_CLICKED.eventName, @@ -302,7 +277,7 @@ class CourseDetailsViewModelTest { coEvery { interactor.enrollInACourse(any()) } returns Unit coEvery { notifier.send(CourseDashboardUpdate()) } returns Unit every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseDetails(any()) } returns mockCourse + coEvery { interactor.getCourseDetails(any()) } returns DiscoveryMocks.course delay(200) viewModel.enrollInACourse("", "") diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt index 150d02e3e..392923eb2 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt @@ -22,10 +22,9 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.Media import org.openedx.core.domain.model.Pagination +import org.openedx.discovery.DiscoveryMocks import org.openedx.discovery.domain.interactor.DiscoveryInteractor -import org.openedx.discovery.domain.model.Course import org.openedx.discovery.domain.model.CourseList import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.foundation.presentation.UIMessage @@ -49,34 +48,6 @@ class CourseSearchViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - //region course - - private val mockCourse = Course( - id = "id", - blocksUrl = "blocksUrl", - courseId = "courseId", - effort = "effort", - enrollmentStart = null, - enrollmentEnd = null, - hidden = false, - invitationOnly = false, - media = Media(), - mobileAvailable = true, - name = "Test course", - number = "number", - org = "EdX", - pacing = "pacing", - shortDescription = "shortDescription", - start = "start", - end = "end", - startDisplay = "startDisplay", - startType = "startType", - overview = "", - false - ) - - //endregion - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -175,14 +146,14 @@ class CourseSearchViewModelTest { 5, "" ), - listOf(mockCourse, mockCourse) + DiscoveryMocks.courses(2) ) coEvery { interactor.getCoursesListByQuery( any(), not(1) ) - } returns CourseList(Pagination(10, "", 5, ""), listOf(mockCourse)) + } returns CourseList(Pagination(10, "", 5, ""), listOf(DiscoveryMocks.course)) every { analytics.discoveryCourseSearchEvent(any(), any()) } returns Unit viewModel.search("course") @@ -211,14 +182,14 @@ class CourseSearchViewModelTest { 5, "" ), - listOf(mockCourse, mockCourse) + DiscoveryMocks.courses(2) ) coEvery { interactor.getCoursesListByQuery( any(), not(1) ) - } returns CourseList(Pagination(10, "0", 5, ""), listOf(mockCourse)) + } returns CourseList(Pagination(10, "0", 5, ""), listOf(DiscoveryMocks.course)) every { analytics.discoveryCourseSearchEvent(any(), any()) } returns Unit viewModel.search("course") @@ -248,7 +219,7 @@ class CourseSearchViewModelTest { 5, "" ), - listOf(mockCourse, mockCourse) + DiscoveryMocks.courses(2) ) viewModel.updateSearchQuery() diff --git a/discussion/src/main/java/org/openedx/discussion/DiscussionMocks.kt b/discussion/src/main/java/org/openedx/discussion/DiscussionMocks.kt new file mode 100644 index 000000000..50ab5f27a --- /dev/null +++ b/discussion/src/main/java/org/openedx/discussion/DiscussionMocks.kt @@ -0,0 +1,76 @@ +package org.openedx.discussion + +import org.openedx.core.domain.model.ProfileImage +import org.openedx.discussion.domain.model.DiscussionComment +import org.openedx.discussion.domain.model.DiscussionProfile +import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.discussion.domain.model.Thread +import org.openedx.discussion.domain.model.Topic + +object DiscussionMocks { + val topic = Topic( + id = "topic-id", + name = "Mock Topic", + threadListUrl = "", + children = emptyList() + ) + + val thread = Thread( + id = "thread-id", + author = "Preview Author", + authorLabel = "staff", + createdAt = "2024-01-01", + updatedAt = "2024-01-02", + rawBody = "Preview thread body", + renderedBody = "Preview thread body", + abuseFlagged = false, + voted = false, + voteCount = 0, + editableFields = emptyList(), + canDelete = false, + courseId = "course-id", + topicId = "topic-id", + groupId = "0", + groupName = "", + type = DiscussionType.DISCUSSION, + previewBody = "Preview thread body", + abuseFlaggedCount = "0", + title = "Preview Thread Title", + pinned = false, + closed = false, + following = false, + commentCount = 3, + unreadCommentCount = 2, + read = false, + hasEndorsed = false, + users = null, + responseCount = 2, + anonymous = false, + anonymousToPeers = false + ) + + val comment = DiscussionComment( + id = "comment-id", + author = "Preview Commenter", + authorLabel = "staff", + createdAt = "2024-01-01", + updatedAt = "2024-01-02", + rawBody = "Preview comment", + renderedBody = "Preview comment", + abuseFlagged = false, + voted = false, + voteCount = 0, + editableFields = emptyList(), + canDelete = false, + threadId = "thread-id", + parentId = "", + endorsed = false, + endorsedBy = "", + endorsedByLabel = "", + endorsedAt = "", + childCount = 0, + children = emptyList(), + profileImage = ProfileImage("", "", "", "", false), + users = mapOf("Preview Commenter" to DiscussionProfile(ProfileImage("", "", "", "", false))) + ) +} diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index 5bbee6ff9..46f3eab18 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -71,7 +71,6 @@ 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.domain.model.ProfileImage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -81,9 +80,9 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.R import org.openedx.discussion.domain.model.DiscussionComment -import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.discussion.presentation.ui.CommentItem @@ -388,7 +387,11 @@ private fun DiscussionCommentsScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallBack() } if (!isSystemInDarkTheme()) { @@ -495,8 +498,8 @@ private fun DiscussionCommentsScreenPreview() { DiscussionCommentsScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionCommentsUIState.Success( - mockThread, - listOf(mockComment, mockComment), + DiscussionMocks.thread, + listOf(DiscussionMocks.comment, DiscussionMocks.comment), 2 ), uiMessage = null, @@ -522,8 +525,8 @@ private fun DiscussionCommentsScreenTabletPreview() { DiscussionCommentsScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionCommentsUIState.Success( - mockThread, - listOf(mockComment, mockComment), + DiscussionMocks.thread, + listOf(DiscussionMocks.comment, DiscussionMocks.comment), 2 ), uiMessage = null, @@ -540,62 +543,3 @@ private fun DiscussionCommentsScreenTabletPreview() { ) } } - -private val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 10, - false, - false -) - -private val mockComment = DiscussionComment( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - false, - "", - "", - "", - 21, - emptyList(), - profileImage = ProfileImage("", "", "", "", false), - mapOf() -) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index 863cc89ef..171d5ff31 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -75,7 +75,6 @@ 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.domain.model.ProfileImage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -85,6 +84,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.R import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.presentation.DiscussionRouter @@ -532,10 +532,10 @@ private fun DiscussionResponsesScreenPreview() { DiscussionResponsesScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), uiState = DiscussionResponsesUIState.Success( - mockComment, + DiscussionMocks.comment, listOf( - mockComment, - mockComment + DiscussionMocks.comment, + DiscussionMocks.comment ) ), uiMessage = null, @@ -560,10 +560,10 @@ private fun DiscussionResponsesScreenTabletPreview() { DiscussionResponsesScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = DiscussionResponsesUIState.Success( - mockComment, + DiscussionMocks.comment, listOf( - mockComment, - mockComment + DiscussionMocks.comment, + DiscussionMocks.comment ) ), uiMessage = null, @@ -579,28 +579,3 @@ private fun DiscussionResponsesScreenTabletPreview() { ) } } - -private val mockComment = DiscussionComment( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - false, - "", - "", - "", - 21, - emptyList(), - ProfileImage("", "", "", "", false), - mapOf() -) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt index e67fe40b3..6e69f2a4f 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt @@ -68,7 +68,7 @@ import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.search.DiscussionSearchThreadFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.discussion.presentation.ui.ThreadItem @@ -330,6 +330,7 @@ private fun DiscussionSearchThreadScreen( } } } + is DiscussionSearchThreadUIState.Threads -> { items(uiState.data) { thread -> ThreadItem(thread = thread, onClick = onItemClick) @@ -347,7 +348,11 @@ private fun DiscussionSearchThreadScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallback() } } @@ -372,7 +377,10 @@ fun DiscussionSearchThreadScreenPreview() { OpenEdXTheme { DiscussionSearchThreadScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DiscussionSearchThreadUIState.Threads(listOf(mockThread, mockThread), 2), + uiState = DiscussionSearchThreadUIState.Threads( + listOf(DiscussionMocks.thread, DiscussionMocks.thread), + 2 + ), uiMessage = null, refreshing = false, canLoadMore = true, @@ -392,7 +400,10 @@ fun DiscussionSearchThreadScreenTabletPreview() { OpenEdXTheme { DiscussionSearchThreadScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DiscussionSearchThreadUIState.Threads(listOf(mockThread, mockThread), 2), + uiState = DiscussionSearchThreadUIState.Threads( + listOf(DiscussionMocks.thread, DiscussionMocks.thread), + 2 + ), uiMessage = null, refreshing = false, canLoadMore = true, @@ -404,37 +415,3 @@ fun DiscussionSearchThreadScreenTabletPreview() { ) } } - -private val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 10, - false, - false -) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index f610dfa9d..65a1f24bc 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -88,7 +88,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.discussion.presentation.ui.ThreadItem @@ -693,7 +693,13 @@ private fun DiscussionThreadsScreenPreview() { DiscussionThreadsScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), "All posts", - uiState = DiscussionThreadsUIState.Threads(listOf(mockThread, mockThread, mockThread)), + uiState = DiscussionThreadsUIState.Threads( + listOf( + DiscussionMocks.thread, + DiscussionMocks.thread, + DiscussionMocks.thread + ) + ), uiMessage = null, onItemClick = {}, onBackClick = {}, @@ -717,7 +723,13 @@ private fun DiscussionThreadsScreenTabletPreview() { DiscussionThreadsScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), "All posts", - uiState = DiscussionThreadsUIState.Threads(listOf(mockThread, mockThread, mockThread)), + uiState = DiscussionThreadsUIState.Threads( + listOf( + DiscussionMocks.thread, + DiscussionMocks.thread, + DiscussionMocks.thread + ) + ), uiMessage = null, onItemClick = {}, onBackClick = {}, @@ -732,37 +744,3 @@ private fun DiscussionThreadsScreenTabletPreview() { ) } } - -private val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 10, - false, - false -) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt index 1f4876eb4..a5ffd0614 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsScreen.kt @@ -48,8 +48,8 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.R -import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.ui.ThreadItemCategory import org.openedx.discussion.presentation.ui.TopicItem import org.openedx.foundation.presentation.UIMessage @@ -280,7 +280,12 @@ private fun DiscussionTopicsScreenPreview() { OpenEdXTheme { DiscussionTopicsUI( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), + uiState = DiscussionTopicsUIState.Topics( + listOf( + DiscussionMocks.topic, + DiscussionMocks.topic + ) + ), uiMessage = null, onItemClick = { _, _, _ -> }, onSearchClick = {} @@ -312,17 +317,15 @@ private fun DiscussionTopicsScreenTabletPreview() { OpenEdXTheme { DiscussionTopicsUI( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = DiscussionTopicsUIState.Topics(listOf(mockTopic, mockTopic)), + uiState = DiscussionTopicsUIState.Topics( + listOf( + DiscussionMocks.topic, + DiscussionMocks.topic + ) + ), uiMessage = null, onItemClick = { _, _, _ -> }, onSearchClick = {} ) } } - -private val mockTopic = Topic( - id = "", - name = "All Topics", - threadListUrl = "", - children = emptyList() -) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt index 1a544e40a..2527307af 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest -import org.openedx.core.domain.model.ProfileImage import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.IconText import org.openedx.core.ui.RenderHtmlContent @@ -53,6 +52,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.R import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType @@ -665,7 +665,7 @@ fun TopicItem( private fun TopicItemPreview() { OpenEdXTheme { TopicItem( - topic = mockTopic, + topic = DiscussionMocks.topic, onClick = { _, _ -> } ) } @@ -677,7 +677,7 @@ private fun TopicItemPreview() { private fun ThreadItemPreview() { OpenEdXTheme { ThreadItem( - thread = mockThread, + thread = DiscussionMocks.thread, onClick = {} ) } @@ -689,7 +689,7 @@ private fun CommentItemPreview() { OpenEdXTheme { CommentItem( modifier = Modifier.fillMaxWidth(), - comment = mockComment, + comment = DiscussionMocks.comment, onClick = { _, _, _ -> }, onUserPhotoClick = {} ) @@ -701,74 +701,8 @@ private fun CommentItemPreview() { private fun ThreadMainItemPreview() { ThreadMainItem( modifier = Modifier.fillMaxWidth(), - thread = mockThread, + thread = DiscussionMocks.thread, onClick = { _, _ -> }, onUserPhotoClick = {} ) } - -private val mockComment = DiscussionComment( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - false, - "", - "", - "", - 21, - emptyList(), - ProfileImage("", "", "", "", false), - mapOf() -) - -private val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 10, - false, - false -) - -private val mockTopic = Topic( - id = "", - name = "All Topics", - threadListUrl = "", - children = emptyList() -) diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt index f3a9704f5..74f940396 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt @@ -27,9 +27,9 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData -import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.system.notifier.DiscussionCommentAdded import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged @@ -58,75 +58,14 @@ class DiscussionCommentsViewModelTest { private val commentAddedSuccessfully = "Comment Successfully added" //region mockThread - - val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 0, - false, - false - ) - //endregion //region mockComment - - private val mockComment = DiscussionComment( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - false, - "", - "", - "", - 21, - emptyList(), - null, - mapOf() - ) - // endregion private val comments = listOf( - mockComment.copy(id = "0"), - mockComment.copy(id = "1") + DiscussionMocks.comment.copy(id = "0"), + DiscussionMocks.comment.copy(id = "1") ) @Before @@ -148,13 +87,13 @@ class DiscussionCommentsViewModelTest { @Test fun `getThreadComments no internet connection exception`() = runTest { coEvery { interactor.getThreadComments(any(), any()) } throws UnknownHostException() - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) advanceUntilIdle() @@ -177,7 +116,7 @@ class DiscussionCommentsViewModelTest { interactor, resourceManager, notifier, - mockThread.copy(type = DiscussionType.QUESTION) + DiscussionMocks.thread.copy(type = DiscussionType.QUESTION) ) coEvery { interactor.getThreadQuestionComments(any(), any(), any()) } throws Exception() @@ -199,7 +138,7 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread every { resourceManager.getString(eq(DiscussionType.QUESTION.resId)) } returns "" val viewModel = @@ -207,7 +146,7 @@ class DiscussionCommentsViewModelTest { interactor, resourceManager, notifier, - mockThread.copy(type = DiscussionType.QUESTION) + DiscussionMocks.thread.copy(type = DiscussionType.QUESTION) ) advanceUntilIdle() @@ -228,15 +167,15 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.getThreadQuestionComments(any(), any(), any()) } returns CommentsData( @@ -261,15 +200,15 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.getThreadQuestionComments(any(), any(), any()) } returns CommentsData( @@ -295,14 +234,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.getThreadQuestionComments(any(), any(), any()) } returns CommentsData( @@ -329,14 +268,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.getThreadComments(any(), eq(2)) } returns CommentsData( @@ -362,14 +301,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setThreadVoted(any(), any()) } throws UnknownHostException() @@ -390,15 +329,15 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setThreadVoted(any(), any()) } throws Exception() @@ -419,18 +358,18 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.setThreadVoted(any(), any()) } returns mockThread + coEvery { interactor.setThreadVoted(any(), any()) } returns DiscussionMocks.thread viewModel.setThreadUpvoted(true) advanceUntilIdle() @@ -447,15 +386,15 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setCommentFlagged(any(), any()) } throws UnknownHostException() @@ -476,15 +415,15 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setCommentFlagged(any(), any()) } throws Exception() @@ -505,17 +444,19 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.setCommentFlagged(any(), any()) } returns mockComment.copy(id = "0") + coEvery { interactor.setCommentFlagged(any(), any()) } returns DiscussionMocks.comment.copy( + id = "0" + ) viewModel.setCommentReported("", true) advanceUntilIdle() @@ -532,14 +473,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setCommentVoted(any(), any()) } throws UnknownHostException() @@ -560,14 +501,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setCommentVoted(any(), any()) } throws Exception() @@ -588,17 +529,22 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.setCommentVoted(any(), any()) } returns mockComment.copy(id = "0") + coEvery { + interactor.setCommentVoted( + any(), + any() + ) + } returns DiscussionMocks.comment.copy(id = "0") viewModel.setCommentUpvoted("", true) advanceUntilIdle() @@ -615,14 +561,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setThreadFlagged(any(), any()) } throws UnknownHostException() @@ -643,14 +589,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setThreadFlagged(any(), any()) } throws Exception() @@ -671,17 +617,17 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.setThreadFlagged(any(), any()) } returns mockThread + coEvery { interactor.setThreadFlagged(any(), any()) } returns DiscussionMocks.thread viewModel.setThreadReported(true) advanceUntilIdle() @@ -698,16 +644,16 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" - coEvery { interactor.setThreadRead(any()) } returns mockThread + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread coEvery { interactor.setThreadFollowed(any(), any()) } throws UnknownHostException() val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) viewModel.setThreadFollowed(true) @@ -726,14 +672,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.setThreadFollowed(any(), any()) } throws Exception() @@ -754,17 +700,17 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.setThreadFollowed(any(), any()) } returns mockThread + coEvery { interactor.setThreadFollowed(any(), any()) } returns DiscussionMocks.thread viewModel.setThreadFollowed(true) advanceUntilIdle() @@ -781,21 +727,21 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { notifier.notifier } returns flow { delay(100) - emit(DiscussionCommentAdded(mockComment)) + emit(DiscussionCommentAdded(DiscussionMocks.comment)) } - coEvery { notifier.send(DiscussionThreadDataChanged(mockThread)) } returns Unit + coEvery { notifier.send(DiscussionThreadDataChanged(DiscussionMocks.thread)) } returns Unit val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -814,19 +760,19 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { notifier.notifier } returns flow { delay(100) - emit(DiscussionCommentAdded(mockComment)) + emit(DiscussionCommentAdded(DiscussionMocks.comment)) } coEvery { notifier.send(DiscussionThreadDataChanged(mockk())) } returns Unit @@ -848,21 +794,21 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { notifier.notifier } returns flow { delay(100) - emit(DiscussionCommentDataChanged(mockComment.copy(id = "0"))) + emit(DiscussionCommentDataChanged(DiscussionMocks.comment.copy(id = "0"))) } - coEvery { notifier.send(DiscussionCommentDataChanged(mockComment)) } returns Unit + coEvery { notifier.send(DiscussionCommentDataChanged(DiscussionMocks.comment)) } returns Unit val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -881,14 +827,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.createComment(any(), any(), any()) } throws UnknownHostException() @@ -907,14 +853,14 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) coEvery { interactor.createComment(any(), any(), any()) } throws Exception() @@ -933,17 +879,17 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.createComment(any(), any(), any()) } returns mockComment + coEvery { interactor.createComment(any(), any(), any()) } returns DiscussionMocks.comment viewModel.createComment("") advanceUntilIdle() @@ -960,16 +906,16 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.createComment(any(), any(), any()) } returns mockComment + coEvery { interactor.createComment(any(), any(), any()) } returns DiscussionMocks.comment every { preferencesManager.user?.username } returns "" viewModel.createComment("") @@ -982,16 +928,16 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "2", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.createComment(any(), any(), any()) } returns mockComment + coEvery { interactor.createComment(any(), any(), any()) } returns DiscussionMocks.comment every { preferencesManager.user?.username } returns "" viewModel.createComment("") @@ -1006,16 +952,16 @@ class DiscussionCommentsViewModelTest { comments, Pagination(10, "", 4, "1") ) - coEvery { interactor.setThreadRead(any()) } returns mockThread - every { resourceManager.getString(eq(mockThread.type.resId)) } returns "" + coEvery { interactor.setThreadRead(any()) } returns DiscussionMocks.thread + every { resourceManager.getString(eq(DiscussionMocks.thread.type.resId)) } returns "" val viewModel = DiscussionCommentsViewModel( interactor, resourceManager, notifier, - mockThread + DiscussionMocks.thread ) - coEvery { interactor.createComment(any(), any(), any()) } returns mockComment + coEvery { interactor.createComment(any(), any(), any()) } returns DiscussionMocks.comment every { preferencesManager.user?.username } returns "" viewModel.createComment("") diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt index bb3579eda..90b83a448 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt @@ -22,9 +22,9 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData -import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -47,38 +47,9 @@ class DiscussionResponsesViewModelTest { private val somethingWrong = "Something went wrong" private val commentAddedSuccessfully = "Comment Successfully added" - //region mockComment - - private val mockComment = DiscussionComment( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - false, - "", - "", - "", - 21, - emptyList(), - null, - emptyMap() - ) - - //endregion - private val comments = listOf( - mockComment.copy(id = "0"), - mockComment.copy(id = "1") + DiscussionMocks.comment.copy(id = "0"), + DiscussionMocks.comment.copy(id = "1") ) @Before @@ -105,7 +76,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) advanceUntilIdle() @@ -125,7 +96,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) advanceUntilIdle() @@ -148,7 +119,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) advanceUntilIdle() @@ -171,7 +142,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) advanceUntilIdle() @@ -193,7 +164,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) viewModel.fetchMore() advanceUntilIdle() @@ -216,7 +187,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) coEvery { interactor.getCommentsResponses(any(), eq(2)) } returns CommentsData( comments, @@ -243,7 +214,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) coEvery { interactor.setCommentVoted(any(), any()) } throws UnknownHostException() viewModel.setCommentUpvoted("", false) @@ -265,7 +236,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) coEvery { interactor.setCommentVoted(any(), any()) } throws Exception() viewModel.setCommentUpvoted("", false) @@ -287,9 +258,14 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") - ) - coEvery { interactor.setCommentVoted(any(), any()) } returns mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") + ) + coEvery { + interactor.setCommentVoted( + any(), + any() + ) + } returns DiscussionMocks.comment.copy(id = "0") viewModel.updateCommentResponses() viewModel.setCommentUpvoted("", false) advanceUntilIdle() @@ -310,9 +286,14 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") - ) - coEvery { interactor.setCommentVoted(any(), any()) } returns mockComment.copy(id = "2") + DiscussionMocks.comment.copy(id = "0") + ) + coEvery { + interactor.setCommentVoted( + any(), + any() + ) + } returns DiscussionMocks.comment.copy(id = "2") viewModel.updateCommentResponses() viewModel.setCommentUpvoted("", false) advanceUntilIdle() @@ -333,7 +314,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) coEvery { interactor.setCommentFlagged(any(), any()) } throws UnknownHostException() viewModel.setCommentReported("", false) @@ -355,7 +336,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) coEvery { interactor.setCommentFlagged(any(), any()) } throws Exception() viewModel.setCommentReported("", false) @@ -377,9 +358,11 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") + ) + coEvery { interactor.setCommentFlagged(any(), any()) } returns DiscussionMocks.comment.copy( + id = "0" ) - coEvery { interactor.setCommentFlagged(any(), any()) } returns mockComment.copy(id = "0") viewModel.setCommentReported("", false) advanceUntilIdle() @@ -399,9 +382,11 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") + ) + coEvery { interactor.setCommentFlagged(any(), any()) } returns DiscussionMocks.comment.copy( + id = "0" ) - coEvery { interactor.setCommentFlagged(any(), any()) } returns mockComment.copy(id = "0") viewModel.updateCommentResponses() viewModel.setCommentReported("", false) @@ -423,7 +408,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) coEvery { interactor.createComment(any(), any(), any()) } throws UnknownHostException() @@ -446,7 +431,7 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) coEvery { interactor.createComment(any(), any(), any()) } throws Exception() @@ -469,9 +454,9 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) - coEvery { interactor.createComment(any(), any(), any()) } returns mockComment + coEvery { interactor.createComment(any(), any(), any()) } returns DiscussionMocks.comment viewModel.createComment("") advanceUntilIdle() @@ -492,9 +477,9 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) - coEvery { interactor.createComment(any(), any(), any()) } returns mockComment + coEvery { interactor.createComment(any(), any(), any()) } returns DiscussionMocks.comment every { preferencesManager.user?.username } returns "" viewModel.createComment("") @@ -513,9 +498,9 @@ class DiscussionResponsesViewModelTest { interactor, resourceManager, notifier, - mockComment.copy(id = "0") + DiscussionMocks.comment.copy(id = "0") ) - coEvery { interactor.createComment(any(), any(), any()) } returns mockComment + coEvery { interactor.createComment(any(), any(), any()) } returns DiscussionMocks.comment every { preferencesManager.user?.username } returns "" viewModel.createComment("") diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt index 14eb3f062..9817ea242 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt @@ -24,8 +24,8 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.domain.model.Pagination +import org.openedx.discussion.DiscussionMocks 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.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged @@ -48,44 +48,6 @@ class DiscussionSearchThreadViewModelTest { private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" - //region thread - - private val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 0, - false, - false - ) - - //endregion - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -171,7 +133,7 @@ class DiscussionSearchThreadViewModelTest { fun `search query success with next page and fetch`() = runTest { val viewModel = DiscussionSearchThreadViewModel(interactor, resourceManager, notifier, "") coEvery { interactor.searchThread(any(), any(), eq(1)) } returns ThreadsData( - listOf(mockThread, mockThread), + listOf(DiscussionMocks.thread, DiscussionMocks.thread), "", Pagination( 10, @@ -186,7 +148,7 @@ class DiscussionSearchThreadViewModelTest { any(), not(1) ) - } returns ThreadsData(listOf(mockThread), "", Pagination(10, "", 5, "")) + } returns ThreadsData(listOf(DiscussionMocks.thread), "", Pagination(10, "", 5, "")) viewModel.searchThreads("course") delay(1000) @@ -206,7 +168,7 @@ class DiscussionSearchThreadViewModelTest { fun `search query success with next page and fetch, update`() = runTest { val viewModel = DiscussionSearchThreadViewModel(interactor, resourceManager, notifier, "") coEvery { interactor.searchThread(any(), any(), eq(1)) } returns ThreadsData( - listOf(mockThread, mockThread), + listOf(DiscussionMocks.thread, DiscussionMocks.thread), "", Pagination( 10, @@ -221,7 +183,7 @@ class DiscussionSearchThreadViewModelTest { any(), not(1) ) - } returns ThreadsData(listOf(mockThread), "", Pagination(10, "0", 5, "")) + } returns ThreadsData(listOf(DiscussionMocks.thread), "", Pagination(10, "0", 5, "")) viewModel.searchThreads("course") delay(1000) @@ -242,7 +204,7 @@ class DiscussionSearchThreadViewModelTest { fun `search query update in empty state`() = runTest { val viewModel = DiscussionSearchThreadViewModel(interactor, resourceManager, notifier, "") coEvery { interactor.searchThread(any(), any(), eq(1)) } returns ThreadsData( - listOf(mockThread, mockThread), + listOf(DiscussionMocks.thread, DiscussionMocks.thread), "", Pagination( 10, @@ -271,7 +233,7 @@ class DiscussionSearchThreadViewModelTest { notifier.notifier } returns flow { delay(100) - emit(DiscussionThreadDataChanged(mockThread.copy(id = "1"))) + emit(DiscussionThreadDataChanged(DiscussionMocks.thread.copy(id = "1"))) } val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -289,7 +251,7 @@ class DiscussionSearchThreadViewModelTest { fun `notifier DiscussionThreadDataChanged with list`() = runTest { val viewModel = DiscussionSearchThreadViewModel(interactor, resourceManager, notifier, "") coEvery { interactor.searchThread(any(), any(), any()) } returns ThreadsData( - listOf(mockThread.copy(id = "id")), + listOf(DiscussionMocks.thread.copy(id = "id")), "", Pagination( 10, @@ -303,7 +265,7 @@ class DiscussionSearchThreadViewModelTest { notifier.notifier } returns flow { delay(1000) - emit(DiscussionThreadDataChanged(mockThread.copy(id = "id"))) + emit(DiscussionThreadDataChanged(DiscussionMocks.thread.copy(id = "id"))) } val mockLifeCycleOwner: LifecycleOwner = mockk() diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt index 65b4a1ae8..d46df5e53 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt @@ -19,9 +19,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.domain.model.DiscussionType -import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.foundation.presentation.UIMessage @@ -44,57 +43,17 @@ class DiscussionAddThreadViewModelTest { private val somethingWrong = "Something went wrong" //region mockThread - val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 0, - false, - false - ) //endregion //region mockTopic - private val mockTopic = Topic( - id = "", - name = "All Topics", - threadListUrl = "", - children = emptyList() - ) + //endregion val topics = listOf( - mockTopic.copy(id = "0"), - mockTopic.copy(id = "1"), - mockTopic.copy(id = "2") + DiscussionMocks.topic.copy(id = "0", name = "All Topics"), + DiscussionMocks.topic.copy(id = "1", name = "All Topics"), + DiscussionMocks.topic.copy(id = "2", name = "All Topics") ) - //endregion - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -170,7 +129,7 @@ class DiscussionAddThreadViewModelTest { any(), any() ) - } returns mockThread + } returns DiscussionMocks.thread viewModel.createThread("", "", "", "", false) advanceUntilIdle() 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 15e49570d..ecb7e5f53 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 @@ -26,8 +26,9 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.domain.model.Pagination +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.domain.model.DiscussionType +import org.openedx.discussion.domain.model.Thread import org.openedx.discussion.domain.model.ThreadsData import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.discussion.system.notifier.DiscussionNotifier @@ -53,47 +54,12 @@ class DiscussionThreadsViewModelTest { private val somethingWrong = "Something went wrong" //region mockThread - - val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 0, - false, - false - ) - //endregion - private val threads = listOf( - mockThread.copy(id = "0"), - mockThread.copy(id = "1"), - mockThread.copy(id = "2") + private val threads = listOf( + DiscussionMocks.thread.copy(id = "0"), + DiscussionMocks.thread.copy(id = "1"), + DiscussionMocks.thread.copy(id = "2") ) @Before @@ -513,7 +479,13 @@ class DiscussionThreadsViewModelTest { notifier.notifier } returns flow { delay(100) - emit(DiscussionThreadDataChanged(mockThread.copy(id = "1"))) + emit( + DiscussionThreadDataChanged( + DiscussionMocks.thread.copy( + id = "1", + ) + ) + ) } val viewModel = DiscussionThreadsViewModel( interactor, 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 4241976c6..3a180c7ab 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 @@ -26,8 +26,8 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor -import org.openedx.discussion.domain.model.Topic import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.foundation.presentation.UIMessage @@ -50,13 +50,6 @@ class DiscussionTopicsViewModelTest { private val noInternet = "Slow or no internet connection" - private val mockTopic = Topic( - id = "", - name = "All Topics", - threadListUrl = "", - children = emptyList() - ) - @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -133,7 +126,10 @@ class DiscussionTopicsViewModelTest { router ) - coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) + coEvery { interactor.getCourseTopics(any()) } returns listOf( + DiscussionMocks.topic, + DiscussionMocks.topic + ) advanceUntilIdle() val message = async { withTimeoutOrNull(5000) { @@ -209,7 +205,10 @@ class DiscussionTopicsViewModelTest { router ) - coEvery { interactor.getCourseTopics(any()) } returns listOf(mockTopic, mockTopic) + coEvery { interactor.getCourseTopics(any()) } returns listOf( + DiscussionMocks.topic, + DiscussionMocks.topic + ) val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt index e633368b3..ae060851c 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -70,6 +70,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest +import org.openedx.core.CoreMocks import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.extension.safeDivBy import org.openedx.core.module.db.DownloadModel @@ -554,8 +555,8 @@ private fun DownloadsScreenPreview() { private fun CourseItemPreview() { OpenEdXTheme { CourseItem( - downloadCoursePreview = DownloadCoursePreview("", "name", "", 100), - downloadModels = emptyList(), + downloadCoursePreview = CoreMocks.coursePreview, + downloadModels = listOf(CoreMocks.mockDownloadModel), apiHostUrl = "", downloadedState = DownloadedState.NOT_DOWNLOADED, onCourseClick = {}, diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index c57445b42..42506c7a3 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -23,22 +23,14 @@ 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.CoreMocks import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.AssignmentProgress -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.domain.model.DownloadCoursePreview import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao -import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity -import org.openedx.core.module.db.DownloadedState -import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.DownloadsAnalytics @@ -53,7 +45,6 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException -import java.util.Date class DownloadsViewModelTest { @@ -90,108 +81,6 @@ class DownloadsViewModelTest { image = "", totalSize = DownloadDialogManager.MAX_CELLULAR_SIZE.toLong() ) - private val assignmentProgress = AssignmentProgress( - assignmentType = "Homework", - numPointsEarned = 1f, - numPointsPossible = 3f, - shortLabel = "HW1", - ) - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ), - 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, - assignmentProgress = assignmentProgress, - due = Date(), - offlineDownload = null, - ) - ) - - private val downloadModel = DownloadModel( - "id", - "title", - "", - 0, - "", - "url", - FileType.VIDEO, - DownloadedState.NOT_DOWNLOADED, - null - ) - - 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, - progress = null - ) @OptIn(ExperimentalCoroutinesApi::class) @Before @@ -205,13 +94,13 @@ class DownloadsViewModelTest { coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { emit(listOf(downloadCoursePreview)) } - coEvery { interactor.getCourseStructureFromCache("course1") } returns courseStructure - coEvery { interactor.getCourseStructure("course1") } returns courseStructure + coEvery { interactor.getCourseStructureFromCache("course1") } returns CoreMocks.mockCourseStructure + coEvery { interactor.getCourseStructure("course1") } returns CoreMocks.mockCourseStructure coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns emptyList() coEvery { downloadDao.getAllDataFlow() } returns flowOf( listOf( DownloadModelEntity.createFrom( - downloadModel + CoreMocks.mockDownloadModel ) ) ) @@ -323,7 +212,7 @@ class DownloadsViewModelTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `removeDownloads should show remove popup with correct parameters`() = runTest { - coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns listOf(downloadModel) + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns listOf(CoreMocks.mockDownloadModel) val viewModel = DownloadsViewModel( downloadsRouter, diff --git a/profile/src/main/java/org/openedx/profile/ProfileMocks.kt b/profile/src/main/java/org/openedx/profile/ProfileMocks.kt new file mode 100644 index 000000000..046fdf94b --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/ProfileMocks.kt @@ -0,0 +1,50 @@ +package org.openedx.profile + +import org.openedx.core.domain.model.AgreementUrls +import org.openedx.core.domain.model.LanguageProficiency +import org.openedx.core.domain.model.ProfileImage +import org.openedx.core.presentation.global.AppData +import org.openedx.profile.domain.model.Account +import org.openedx.profile.domain.model.Configuration + +object ProfileMocks { + val account = Account( + username = "jdoe", + name = "John Doe", + bio = "Preview user", + requiresParentalConsent = false, + country = "US", + isActive = true, + profileImage = ProfileImage( + imageUrlFull = "", + imageUrlLarge = "", + imageUrlMedium = "", + imageUrlSmall = "", + hasImage = false + ), + yearOfBirth = 1990, + levelOfEducation = "Bachelor", + goals = "Learning Kotlin", + languageProficiencies = emptyList(), + gender = "Male", + mailingAddress = "", + email = "jdoe@example.com", + dateJoined = null, + accountPrivacy = Account.Privacy.ALL_USERS + ) + + val appData = AppData( + appName = "OpenEdX", + applicationId = "org.edx.mobile", + versionName = "1.0.0" + ) + + val configuration = Configuration( + agreementUrls = AgreementUrls( + eulaUrl = "https://example.com/eula" + ), + faqUrl = "https://example.com/faq", + supportEmail = "support@example.com", + versionName = "1.0.0" + ) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt index 6a5061723..489695eb2 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt @@ -41,7 +41,6 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.R -import org.openedx.core.domain.model.ProfileImage import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.statusBarsInset @@ -53,7 +52,7 @@ import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.profile.domain.model.Account +import org.openedx.profile.ProfileMocks import org.openedx.profile.presentation.ui.ProfileInfoSection import org.openedx.profile.presentation.ui.ProfileTopic @@ -219,7 +218,7 @@ private fun ProfileScreenPreview() { OpenEdXTheme { AnothersProfileScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = AnothersProfileUIState.Data(mockAccount), + uiState = AnothersProfileUIState.Data(ProfileMocks.account), uiMessage = null, onBackClick = {} ) @@ -233,29 +232,9 @@ private fun ProfileScreenTabletPreview() { OpenEdXTheme { AnothersProfileScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = AnothersProfileUIState.Data(mockAccount), + uiState = AnothersProfileUIState.Data(ProfileMocks.account), uiMessage = null, onBackClick = {} ) } } - -private val mockAccount = Account( - username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper " + - "questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", - requiresParentalConsent = true, - name = "Thomas", - country = "Ukraine", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "Bachelor", - goals = "130", - languageProficiencies = emptyList(), - gender = "male", - mailingAddress = "", - "", - null, - accountPrivacy = Account.Privacy.ALL_USERS -) diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 8f9a3fd14..abc042aff 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -106,7 +106,6 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.AppDataConstants.DEFAULT_MIME_TYPE import org.openedx.core.domain.model.LanguageProficiency -import org.openedx.core.domain.model.ProfileImage import org.openedx.core.domain.model.RegistrationField import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.BackBtn @@ -133,6 +132,7 @@ import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.ProfileMocks import org.openedx.profile.R import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.edit.EditProfileFragment.Companion.LEAVE_PROFILE_WIDTH_FACTOR @@ -1284,7 +1284,7 @@ private fun EditProfileScreenPreview() { OpenEdXTheme { EditProfileScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = EditProfileUIState(account = mockAccount, isUpdating = false, false), + uiState = EditProfileUIState(account = ProfileMocks.account, isUpdating = false, false), selectedImageUri = null, uiMessage = null, isImageDeleted = true, @@ -1307,7 +1307,7 @@ private fun EditProfileScreenTabletPreview() { OpenEdXTheme { EditProfileScreen( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = EditProfileUIState(account = mockAccount, isUpdating = false, false), + uiState = EditProfileUIState(account = ProfileMocks.account, isUpdating = false, false), selectedImageUri = null, uiMessage = null, isImageDeleted = true, @@ -1322,22 +1322,3 @@ private fun EditProfileScreenTabletPreview() { ) } } - -private val mockAccount = Account( - username = "thom84", - bio = "designer", - requiresParentalConsent = true, - name = "Thomas", - country = "Ukraine", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "Bachelor", - goals = "130", - languageProficiencies = emptyList(), - gender = "male", - mailingAddress = "", - "", - null, - accountPrivacy = Account.Privacy.ALL_USERS -) diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt index 3873f8c5c..92a86155e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/compose/ManageAccountView.kt @@ -53,9 +53,9 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.ProfileMocks import org.openedx.profile.presentation.manageaccount.ManageAccountUIState import org.openedx.profile.presentation.ui.ProfileTopic -import org.openedx.profile.presentation.ui.mockAccount import org.openedx.profile.R as ProfileR @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @@ -213,7 +213,9 @@ private fun ManageAccountViewPreview() { OpenEdXTheme { ManageAccountView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = mockUiState, + uiState = ManageAccountUIState.Data( + account = ProfileMocks.account + ), uiMessage = null, refreshing = false, onAction = {} @@ -228,7 +230,9 @@ private fun ManageAccountViewTabletPreview() { OpenEdXTheme { ManageAccountView( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = mockUiState, + uiState = ManageAccountUIState.Data( + account = ProfileMocks.account + ), uiMessage = null, refreshing = false, onAction = {} @@ -236,10 +240,6 @@ private fun ManageAccountViewTabletPreview() { } } -private val mockUiState = ManageAccountUIState.Data( - account = mockAccount -) - internal interface ManageAccountViewAction { object EditAccountClick : ManageAccountViewAction object SwipeRefresh : ManageAccountViewAction diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt index e897b37c6..975939902 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -48,10 +48,10 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.profile.ProfileMocks import org.openedx.profile.presentation.profile.ProfileUIState import org.openedx.profile.presentation.ui.ProfileInfoSection import org.openedx.profile.presentation.ui.ProfileTopic -import org.openedx.profile.presentation.ui.mockAccount @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable @@ -178,7 +178,9 @@ private fun ProfileScreenPreview() { OpenEdXTheme { ProfileView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = mockUiState, + uiState = ProfileUIState.Data( + account = ProfileMocks.account + ), uiMessage = null, refreshing = false, onAction = {}, @@ -194,7 +196,9 @@ private fun ProfileScreenTabletPreview() { OpenEdXTheme { ProfileView( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = mockUiState, + uiState = ProfileUIState.Data( + account = ProfileMocks.account + ), uiMessage = null, refreshing = false, onAction = {}, @@ -203,10 +207,6 @@ private fun ProfileScreenTabletPreview() { } } -private val mockUiState = ProfileUIState.Data( - account = mockAccount -) - internal interface ProfileViewAction { object EditAccountClick : ProfileViewAction object SwipeRefresh : ProfileViewAction diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index 6122775bf..f949291aa 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -52,8 +52,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import org.openedx.core.AppUpdateState import org.openedx.core.R -import org.openedx.core.domain.model.AgreementUrls -import org.openedx.core.presentation.global.AppData import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar @@ -67,7 +65,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.profile.domain.model.Configuration +import org.openedx.profile.ProfileMocks import org.openedx.profile.presentation.ui.SettingsDivider import org.openedx.profile.presentation.ui.SettingsItem import org.openedx.profile.R as profileR @@ -622,29 +620,12 @@ fun AppVersionItemUpgradeRequired( } } -private val mockAppData = AppData( - appName = "openedx", - versionName = "1.0.0", - applicationId = "org.example.com" -) - -private val mockConfiguration = Configuration( - agreementUrls = AgreementUrls(), - faqUrl = "https://example.com/faq", - supportEmail = "test@example.com", - versionName = mockAppData.versionName, -) - -private val mockUiState = SettingsUIState.Data( - configuration = mockConfiguration -) - @Preview @Composable private fun AppVersionItemAppToDatePreview() { OpenEdXTheme { AppVersionItem( - versionName = mockAppData.versionName, + versionName = ProfileMocks.appData.versionName, appUpgradeEvent = null, onClick = {} ) @@ -656,7 +637,7 @@ private fun AppVersionItemAppToDatePreview() { private fun AppVersionItemUpgradeRecommendedPreview() { OpenEdXTheme { AppVersionItem( - versionName = mockAppData.versionName, + versionName = ProfileMocks.appData.versionName, appUpgradeEvent = AppUpgradeEvent.UpgradeRecommendedEvent("1.0.1"), onClick = {} ) @@ -668,7 +649,7 @@ private fun AppVersionItemUpgradeRecommendedPreview() { private fun AppVersionItemUpgradeRequiredPreview() { OpenEdXTheme { AppVersionItem( - versionName = mockAppData.versionName, + versionName = ProfileMocks.appData.versionName, appUpgradeEvent = AppUpgradeEvent.UpgradeRequiredEvent, onClick = {} ) @@ -688,7 +669,9 @@ private fun SettingsScreenPreview() { SettingsScreen( onBackClick = {}, windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiState = mockUiState, + uiState = SettingsUIState.Data( + configuration = ProfileMocks.configuration + ), onAction = {}, ) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt index c87afd492..dcaa2f945 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/ProfileUI.kt @@ -25,11 +25,11 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.core.R -import org.openedx.core.domain.model.ProfileImage import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.ProfileMocks import org.openedx.profile.domain.model.Account import org.openedx.profile.R as ProfileR @@ -116,35 +116,15 @@ fun ProfileInfoSection(account: Account) { } } -val mockAccount = Account( - username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper " + - "questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", - requiresParentalConsent = true, - name = "Thomas", - country = "Ukraine", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "Bachelor", - goals = "130", - languageProficiencies = emptyList(), - gender = "male", - mailingAddress = "", - "example@email.com", - null, - accountPrivacy = Account.Privacy.ALL_USERS -) - @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ProfileTopicPreview() { OpenEdXTheme { ProfileTopic( - image = mockAccount.profileImage.imageUrlFull, - title = mockAccount.name, - subtitle = mockAccount.username, + image = ProfileMocks.account.profileImage.imageUrlFull, + title = ProfileMocks.account.name, + subtitle = ProfileMocks.account.username, ) } } @@ -155,7 +135,7 @@ private fun ProfileTopicPreview() { private fun ProfileInfoSectionPreview() { OpenEdXTheme { ProfileInfoSection( - account = mockAccount + account = ProfileMocks.account ) } } diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index 9ea2f1d5f..131ec237b 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -21,11 +21,10 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.config.Config -import org.openedx.core.domain.model.ProfileImage import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager +import org.openedx.profile.ProfileMocks import org.openedx.profile.domain.interactor.ProfileInteractor -import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.system.notifier.account.AccountUpdated import org.openedx.profile.system.notifier.profile.ProfileNotifier @@ -46,25 +45,6 @@ class EditProfileViewModelTest { private val analytics = mockk() private val config = mockk() - private val account = Account( - username = "thom84", - bio = "He as compliment unreserved projecting. Between had observe pretend delight for believe. Do newspaper " + - "questions consulted sweetness do. Our sportsman his unwilling fulfilled departure law.", - requiresParentalConsent = true, - name = "Thomas", - country = "Ukraine", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "Bachelor", - goals = "130", - languageProficiencies = emptyList(), - gender = "male", - mailingAddress = "", - "", - null, - accountPrivacy = Account.Privacy.ALL_USERS - ) private val file = mockk() private val noInternet = "Slow or no internet connection" @@ -86,7 +66,14 @@ class EditProfileViewModelTest { @Test fun `updateAccount no internet connection`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) + EditProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + ProfileMocks.account + ) coEvery { interactor.updateAccount(any()) } throws UnknownHostException() viewModel.updateAccount(emptyMap()) advanceUntilIdle() @@ -101,7 +88,14 @@ class EditProfileViewModelTest { @Test fun `updateAccount unknown exception`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) + EditProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + ProfileMocks.account + ) coEvery { interactor.updateAccount(any()) } throws Exception() viewModel.updateAccount(emptyMap()) @@ -117,8 +111,15 @@ class EditProfileViewModelTest { @Test fun `updateAccount success`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) - coEvery { interactor.updateAccount(any()) } returns account + EditProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + ProfileMocks.account + ) + coEvery { interactor.updateAccount(any()) } returns ProfileMocks.account coEvery { notifier.send(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit viewModel.updateAccount(emptyMap()) @@ -134,9 +135,16 @@ class EditProfileViewModelTest { @Test fun `updateAccountAndImage no internet connection`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) + EditProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + ProfileMocks.account + ) coEvery { interactor.setProfileImage(any(), any()) } throws UnknownHostException() - coEvery { interactor.updateAccount(any()) } returns account + coEvery { interactor.updateAccount(any()) } returns ProfileMocks.account coEvery { notifier.send(AccountUpdated()) } returns Unit viewModel.updateAccountAndImage(emptyMap(), file, "") @@ -154,9 +162,16 @@ class EditProfileViewModelTest { @Test fun `updateAccountAndImage unknown exception`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) + EditProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + ProfileMocks.account + ) coEvery { interactor.setProfileImage(any(), any()) } throws Exception() - coEvery { interactor.updateAccount(any()) } returns account + coEvery { interactor.updateAccount(any()) } returns ProfileMocks.account coEvery { notifier.send(AccountUpdated()) } returns Unit viewModel.updateAccountAndImage(emptyMap(), file, "") @@ -174,9 +189,16 @@ class EditProfileViewModelTest { @Test fun `updateAccountAndImage success`() = runTest { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) + EditProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + ProfileMocks.account + ) coEvery { interactor.setProfileImage(any(), any()) } returns Unit - coEvery { interactor.updateAccount(any()) } returns account + coEvery { interactor.updateAccount(any()) } returns ProfileMocks.account coEvery { notifier.send(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit @@ -196,7 +218,14 @@ class EditProfileViewModelTest { @Test fun `setImageUri set new value`() { val viewModel = - EditProfileViewModel(interactor, resourceManager, notifier, analytics, config, account) + EditProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + ProfileMocks.account + ) viewModel.setImageUri(mockk()) assert(viewModel.selectedImageUri.value != null) diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt index 8f7fdf53a..fa0b67bdc 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt @@ -19,10 +19,11 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.domain.model.ProfileImage import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager +import org.openedx.profile.ProfileMocks import org.openedx.profile.domain.interactor.ProfileInteractor +import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileUIState import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel import java.net.UnknownHostException @@ -39,25 +40,6 @@ class AnothersProfileViewModelTest { private val interactor = mockk() private val username = "username" - private val account = org.openedx.profile.domain.model.Account( - username = "", - bio = "", - requiresParentalConsent = false, - name = "", - country = "", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "", - goals = "", - languageProficiencies = emptyList(), - gender = "", - mailingAddress = "", - email = "", - dateJoined = null, - accountPrivacy = org.openedx.profile.domain.model.Account.Privacy.PRIVATE - ) - private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -114,7 +96,9 @@ class AnothersProfileViewModelTest { resourceManager, username ) - coEvery { interactor.getAccount(username) } returns account + coEvery { interactor.getAccount(username) } returns ProfileMocks.account.copy( + accountPrivacy = Account.Privacy.PRIVATE + ) advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount(username) } diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index 0d11e9c8a..6ffcf1355 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -26,9 +26,9 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.domain.model.AgreementUrls -import org.openedx.core.domain.model.ProfileImage import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager +import org.openedx.profile.ProfileMocks import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter @@ -51,25 +51,6 @@ class ProfileViewModelTest { private val analytics = mockk() private val router = mockk() - private val account = org.openedx.profile.domain.model.Account( - username = "", - bio = "", - requiresParentalConsent = false, - name = "", - country = "", - isActive = true, - profileImage = ProfileImage("", "", "", "", false), - yearOfBirth = 2000, - levelOfEducation = "", - goals = "", - languageProficiencies = emptyList(), - gender = "", - mailingAddress = "", - email = "", - dateJoined = null, - accountPrivacy = org.openedx.profile.domain.model.Account.Privacy.PRIVATE - ) - private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -118,7 +99,9 @@ class ProfileViewModelTest { analytics, router ) - coEvery { interactor.getCachedAccount() } returns account + coEvery { interactor.getCachedAccount() } returns ProfileMocks.account.copy( + accountPrivacy = org.openedx.profile.domain.model.Account.Privacy.PRIVATE + ) coEvery { interactor.getAccount() } throws UnknownHostException() advanceUntilIdle() @@ -159,7 +142,9 @@ class ProfileViewModelTest { router ) coEvery { interactor.getCachedAccount() } returns null - coEvery { interactor.getAccount() } returns account + coEvery { interactor.getAccount() } returns ProfileMocks.account.copy( + accountPrivacy = org.openedx.profile.domain.model.Account.Privacy.PRIVATE + ) advanceUntilIdle() coVerify(exactly = 1) { interactor.getAccount() } From 3db6518031752c30dec60bb3089a2770225a110a Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:20:59 +0200 Subject: [PATCH 05/16] feat: html unit file chooser (#471) --- .../unit/html/HtmlUnitFragment.kt | 102 +++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt index 471918622..bb16f7ed6 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitFragment.kt @@ -1,21 +1,27 @@ package org.openedx.course.presentation.unit.html import android.annotation.SuppressLint +import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Intent import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri +import android.os.Build import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup import android.webkit.JavascriptInterface +import android.webkit.ValueCallback +import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -76,6 +82,15 @@ class HtmlUnitFragment : Fragment() { private var offlineUrl: String = "" private var lastModified: String = "" private var fromDownloadedContent: Boolean = false + private var filePathCallback: ValueCallback>? = null + + private val fileChooserLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val uris = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data) + ?: extractUrisFromResult(result.resultCode, result.data) + filePathCallback?.onReceiveValue(uris) + filePathCallback = null + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -97,11 +112,84 @@ class HtmlUnitFragment : Fragment() { blockUrl = blockUrl, offlineUrl = offlineUrl, fromDownloadedContent = fromDownloadedContent, - isFragmentAdded = isAdded + isFragmentAdded = isAdded, + onShowFileChooser = ::openFileChooser ) } } + override fun onDestroyView() { + filePathCallback?.onReceiveValue(null) + filePathCallback = null + super.onDestroyView() + } + + private fun openFileChooser( + callback: ValueCallback>, + fileChooserParams: WebChromeClient.FileChooserParams?, + ): Boolean { + filePathCallback?.onReceiveValue(null) + filePathCallback = callback + val intent = try { + fileChooserParams?.createIntent() + } catch (_: Exception) { + null + } ?: Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + val mimeTypes = fileChooserParams?.acceptTypes + ?.filter { it.isNotBlank() } + ?.toTypedArray() + if (!mimeTypes.isNullOrEmpty()) { + type = mimeTypes.first() + putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + } else { + type = "*/*" + } + putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, + fileChooserParams?.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE + ) + } + + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (intent.action == Intent.ACTION_CHOOSER) { + val extraIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + extraIntent?.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + return try { + fileChooserLauncher.launch(intent) + true + } catch (_: ActivityNotFoundException) { + filePathCallback?.onReceiveValue(null) + filePathCallback = null + false + } + } + + private fun extractUrisFromResult(resultCode: Int, data: Intent?): Array? { + if (resultCode != Activity.RESULT_OK || data == null) return null + + val clipUris = data.clipData?.let { clipData -> + (0 until clipData.itemCount).mapNotNull { clipData.getItemAt(it)?.uri } + }.orEmpty() + + val singleUri = data.data + + val result = when { + clipUris.isNotEmpty() -> clipUris.toTypedArray() + singleUri != null -> arrayOf(singleUri) + else -> null + } + + return result + } + companion object { private const val ARG_BLOCK_ID = "blockId" private const val ARG_COURSE_ID = "courseId" @@ -135,6 +223,7 @@ fun HtmlUnitView( offlineUrl: String, fromDownloadedContent: Boolean, isFragmentAdded: Boolean, + onShowFileChooser: (ValueCallback>, WebChromeClient.FileChooserParams?) -> Boolean, ) { OpenEdXTheme { val context = LocalContext.current @@ -216,6 +305,7 @@ fun HtmlUnitView( saveXBlockProgress = { jsonProgress -> viewModel.saveXBlockProgress(jsonProgress) }, + onShowFileChooser = onShowFileChooser ) } else { viewModel.onWebPageLoadError() @@ -257,6 +347,7 @@ private fun HTMLContentView( onWebPageLoaded: () -> Unit, onWebPageLoadError: () -> Unit, saveXBlockProgress: (String) -> Unit, + onShowFileChooser: (ValueCallback>, WebChromeClient.FileChooserParams?) -> Boolean, ) { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -299,6 +390,15 @@ private fun HTMLContentView( ), "AndroidBridge" ) + webChromeClient = object : WebChromeClient() { + override fun onShowFileChooser( + view: WebView?, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams? + ): Boolean { + return onShowFileChooser(filePathCallback, fileChooserParams) + } + } webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { From a0c0c928943f74187d2aeda45e110f7117d8e20b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:55:58 +0200 Subject: [PATCH 06/16] feat: Google calendar sync (#470) * feat: google calendar sync * fix: deleting not courses events * fix: switching between local and google calendars. Google calendars options. --- .../app/data/storage/PreferencesManager.kt | 14 + .../signin/SignInViewModelTest.kt | 2 + .../core/data/storage/CalendarPreferences.kt | 3 + .../domain/interactor/CalendarInteractor.kt | 4 + .../openedx/core/domain/model/CalendarType.kt | 6 + .../openedx/core/domain/model/UserCalendar.kt | 7 + .../org/openedx/core/module/db/CalendarDao.kt | 3 + .../core/repository/CalendarRepository.kt | 4 + .../openedx/core/system/CalendarManager.kt | 532 +++++++++++++----- .../calendar/CalendarViewModel.kt | 12 +- .../DisableCalendarSyncDialogFragment.kt | 77 ++- .../DisableCalendarSyncDialogViewModel.kt | 37 +- .../calendar/NewCalendarDialogFragment.kt | 266 +++++++-- .../calendar/NewCalendarDialogViewModel.kt | 66 ++- profile/src/main/res/values/strings.xml | 6 + 15 files changed, 851 insertions(+), 188 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/domain/model/CalendarType.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 3c8ea881e..1cc4c9662 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -8,6 +8,7 @@ import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CalendarType import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings import org.openedx.core.system.CalendarManager @@ -69,6 +70,7 @@ class PreferencesManager(context: Context) : override fun clearCalendarPreferences() { sharedPreferences.edit().apply { remove(CALENDAR_ID) + remove(CALENDAR_TYPE) remove(IS_CALENDAR_SYNC_ENABLED) remove(HIDE_INACTIVE_COURSES) }.apply() @@ -104,6 +106,17 @@ class PreferencesManager(context: Context) : } get() = getLong(CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) + override var calendarType: CalendarType + set(value) { + saveString(CALENDAR_TYPE, value.name) + } + get() { + val storedType = getString(CALENDAR_TYPE, CalendarType.LOCAL.name) + return runCatching { + CalendarType.valueOf(storedType) + }.getOrDefault(CalendarType.LOCAL) + } + override var user: User? set(value) { val userJson = Gson().toJson(value) @@ -234,6 +247,7 @@ class PreferencesManager(context: Context) : private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" private const val APP_CONFIG = "app_config" private const val CALENDAR_ID = "CALENDAR_ID" + private const val CALENDAR_TYPE = "CALENDAR_TYPE" private const val RESET_APP_DIRECTORY = "reset_app_directory" private const val IS_CALENDAR_SYNC_ENABLED = "IS_CALENDAR_SYNC_ENABLED" private const val IS_RELATIVE_DATES_ENABLED = "IS_RELATIVE_DATES_ENABLED" diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index 4a0db245c..dee9bde38 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -37,6 +37,7 @@ import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CalendarType import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier @@ -91,6 +92,7 @@ class SignInViewModelTest { every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() every { calendarPreferences.calendarUser } returns "" + every { calendarPreferences.calendarType } returns CalendarType.LOCAL every { calendarPreferences.clearCalendarPreferences() } returns Unit coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit every { analytics.logScreenEvent(any(), any()) } returns Unit diff --git a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt index 91e38b35c..57724d8f0 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt @@ -1,8 +1,11 @@ package org.openedx.core.data.storage +import org.openedx.core.domain.model.CalendarType + interface CalendarPreferences { var calendarId: Long var calendarUser: String + var calendarType: CalendarType var isCalendarSyncEnabled: Boolean var isHideInactiveCourses: Boolean diff --git a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt index da84dba1a..91935ad61 100644 --- a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt +++ b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt @@ -22,6 +22,10 @@ class CalendarInteractor( return repository.getCourseCalendarEventsByIdFromCache(courseId) } + suspend fun getAllCourseCalendarEventsFromCache(): List { + return repository.getAllCourseCalendarEventsFromCache() + } + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { repository.deleteCourseCalendarEntitiesByIdFromCache(courseId) } diff --git a/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt b/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt new file mode 100644 index 000000000..327a9f651 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +enum class CalendarType { + LOCAL, + GOOGLE, +} diff --git a/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt b/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt new file mode 100644 index 000000000..ed40a38db --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class UserCalendar( + val id: Long, + val title: String, + val color: Int +) diff --git a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt index 686009b92..bc14efa88 100644 --- a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -21,6 +21,9 @@ interface CalendarDao { @Query("SELECT * FROM course_calendar_event_table WHERE course_id=:courseId") suspend fun readCourseCalendarEventsById(courseId: String): List + @Query("SELECT * FROM course_calendar_event_table") + suspend fun readAllCourseCalendarEvents(): List + @Query("DELETE FROM course_calendar_event_table") suspend fun clearCourseCalendarEventsCachedData() diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt index 726709d8a..f2555584d 100644 --- a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -30,6 +30,10 @@ class CalendarRepository( return calendarDao.readCourseCalendarEventsById(courseId).map { it.mapToDomain() } } + suspend fun getAllCourseCalendarEventsFromCache(): List { + return calendarDao.readAllCourseCalendarEvents().map { it.mapToDomain() } + } + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { calendarDao.deleteCourseCalendarEntitiesById(courseId) } diff --git a/core/src/main/java/org/openedx/core/system/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt index c1a393767..431112641 100644 --- a/core/src/main/java/org/openedx/core/system/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -3,6 +3,7 @@ package org.openedx.core.system import android.content.ContentUris import android.content.ContentValues import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.net.Uri @@ -11,9 +12,13 @@ import androidx.core.content.ContextCompat import io.branch.indexing.BranchUniversalObject import io.branch.referral.util.ContentMetadata import io.branch.referral.util.LinkProperties +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CalendarData +import org.openedx.core.domain.model.CalendarType import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.UserCalendar import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar import java.util.TimeZone @@ -25,6 +30,8 @@ class CalendarManager( ) { private val logger = Logger(TAG) + private data class CalendarAccount(val name: String, val type: String) + val permissions = arrayOf( android.Manifest.permission.WRITE_CALENDAR, android.Manifest.permission.READ_CALENDAR @@ -33,16 +40,10 @@ class CalendarManager( val accountName: String get() = getUserAccountForSync() - /** - * Check if the app has the calendar READ/WRITE permissions or not - */ fun hasPermissions(): Boolean = permissions.all { PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(context, it) } - /** - * Check if the calendar is already existed in mobile calendar app or not - */ fun isCalendarExist(calendarId: Long): Boolean { val projection = arrayOf(CalendarContract.Calendars._ID) val selection = "${CalendarContract.Calendars._ID} = ?" @@ -62,109 +63,255 @@ class CalendarManager( return exists } - /** - * Create or update the calendar if it is already existed in mobile calendar app - */ fun createOrUpdateCalendar( calendarId: Long = CALENDAR_DOES_NOT_EXIST, calendarTitle: String, - calendarColor: Long + calendarColor: Long, + calendarType: CalendarType ): Long { - if (calendarId != CALENDAR_DOES_NOT_EXIST) { + if (calendarId != CALENDAR_DOES_NOT_EXIST && calendarType == CalendarType.LOCAL) { deleteCalendar(calendarId = calendarId) } return createCalendar( calendarTitle = calendarTitle, - calendarColor = calendarColor + calendarColor = calendarColor, + calendarType = calendarType ) } - /** - * Method to create a separate calendar based on course name in mobile calendar app - */ private fun createCalendar( calendarTitle: String, - calendarColor: Long + calendarColor: Long, + calendarType: CalendarType ): Long { - val contentValues = ContentValues() - contentValues.put(CalendarContract.Calendars.NAME, calendarTitle) - contentValues.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, calendarTitle) - contentValues.put(CalendarContract.Calendars.ACCOUNT_NAME, accountName) - contentValues.put( - CalendarContract.Calendars.ACCOUNT_TYPE, - CalendarContract.ACCOUNT_TYPE_LOCAL + if (calendarType == CalendarType.GOOGLE) { + val existingGoogleCalendar = findOrCreateGoogleCalendar() + if (existingGoogleCalendar != CALENDAR_DOES_NOT_EXIST) { + return existingGoogleCalendar + } + } + + val calendarAccount = when (calendarType) { + CalendarType.LOCAL -> CalendarAccount(accountName, CalendarContract.ACCOUNT_TYPE_LOCAL) + CalendarType.GOOGLE -> getCalendarOwnerAccount() + } + val contentValues = ContentValues().apply { + put(CalendarContract.Calendars.NAME, calendarTitle) + put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, calendarTitle) + put(CalendarContract.Calendars.ACCOUNT_NAME, calendarAccount.name) + put(CalendarContract.Calendars.ACCOUNT_TYPE, calendarAccount.type) + put(CalendarContract.Calendars.OWNER_ACCOUNT, calendarAccount.name) + put( + CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, + CalendarContract.Calendars.CAL_ACCESS_ROOT + ) + put(CalendarContract.Calendars.SYNC_EVENTS, 1) + put(CalendarContract.Calendars.VISIBLE, 1) + put( + CalendarContract.Calendars.CALENDAR_COLOR, + calendarColor.toInt() + ) + } + + val calendarData = context.contentResolver.insert( + CalendarContract.Calendars.CONTENT_URI, + contentValues ) - contentValues.put(CalendarContract.Calendars.OWNER_ACCOUNT, accountName) - contentValues.put( - CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, - CalendarContract.Calendars.CAL_ACCESS_ROOT + + return calendarData?.lastPathSegment?.toLong()?.also { + logger.d { "Calendar ID $it created" } + } ?: CALENDAR_DOES_NOT_EXIST + } + + private fun findOrCreateGoogleCalendar(): Long { + return findPrimaryGoogleCalendar()?.also { + logger.d { "Using existing primary Google Calendar ID $it" } + } ?: findWritableGoogleCalendar()?.also { + logger.d { "Using existing Google Calendar ID $it" } + } ?: run { + logger.d { "No Google Calendar found, will create local calendar" } + CALENDAR_DOES_NOT_EXIST + } + } + + private fun findPrimaryGoogleCalendar(): Long? { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.IS_PRIMARY} = 1 AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1" + val selectionArgs = arrayOf(GOOGLE_ACCOUNT_TYPE) + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null ) - contentValues.put(CalendarContract.Calendars.SYNC_EVENTS, 1) - contentValues.put(CalendarContract.Calendars.VISIBLE, 1) - contentValues.put( - CalendarContract.Calendars.CALENDAR_COLOR, - calendarColor.toInt() + + return cursor?.use { + if (it.moveToFirst()) { + it.getLong(it.getColumnIndexOrThrow(CalendarContract.Calendars._ID)) + } else { + null + } + } + } + + private fun findWritableGoogleCalendar(): Long? { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1 AND " + + "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ?" + val selectionArgs = arrayOf( + GOOGLE_ACCOUNT_TYPE, + CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR.toString() ) - val creationUri: Uri? = asSyncAdapter( - Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), - accountName + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + "${CalendarContract.Calendars.IS_PRIMARY} DESC" ) - creationUri?.let { - val calendarData: Uri? = context.contentResolver.insert(creationUri, contentValues) - calendarData?.let { - val id = calendarData.lastPathSegment?.toLong() - logger.d { "Calendar ID $id" } - return id ?: CALENDAR_DOES_NOT_EXIST + + return cursor?.use { + if (it.moveToFirst()) { + it.getLong(it.getColumnIndexOrThrow(CalendarContract.Calendars._ID)) + } else { + null } } - return CALENDAR_DOES_NOT_EXIST } - /** - * Method to add important dates of course as calendar event into calendar of mobile app - */ + fun getGoogleCalendars(): List { + val projection = arrayOf( + CalendarContract.Calendars._ID, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.CALENDAR_COLOR + ) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1 AND " + + "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ?" + val selectionArgs = arrayOf( + GOOGLE_ACCOUNT_TYPE, + CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR.toString() + ) + val sortOrder = + "${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME} ASC" + + return try { + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + ) + + cursor?.use { + val idIndex = it.getColumnIndexOrThrow(CalendarContract.Calendars._ID) + val titleIndex = + it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME) + val colorIndex = it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR) + + buildList { + while (it.moveToNext()) { + add( + UserCalendar( + id = it.getLong(idIndex), + title = it.getString(titleIndex), + color = it.getInt(colorIndex) + ) + ) + } + } + } ?: emptyList() + } catch (e: SecurityException) { + logger.d { "Failed to load Google calendars: ${e.message}" } + emptyList() + } + } + + fun hasAlternativeCalendarApp(): Boolean { + val intent = Intent(Intent.ACTION_INSERT).setData(CalendarContract.Events.CONTENT_URI) + val activities = context.packageManager.queryIntentActivities(intent, 0) + return activities.any { it.activityInfo.packageName != GOOGLE_CALENDAR_PACKAGE } + } + fun addEventsIntoCalendar( calendarId: Long, courseId: String, courseName: String, courseDateBlock: CourseDateBlock ): Long { - val date = courseDateBlock.date.toCalendar() - // start time of the event, adjusted 1 hour earlier for a 1-hour duration - val startMillis: Long = date.timeInMillis - TimeUnit.HOURS.toMillis(1) - // end time of the event added to the calendar - val endMillis: Long = date.timeInMillis - - val values = ContentValues().apply { - put(CalendarContract.Events.DTSTART, startMillis) - put(CalendarContract.Events.DTEND, endMillis) - put( - CalendarContract.Events.TITLE, - "${courseDateBlock.title} : $courseName" - ) - put( - CalendarContract.Events.DESCRIPTION, - getEventDescription( - courseId = courseId, - courseDateBlock = courseDateBlock, - isDeeplinkEnabled = corePreferences.appConfig.courseDatesCalendarSync.isDeepLinkEnabled + repeat(EVENT_ATTEMPTS) { attemptIndex -> + val attemptNumber = attemptIndex + 1 + val eventId = + tryCreateEvent(calendarId, courseId, courseName, courseDateBlock, attemptNumber) + if (eventId != EVENT_DOES_NOT_EXIST) { + return eventId + } + if (attemptNumber < EVENT_ATTEMPTS) { + runBlocking { delay(ACTION_RETRY_DELAY) } + } + } + logger.d { "Failed to create event after $EVENT_ATTEMPTS attempts" } + return EVENT_DOES_NOT_EXIST + } + + private fun tryCreateEvent( + calendarId: Long, + courseId: String, + courseName: String, + courseDateBlock: CourseDateBlock, + attemptNumber: Int + ): Long { + return try { + val date = courseDateBlock.date.toCalendar() + val startMillis = date.timeInMillis - TimeUnit.HOURS.toMillis(1) + val endMillis = date.timeInMillis + + val values = ContentValues().apply { + put(CalendarContract.Events.DTSTART, startMillis) + put(CalendarContract.Events.DTEND, endMillis) + put( + CalendarContract.Events.TITLE, + "${courseDateBlock.title} : $courseName" ) - ) - put(CalendarContract.Events.CALENDAR_ID, calendarId) - put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + put( + CalendarContract.Events.DESCRIPTION, + getEventDescription( + courseId = courseId, + courseDateBlock = courseDateBlock, + isDeeplinkEnabled = corePreferences.appConfig.courseDatesCalendarSync.isDeepLinkEnabled + ) + ) + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + } + val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) + val insertedEventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST + + if (insertedEventId != EVENT_DOES_NOT_EXIST && isEventExists(insertedEventId)) { + uri?.let { addReminderToEvent(uri = it) } + logger.d { "Event created successfully: $insertedEventId (attempt $attemptNumber)" } + insertedEventId + } else { + logger.d { "Event creation failed, retrying... (attempt $attemptNumber/$EVENT_ATTEMPTS)" } + EVENT_DOES_NOT_EXIST + } + } catch (e: Exception) { + logger.d { "Event creation error on attempt $attemptNumber: ${e.message}" } + EVENT_DOES_NOT_EXIST } - val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) - uri?.let { addReminderToEvent(uri = it) } - val eventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST - return eventId } - /** - * Method to generate & add deeplink into event description - * - * @return event description with deeplink for assignment block else block title - */ private fun getEventDescription( courseId: String, courseDateBlock: CourseDateBlock, @@ -194,69 +341,169 @@ class CalendarManager( return eventDescription } - /** - * Method to add a reminder to the given calendar events - * - * @param uri Calendar event Uri - */ private fun addReminderToEvent(uri: Uri) { - val eventId: Long? = uri.lastPathSegment?.toLong() + val eventId = uri.lastPathSegment?.toLong() ?: return logger.d { "Event ID $eventId" } - // Adding reminder on the start of event val eventValues = ContentValues().apply { - put(CalendarContract.Reminders.MINUTES, 0) put(CalendarContract.Reminders.EVENT_ID, eventId) put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) } - context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) - // Adding reminder 24 hours before the event get started - eventValues.apply { - put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(1)) - } - context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) - // Adding reminder 48 hours before the event get started - eventValues.apply { - put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(2)) + + listOf(0, TimeUnit.DAYS.toMinutes(1), TimeUnit.DAYS.toMinutes(2)).forEach { minutes -> + eventValues.put(CalendarContract.Reminders.MINUTES, minutes) + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - /** - * Method to delete the course calendar from the mobile calendar app - */ fun deleteCalendar(calendarId: Long) { - context.contentResolver.delete( - Uri.parse("content://com.android.calendar/calendars/$calendarId"), - null, + val calendarAccount = getCalendarAccountById(calendarId) + if (calendarAccount?.type == GOOGLE_ACCOUNT_TYPE) { + logger.d { "Cannot delete Google Calendar" } + return + } + + val calendarUri = ContentUris.withAppendedId( + CalendarContract.Calendars.CONTENT_URI, + calendarId + ) + val rowsDeleted = context.contentResolver.delete(calendarUri, null, null) + logger.d { + if (rowsDeleted > 0) { + "Calendar $calendarId deleted successfully" + } else { + "Calendar $calendarId deletion failed or calendar doesn't exist" + } + } + } + + suspend fun deleteEvents(eventIds: List) { + val deletedCount = eventIds.count { eventId -> + var deleted = false + var attempts = 0 + + while (!deleted && attempts < EVENT_ATTEMPTS) { + attempts++ + try { + deleted = deleteEventWithRetry(eventId) + if (!deleted && attempts < EVENT_ATTEMPTS) { + delay(ACTION_RETRY_DELAY) + } + } catch (e: Exception) { + logger.d { "Failed to delete event $eventId on attempt $attempts: ${e.message}" } + if (attempts < EVENT_ATTEMPTS) { + delay(ACTION_RETRY_DELAY) + } + } + } + + if (!deleted) { + logger.d { "Failed to delete event $eventId after $EVENT_ATTEMPTS attempts" } + } + + deleted + } + logger.d { "Successfully deleted $deletedCount out of ${eventIds.size} events" } + } + + private fun deleteEventWithRetry(eventId: Long): Boolean { + val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + val rows = context.contentResolver.delete(deleteUri, null, null) + + val deleted = if (rows > 0) { + val stillExists = isEventExists(eventId) + if (!stillExists) { + logger.d { "Event $eventId deleted successfully" } + true + } else { + logger.d { "Event $eventId deletion reported success but event still exists" } + false + } + } else { + val exists = isEventExists(eventId) + if (!exists) { + logger.d { "Event $eventId doesn't exist (already deleted)" } + true + } else { + logger.d { "Event $eventId deletion failed" } + false + } + } + return deleted + } + + private fun getCalendarOwnerAccount(): CalendarAccount { + return getSyncedAccountByType(GOOGLE_ACCOUNT_TYPE) + ?: getFirstSyncedAccount(excludeLocal = true) + ?: CalendarAccount(accountName, CalendarContract.ACCOUNT_TYPE_LOCAL) + } + + private fun getFirstSyncedAccount(excludeLocal: Boolean): CalendarAccount? { + val selection = buildString { + append("${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND ${CalendarContract.Calendars.VISIBLE} = 1") + if (excludeLocal) { + append(" AND ${CalendarContract.Calendars.ACCOUNT_TYPE} != ?") + } + } + val selectionArgs = if (excludeLocal) { + arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL) + } else { null + } + + return queryCalendarAccount(selection, selectionArgs) + } + + private fun getSyncedAccountByType(accountType: String): CalendarAccount? { + val selection = + "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1" + val selectionArgs = arrayOf(accountType) + + return queryCalendarAccount(selection, selectionArgs) + } + + private fun queryCalendarAccount( + selection: String, + selectionArgs: Array? + ): CalendarAccount? { + val projection = arrayOf( + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.ACCOUNT_TYPE, + CalendarContract.Calendars.IS_PRIMARY + ) + val sortOrder = "${CalendarContract.Calendars.IS_PRIMARY} DESC" + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder ) + + return cursor?.use { + if (it.moveToFirst()) { + val accountName = it.getString( + it.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_NAME) + ) + val accountType = it.getString( + it.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_TYPE) + ) + CalendarAccount(accountName, accountType) + } else { + null + } + } } - /** - * Helper method used to return a URI for use with a sync adapter (how an application and a - * sync adapter access the Calendar Provider) - * - * @param uri URI to access the calendar - * @param account Name of the calendar owner - * - * @return URI of the calendar - * - */ - private fun asSyncAdapter(uri: Uri, account: String): Uri? { - return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") - .appendQueryParameter(CalendarContract.SyncState.ACCOUNT_NAME, account) - .appendQueryParameter( - CalendarContract.SyncState.ACCOUNT_TYPE, - CalendarContract.ACCOUNT_TYPE_LOCAL - ).build() + private fun getCalendarAccountById(calendarId: Long): CalendarAccount? { + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + return queryCalendarAccount(selection, selectionArgs) } - /** - * Method to get the current user account as the Calendar owner - * - * @return calendar owner account or "local_user" - */ private fun getUserAccountForSync(): String { return corePreferences.user?.email ?: LOCAL_USER } @@ -279,8 +526,10 @@ class CalendarManager( return cursor?.use { if (it.moveToFirst()) { - val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) - val color = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) + val title = + it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) + val color = + it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) CalendarData( title = title, color = color @@ -294,17 +543,44 @@ class CalendarManager( fun deleteEvent(eventId: Long) { val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) val rows = context.contentResolver.delete(deleteUri, null, null) - if (rows > 0) { - logger.d { "Event deleted successfully" } - } else { - logger.d { "Event deletion failed" } + logger.d { + if (rows > 0) { + "Event deleted successfully" + } else { + "Event deletion failed" + } } } + private fun isEventExists(eventId: Long): Boolean { + if (eventId == EVENT_DOES_NOT_EXIST) return false + + val projection = arrayOf(CalendarContract.Events._ID) + val selection = "${CalendarContract.Events._ID} = ?" + val selectionArgs = arrayOf(eventId.toString()) + + val cursor = context.contentResolver.query( + CalendarContract.Events.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + return cursor?.use { + it.count > 0 + } ?: false + } + companion object { const val CALENDAR_DOES_NOT_EXIST = -1L const val EVENT_DOES_NOT_EXIST = -1L private const val TAG = "CalendarManager" private const val LOCAL_USER = "local_user" + private const val GOOGLE_ACCOUNT_TYPE = "com.google" + private const val GOOGLE_CALENDAR_PACKAGE = "com.google.android.calendar" + + private const val ACTION_RETRY_DELAY = 500L + private const val EVENT_ATTEMPTS = 3 } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt index 45ca74658..dcc31d04e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -124,10 +124,16 @@ class CalendarViewModel( } private fun getCalendarData() { - if (calendarManager.hasPermissions()) { - val calendarData = calendarManager.getCalendarData(calendarId = calendarPreferences.calendarId) - _uiState.update { it.copy(calendarData = calendarData) } + if (!calendarManager.hasPermissions()) return + + val calendarId = calendarPreferences.calendarId + if (calendarId == CalendarManager.CALENDAR_DOES_NOT_EXIST) { + _uiState.update { it.copy(calendarData = null) } + return } + + val calendarData = calendarManager.getCalendarData(calendarId = calendarId) + _uiState.update { it.copy(calendarData = calendarData) } } private fun updateSyncedCoursesCount() { diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt index 8a71410b1..4920360bc 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogFragment.kt @@ -18,9 +18,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -55,18 +59,28 @@ class DisableCalendarSyncDialogFragment : DialogFragment() { savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.setCancelable(false) + dialog?.setCanceledOnTouchOutside(false) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { val viewModel: DisableCalendarSyncDialogViewModel = koinViewModel() + val isDeleting by viewModel.deletionState.collectAsState() + + LaunchedEffect(isDeleting) { + if (isDeleting == DeletionState.DELETED) { + dismiss() + } + } + DisableCalendarSyncDialogView( calendarData = requireArguments().parcelable(ARG_CALENDAR_DATA), + isDeleting = isDeleting == DeletionState.DELETING, onCancelClick = { dismiss() }, onDisableSyncingClick = { viewModel.disableSyncingClick() - dismiss() } ) } @@ -93,13 +107,18 @@ class DisableCalendarSyncDialogFragment : DialogFragment() { private fun DisableCalendarSyncDialogView( modifier: Modifier = Modifier, calendarData: CalendarData?, + isDeleting: Boolean, onCancelClick: () -> Unit, onDisableSyncingClick: () -> Unit ) { val scrollState = rememberScrollState() DefaultDialogBox( modifier = modifier, - onDismissClick = onCancelClick + onDismissClick = { + if (!isDeleting) { + onCancelClick() + } + } ) { Column( modifier = Modifier @@ -159,23 +178,42 @@ private fun DisableCalendarSyncDialogView( style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textDark ) - OpenEdXOutlinedButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.profile_disable_syncing), - backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.primaryButtonBackground, - textColor = MaterialTheme.appColors.primaryButtonBackground, - onClick = { - onDisableSyncingClick() - } - ) - OpenEdXButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), - onClick = { - onCancelClick() + + if (isDeleting) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.appColors.primary + ) + Text( + text = stringResource(id = R.string.profile_deleting_events), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textDark + ) } - ) + } else { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_disable_syncing), + backgroundColor = MaterialTheme.appColors.background, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + onClick = { + onDisableSyncingClick() + } + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_cancel), + onClick = { + onCancelClick() + } + ) + } } } } @@ -187,8 +225,9 @@ private fun DisableCalendarSyncDialogPreview() { OpenEdXTheme { DisableCalendarSyncDialogView( calendarData = CalendarData("calendar", Color.GREEN), + isDeleting = true, onCancelClick = { }, - onDisableSyncingClick = { } + onDisableSyncingClick = { }, ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt index b29c3394c..3d0cf94a8 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt @@ -1,9 +1,15 @@ package org.openedx.profile.presentation.calendar import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CalendarType import org.openedx.core.system.CalendarManager import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled @@ -16,12 +22,35 @@ class DisableCalendarSyncDialogViewModel( private val calendarInteractor: CalendarInteractor, ) : BaseViewModel() { + private val _deletionState = MutableStateFlow(null) + val deletionState: StateFlow = _deletionState.asStateFlow() + fun disableSyncingClick() { viewModelScope.launch { - calendarInteractor.clearCalendarCachedData() - calendarManager.deleteCalendar(calendarPreferences.calendarId) - calendarPreferences.clearCalendarPreferences() - calendarNotifier.send(CalendarSyncDisabled) + try { + withContext(NonCancellable) { + _deletionState.value = DeletionState.DELETING + val allEvents = calendarInteractor.getAllCourseCalendarEventsFromCache() + val eventIds = allEvents.map { it.eventId } + calendarManager.deleteEvents(eventIds) + _deletionState.value = DeletionState.DELETED + calendarInteractor.clearCalendarCachedData() + val calendarId = calendarPreferences.calendarId + if (calendarPreferences.calendarType == CalendarType.LOCAL) { + calendarManager.deleteCalendar(calendarId) + } + calendarPreferences.clearCalendarPreferences() + calendarNotifier.send(CalendarSyncDisabled) + } + } catch (e: Exception) { + e.printStackTrace() + } finally { + _deletionState.value = null + } } } } + +enum class DeletionState { + DELETING, DELETED +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt index 857af17d0..162f0d10b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ExpandMore 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 @@ -64,6 +66,7 @@ import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.koin.androidx.compose.koinViewModel +import org.openedx.core.domain.model.UserCalendar import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -108,14 +111,24 @@ class NewCalendarDialogFragment : DialogFragment() { } } + val googleCalendars by viewModel.googleCalendars.collectAsState() + val showLocalCalendarSection by viewModel.showLocalCalendarSection.collectAsState() + NewCalendarDialog( - newCalendarDialogType = requireArguments().parcelable(ARG_DIALOG_TYPE) + newCalendarDialogType = requireArguments().parcelable( + ARG_DIALOG_TYPE + ) ?: NewCalendarDialogType.CREATE_NEW, + googleCalendars = googleCalendars, + showLocalCalendarSection = showLocalCalendarSection, onCancelClick = { dismiss() }, onBeginSyncingClick = { calendarTitle, calendarColor -> viewModel.createCalendar(calendarTitle, calendarColor) + }, + onGoogleCalendarClick = { + viewModel.syncWithGoogleCalendar(it) } ) } @@ -147,8 +160,11 @@ class NewCalendarDialogFragment : DialogFragment() { private fun NewCalendarDialog( modifier: Modifier = Modifier, newCalendarDialogType: NewCalendarDialogType, + googleCalendars: List, + showLocalCalendarSection: Boolean, onCancelClick: () -> Unit, - onBeginSyncingClick: (calendarTitle: String, calendarColor: CalendarColor) -> Unit + onBeginSyncingClick: (calendarTitle: String, calendarColor: CalendarColor) -> Unit, + onGoogleCalendarClick: (Long) -> Unit ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -162,6 +178,7 @@ private fun NewCalendarDialog( var calendarColor by rememberSaveable { mutableStateOf(CalendarColor.ACCENT) } + var selectedCalendar by remember { mutableStateOf(null) } DefaultDialogBox( modifier = modifier, onDismissClick = onCancelClick @@ -186,51 +203,199 @@ private fun NewCalendarDialog( Icon( modifier = Modifier .size(24.dp) - .clickable { - onCancelClick() - }, + .clickable { onCancelClick() }, imageVector = Icons.Default.Close, contentDescription = null, tint = MaterialTheme.appColors.primary ) } - CalendarTitleTextField( - onValueChanged = { - calendarTitle = it - } - ) - ColorDropdown( - onValueChanged = { - calendarColor = it + CalendarDropdown( + calendars = googleCalendars, + showLocalCalendarOption = showLocalCalendarSection, + selectedCalendar = selectedCalendar, + onLocalCalendarClick = { selectedCalendar = SelectedCalendar.Local }, + onGoogleCalendarClick = { + selectedCalendar = SelectedCalendar.Google(it) } ) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.profile_new_calendar_description), - style = MaterialTheme.appTypography.bodyMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.appColors.textDark - ) + if (googleCalendars.isEmpty() && !showLocalCalendarSection) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_no_google_calendars), + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textFieldHint + ) + } + if (selectedCalendar == SelectedCalendar.Local) { + LocalCalendarSection( + onCalendarTitleChange = { calendarTitle = it }, + onCalendarColorChange = { calendarColor = it }, + ) + } + if (selectedCalendar != null) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_new_calendar_description), + style = MaterialTheme.appTypography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.appColors.textDark + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.profile_begin_syncing), + onClick = { + when (val selectedCalendar = selectedCalendar) { + is SelectedCalendar.Google -> { + onGoogleCalendarClick(selectedCalendar.calendar.id) + } + + SelectedCalendar.Local -> { + onBeginSyncingClick( + calendarTitle.ifEmpty { + NewCalendarDialogFragment.getDefaultCalendarTitle(context) + }, + calendarColor + ) + } + + else -> {} + } + } + ) + } OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), text = stringResource(id = CoreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, - onClick = { - onCancelClick() - } + onClick = onCancelClick ) - OpenEdXButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.profile_begin_syncing), - onClick = { - onBeginSyncingClick( - calendarTitle.ifEmpty { NewCalendarDialogFragment.getDefaultCalendarTitle(context) }, - calendarColor + } + } +} + +@Composable +private fun LocalCalendarSection( + onCalendarTitleChange: (String) -> Unit, + onCalendarColorChange: (CalendarColor) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CalendarTitleTextField( + onValueChanged = onCalendarTitleChange + ) + ColorDropdown( + onValueChanged = onCalendarColorChange + ) + } +} + +@Composable +private fun CalendarDropdown( + calendars: List, + showLocalCalendarOption: Boolean, + selectedCalendar: SelectedCalendar?, + onLocalCalendarClick: () -> Unit, + onGoogleCalendarClick: (UserCalendar) -> Unit +) { + val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } + var dropdownWidth by remember { mutableStateOf(300.dp) } + + val selectedLabel = when (selectedCalendar) { + SelectedCalendar.Local -> stringResource(id = R.string.profile_local_calendar_option) + is SelectedCalendar.Google -> selectedCalendar.calendar.title + null -> stringResource(id = R.string.profile_select_calendar) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(MaterialTheme.appShapes.textFieldShape) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .onSizeChanged { dropdownWidth = with(density) { it.width.toDp() } } + .clickable { expanded = true }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp), + text = selectedLabel, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.bodyMedium + ) + Icon( + modifier = Modifier + .padding(end = 16.dp) + .rotate(if (expanded) 180f else 0f), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy(MaterialTheme.appShapes.textFieldShape) + ) { + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .height(180.dp) + .width(dropdownWidth) + .border( + 1.dp, + MaterialTheme.appColors.textFieldBorder, + MaterialTheme.appShapes.textFieldShape + ) + .crop(vertical = 8.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + if (showLocalCalendarOption) { + CalendarOptionItem( + text = stringResource(id = R.string.profile_local_calendar_option), + contentColor = MaterialTheme.appColors.textDark + ) { + expanded = false + onLocalCalendarClick() + } + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider ) } - ) + + calendars.forEachIndexed { index, calendar -> + CalendarOptionItem( + text = calendar.title, + contentColor = MaterialTheme.appColors.textDark, + leadingColor = ComposeColor(calendar.color) + ) { + expanded = false + onGoogleCalendarClick(calendar) + } + if (index < calendars.lastIndex) { + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + } + } } } } @@ -421,6 +586,37 @@ private fun ColorCircle( ) } +@Composable +private fun CalendarOptionItem( + text: String, + contentColor: ComposeColor, + leadingColor: ComposeColor? = null, + onClick: () -> Unit +) { + DropdownMenuItem( + modifier = Modifier.background(MaterialTheme.appColors.background), + onClick = onClick + ) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + leadingColor?.let { ColorCircle(color = it) } + Text( + text = text, + style = MaterialTheme.appTypography.titleSmall, + color = contentColor + ) + } + } +} + +private sealed class SelectedCalendar { + object Local : SelectedCalendar() + data class Google(val calendar: UserCalendar) : SelectedCalendar() +} + @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable @@ -428,8 +624,14 @@ private fun NewCalendarDialogPreview() { OpenEdXTheme { NewCalendarDialog( newCalendarDialogType = NewCalendarDialogType.CREATE_NEW, + googleCalendars = listOf( + UserCalendar(1, "Work", CalendarColor.BLUE.color.toInt()), + UserCalendar(2, "Personal", CalendarColor.GREEN.color.toInt()) + ), + showLocalCalendarSection = true, onCancelClick = { }, - onBeginSyncingClick = { _, _ -> } + onBeginSyncingClick = { _, _ -> }, + onGoogleCalendarClick = { } ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt index 20fbdbf23..eb95d1650 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt @@ -1,13 +1,20 @@ package org.openedx.profile.presentation.calendar import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.withContext import org.openedx.core.R import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CalendarType +import org.openedx.core.domain.model.UserCalendar import org.openedx.core.system.CalendarManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.calendar.CalendarCreated @@ -32,6 +39,28 @@ class NewCalendarDialogViewModel( val isSuccess: SharedFlow get() = _isSuccess.asSharedFlow() + private val _googleCalendars = MutableStateFlow>(emptyList()) + val googleCalendars: StateFlow> + get() = _googleCalendars.asStateFlow() + + private val _showLocalCalendarSection = + MutableStateFlow(calendarManager.hasAlternativeCalendarApp()) + val showLocalCalendarSection: StateFlow + get() = _showLocalCalendarSection.asStateFlow() + + init { + loadGoogleCalendars() + } + + private fun loadGoogleCalendars() { + viewModelScope.launch { + val calendars = withContext(Dispatchers.IO) { + calendarManager.getGoogleCalendars() + } + _googleCalendars.emit(calendars) + } + } + fun createCalendar( calendarTitle: String, calendarColor: CalendarColor, @@ -39,14 +68,19 @@ class NewCalendarDialogViewModel( viewModelScope.launch { if (networkConnection.isOnline()) { calendarInteractor.resetChecksums() + val currentCalendarType = calendarPreferences.calendarType val calendarId = calendarManager.createOrUpdateCalendar( - calendarId = calendarPreferences.calendarId, + calendarId = calendarPreferences.calendarId.takeIf { + currentCalendarType == CalendarType.LOCAL + } ?: CalendarManager.CALENDAR_DOES_NOT_EXIST, calendarTitle = calendarTitle, - calendarColor = calendarColor.color + calendarColor = calendarColor.color, + calendarType = CalendarType.LOCAL ) if (calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST) { calendarPreferences.calendarId = calendarId calendarPreferences.calendarUser = calendarManager.accountName + calendarPreferences.calendarType = CalendarType.LOCAL viewModelScope.launch { calendarNotifier.send(CalendarCreated) } @@ -59,4 +93,32 @@ class NewCalendarDialogViewModel( } } } + + fun syncWithGoogleCalendar(calendarId: Long) { + viewModelScope.launch { + if (!networkConnection.isOnline()) { + _uiMessage.emit(resourceManager.getString(R.string.core_error_no_connection)) + return@launch + } + + if (!calendarManager.isCalendarExist(calendarId)) { + _uiMessage.emit(resourceManager.getString(R.string.core_error_unknown_error)) + return@launch + } + + calendarInteractor.resetChecksums() + if (calendarPreferences.calendarId != CalendarManager.CALENDAR_DOES_NOT_EXIST && + calendarPreferences.calendarId != calendarId && + calendarPreferences.calendarType == CalendarType.LOCAL + ) { + calendarManager.deleteCalendar(calendarPreferences.calendarId) + } + + calendarPreferences.calendarId = calendarId + calendarPreferences.calendarUser = calendarManager.accountName + calendarPreferences.calendarType = CalendarType.GOOGLE + calendarNotifier.send(CalendarCreated) + _isSuccess.emit(true) + } + } } diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 1de55c683..d2ee6951e 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -69,9 +69,15 @@ Disable Calendar Sync Disabling calendar sync will delete the calendar “%1$s.” You can turn calendar sync back on at any time. Disable Syncing + Deleting events, please wait… No %1$s Courses No courses are currently being synced to your calendar. No courses match the current filter. Show full dates like “%1$s” + Select Google Calendar + Change Google Calendar + No Google calendars available + Select calendar + Local calendar From 5707e95178594ac29d792dc896b9b142f4cfd6b7 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:20:30 +0200 Subject: [PATCH 07/16] feat: [FC-0078] Dates page (#434) * feat: dates tab UI * feat: added config flag for enabling/disabling dates screen * feat: pull to refresh * feat: offline mode dialog * feat: added dates request * feat: paging and caching * feat: navigating to block * feat: reuse dates UI from CourseDatesScreen * feat: shift due date card * feat: shift due date request * feat: junit tests * feat: junit tests and analytics * fix: changes according detekt warnings * feat: pagination * fix: pagination bugs * feat: cache-first logic * fix: changes according code review * feat: according designer feedback * fix: assignment default color fix * fix: empty state icon * fix: string * feat: colors update * fix: fixes according PR review feedback --- app/build.gradle | 1 + .../org.openedx.app.room.AppDatabase/6.json | 1206 +++++++++++++++++ .../java/org/openedx/app/AnalyticsManager.kt | 4 +- .../main/java/org/openedx/app/AppAnalytics.kt | 4 + .../main/java/org/openedx/app/AppRouter.kt | 4 +- .../main/java/org/openedx/app/MainFragment.kt | 8 + .../java/org/openedx/app/MainViewModel.kt | 5 + .../app/data/networking/HeadersInterceptor.kt | 2 +- .../openedx/app/deeplink/DeepLinkRouter.kt | 13 +- .../java/org/openedx/app/deeplink/HomeTab.kt | 1 + .../main/java/org/openedx/app/di/AppModule.kt | 9 + .../java/org/openedx/app/di/ScreenModule.kt | 27 + .../java/org/openedx/app/room/AppDatabase.kt | 9 +- .../res/drawable/app_ic_dates_cloud_fill.xml | 9 + .../drawable/app_ic_dates_cloud_outline.xml | 9 + .../res/drawable/app_ic_dates_selector.xml | 5 + app/src/main/res/values/main_manu_tab_ids.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../core/config/AppLevelDatesConfig.kt | 8 + .../java/org/openedx/core/config/Config.kt | 4 + .../core/config/ExperimentalFeaturesConfig.kt | 2 + .../org/openedx/core/data/api/CourseApi.kt | 13 +- .../core/data/model/CourseDatesResponse.kt | 56 + .../core/data/model/CourseProgressResponse.kt | 28 +- .../core/data/model/room/CourseDateEntity.kt | 60 + .../core/domain/model/CourseDatesResponse.kt | 20 + .../openedx/core/domain/model/DatesSection.kt | 20 +- .../core/presentation/ListItemPosition.kt | 16 + .../core/presentation/dates/DatesUI.kt | 322 +++++ .../java/org/openedx/core/ui/ComposeCommon.kt | 79 +- .../org/openedx/core/ui/theme/Colors.kt | 10 +- .../presentation/dates/CourseDatesScreen.kt | 1 + .../outline/CourseContentAllViewModel.kt | 5 - .../AllEnrolledCoursesViewModel.kt | 4 +- .../presentation/DashboardListFragment.kt | 2 + .../dashboard/presentation/DashboardRouter.kt | 4 +- .../learn/presentation/LearnFragment.kt | 6 +- dates/.gitignore | 1 + dates/build.gradle | 64 + dates/consumer-rules.pro | 0 dates/proguard-rules.pro | 7 + dates/src/main/AndroidManifest.xml | 4 + .../dates/data/repository/DatesRepository.kt | 38 + .../openedx/dates/data/storage/DatesDao.kt | 23 + .../domain/interactor/DatesInteractor.kt | 16 + .../dates/presentation/DatesAnalytics.kt | 20 + .../openedx/dates/presentation/DatesRouter.kt | 16 + .../dates/presentation/dates/DatesFragment.kt | 72 + .../dates/presentation/dates/DatesScreen.kt | 331 +++++ .../dates/presentation/dates/DatesUIState.kt | 12 + .../presentation/dates/DatesViewModel.kt | 279 ++++ dates/src/main/res/values/strings.xml | 9 + .../org/openedx/dates/DatesViewModelTest.kt | 378 ++++++ default_config/prod/config.yaml | 2 + default_config/stage/config.yaml | 2 + .../presentation/download/DownloadsScreen.kt | 4 +- settings.gradle | 1 + 57 files changed, 3181 insertions(+), 76 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/6.json create mode 100644 app/src/main/res/drawable/app_ic_dates_cloud_fill.xml create mode 100644 app/src/main/res/drawable/app_ic_dates_cloud_outline.xml create mode 100644 app/src/main/res/drawable/app_ic_dates_selector.xml create mode 100644 core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt create mode 100644 dates/.gitignore create mode 100644 dates/build.gradle create mode 100644 dates/consumer-rules.pro create mode 100644 dates/proguard-rules.pro create mode 100644 dates/src/main/AndroidManifest.xml create mode 100644 dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt create mode 100644 dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt create mode 100644 dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt create mode 100644 dates/src/main/res/values/strings.xml create mode 100644 dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt diff --git a/app/build.gradle b/app/build.gradle index f7ad7ef16..f41d93cec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -127,6 +127,7 @@ dependencies { implementation project(path: ':profile') implementation project(path: ':discussion') implementation project(path: ':whatsnew') + implementation project(path: ':dates') implementation project(path: ':downloads') ksp "androidx.room:room-compiler:$room_version" diff --git a/app/schemas/org.openedx.app.room.AppDatabase/6.json b/app/schemas/org.openedx.app.room.AppDatabase/6.json new file mode 100644 index 000000000..de1e51a90 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/6.json @@ -0,0 +1,1206 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "3c35a346cc635ac7115a9f5021306a61", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT" + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + } + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT" + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` INTEGER, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` INTEGER, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_dates_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `first_component_block_id` TEXT, `course_id` TEXT NOT NULL, `due_date` TEXT, `assignment_title` TEXT, `learner_has_access` INTEGER, `relative` INTEGER, `course_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstComponentBlockId", + "columnName": "first_component_block_id", + "affinity": "TEXT" + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "due_date", + "affinity": "TEXT" + }, + { + "fieldPath": "assignmentTitle", + "columnName": "assignment_title", + "affinity": "TEXT" + }, + { + "fieldPath": "learnerHasAccess", + "columnName": "learner_has_access", + "affinity": "INTEGER" + }, + { + "fieldPath": "relative", + "columnName": "relative", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseName", + "columnName": "course_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + } + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT" + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT" + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL" + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER" + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3c35a346cc635ac7115a9f5021306a61')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 6c29cdf12..5e96784d8 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -6,6 +6,7 @@ import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dates.presentation.DatesAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.foundation.interfaces.Analytics @@ -23,7 +24,8 @@ class AnalyticsManager : DiscussionAnalytics, ProfileAnalytics, WhatsNewAnalytics, - DownloadsAnalytics { + DownloadsAnalytics, + DatesAnalytics { private val analytics: MutableList = mutableListOf() diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 55b26b492..997ab096d 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -20,6 +20,10 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), + DATES( + "MainDashboard:DATES", + "edx.bi.app.main_dashboard.dates" + ), DOWNLOADS( "MainDashboard:Downloads", "edx.bi.app.main_dashboard.downloads" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 4678344ee..c168a9b5a 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -29,6 +29,7 @@ import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment import org.openedx.courses.presentation.AllEnrolledCoursesFragment import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.presentation.DatesRouter import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment import org.openedx.discovery.presentation.WebViewDiscoveryFragment @@ -69,7 +70,8 @@ class AppRouter : AppUpgradeRouter, WhatsNewRouter, CalendarRouter, - DownloadsRouter { + DownloadsRouter, + DatesRouter { // region AuthRouter override fun navigateToMain( diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 82092e439..397216b74 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -27,6 +27,7 @@ import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.dates.presentation.dates.DatesFragment import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment @@ -104,6 +105,9 @@ class MainFragment : Fragment(R.layout.fragment_main) { if (viewModel.isDownloadsFragmentEnabled) { add(R.id.fragmentDownloads to { DownloadsFragment() }) } + if (viewModel.isDatesFragmentEnabled) { + add(R.id.fragmentDates to { DatesFragment() }) + } add(R.id.fragmentProfile to { ProfileFragment() }) } } @@ -113,12 +117,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), + R.id.fragmentDates to resources.getString(R.string.app_navigation_dates), R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), ) val tabIconSelectors = mapOf( R.id.fragmentLearn to R.drawable.app_ic_learn_selector, R.id.fragmentDiscover to R.drawable.app_ic_discover_selector, R.id.fragmentDownloads to R.drawable.app_ic_downloads_selector, + R.id.fragmentDates to R.drawable.app_ic_dates_selector, R.id.fragmentProfile to R.drawable.app_ic_profile_selector ) @@ -136,6 +142,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn -> viewModel.logLearnTabClickedEvent() R.id.fragmentDiscover -> viewModel.logDiscoveryTabClickedEvent() R.id.fragmentDownloads -> viewModel.logDownloadsTabClickedEvent() + R.id.fragmentDates -> viewModel.logDatesTabClickedEvent() R.id.fragmentProfile -> viewModel.logProfileTabClickedEvent() } menuIdToIndex[menuItem.itemId]?.let { index -> @@ -174,6 +181,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn } + HomeTab.DATES.name -> R.id.fragmentDates HomeTab.PROFILE.name -> R.id.fragmentProfile else -> R.id.fragmentLearn } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 8723d6dbe..74f309e68 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -41,6 +41,7 @@ class MainViewModel( val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() + val isDatesFragmentEnabled get() = config.getDatesConfig().isEnabled val isDownloadsFragmentEnabled get() = config.getDownloadsConfig().isEnabled override fun onCreate(owner: LifecycleOwner) { @@ -65,6 +66,10 @@ class MainViewModel( logScreenEvent(AppAnalyticsEvent.DOWNLOADS) } + fun logDatesTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DATES) + } + fun logProfileTabClickedEvent() { logScreenEvent(AppAnalyticsEvent.PROFILE) } diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index a4daf0809..baafe5a86 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -25,7 +25,7 @@ class HeadersInterceptor( addHeader("Accept", "application/json") val httpAgent = System.getProperty("http.agent") ?: "" - addHeader("User-Agent", "$httpAgent ${appData.versionName}") + addHeader("User-Agent", "$httpAgent ${appData.appUserAgent}") }.build() ) } diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 2192a6b89..32d8ed20e 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -212,7 +212,9 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "VIDEOS" + openTab = "VIDEOS", + resumeBlockId = "", + ) } } @@ -223,7 +225,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "DATES" + openTab = "DATES", + resumeBlockId = "", ) } } @@ -234,7 +237,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "DISCUSSIONS" + openTab = "DISCUSSIONS", + resumeBlockId = "", ) } } @@ -245,7 +249,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "MORE" + openTab = "MORE", + resumeBlockId = "", ) } } diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt index ce72703ad..e687f1589 100644 --- a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -4,6 +4,7 @@ enum class HomeTab { LEARN, PROGRAMS, DISCOVER, + DATES, DOWNLOADS, PROFILE } 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 cdb240387..92e4feada 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -65,6 +65,8 @@ import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.presentation.DatesAnalytics +import org.openedx.dates.presentation.DatesRouter import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -131,6 +133,7 @@ val appModule = module { single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } single { get() } + single { get() } single { NetworkConnection(get()) } @@ -177,6 +180,11 @@ val appModule = module { room.calendarDao() } + single { + val room = get() + room.datesDao() + } + single { FileDownloader() } @@ -209,6 +217,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { get() } factory { AgreementProvider(get(), get()) } 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 1d3604050..f2d531918 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -42,6 +42,9 @@ import org.openedx.courses.presentation.DashboardGalleryViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardListViewModel +import org.openedx.dates.data.repository.DatesRepository +import org.openedx.dates.domain.interactor.DatesInteractor +import org.openedx.dates.presentation.dates.DatesViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -583,4 +586,28 @@ val screenModule = module { analytics = get() ) } + + factory { + DatesRepository( + api = get(), + dao = get(), + preferencesManager = get(), + ) + } + factory { + DatesInteractor( + repository = get() + ) + } + viewModel { + DatesViewModel( + datesRouter = get(), + networkConnection = get(), + resourceManager = get(), + datesInteractor = get(), + corePreferences = get(), + analytics = get(), + calendarSyncScheduler = get() + ) + } } diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index b2f275bb3..3a3316bd0 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -6,6 +6,7 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.CourseDateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity @@ -19,11 +20,12 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter import org.openedx.dashboard.data.DashboardDao +import org.openedx.dates.data.storage.DatesDao import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao -const val DATABASE_VERSION = 5 +const val DATABASE_VERSION = 6 const val DATABASE_NAME = "OpenEdX_db" @Suppress("MagicNumber") @@ -38,6 +40,7 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarStateEntity::class, DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class, + CourseDateEntity::class, VideoProgressEntity::class, CourseProgressEntity::class, ], @@ -45,7 +48,8 @@ const val DATABASE_NAME = "OpenEdX_db" AutoMigration(1, 2), AutoMigration(2, 3), AutoMigration(3, 4), - AutoMigration(4, DATABASE_VERSION), + AutoMigration(4, 5), + AutoMigration(5, DATABASE_VERSION), ], version = DATABASE_VERSION ) @@ -55,5 +59,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun courseDao(): CourseDao abstract fun dashboardDao(): DashboardDao abstract fun downloadDao(): DownloadDao + abstract fun datesDao(): DatesDao abstract fun calendarDao(): CalendarDao } diff --git a/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml b/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml new file mode 100644 index 000000000..a3fdccec3 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml b/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml new file mode 100644 index 000000000..000fc5893 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_dates_selector.xml b/app/src/main/res/drawable/app_ic_dates_selector.xml new file mode 100644 index 000000000..9e20819bf --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/main_manu_tab_ids.xml b/app/src/main/res/values/main_manu_tab_ids.xml index f769b5bde..d78543a76 100644 --- a/app/src/main/res/values/main_manu_tab_ids.xml +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -3,5 +3,6 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 801ce0c80..65440a993 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,6 @@ Discover Learn Profile + Dates Downloads diff --git a/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt new file mode 100644 index 000000000..73392bf72 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class AppLevelDatesConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) 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 d26741699..7285a6b66 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -96,6 +96,10 @@ class Config(context: Context) { return getExperimentalFeaturesConfig().appLevelDownloadsConfig } + fun getDatesConfig(): AppLevelDatesConfig { + return getExperimentalFeaturesConfig().appLevelDatesConfig + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } diff --git a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt index 03dd43150..738938835 100644 --- a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -5,4 +5,6 @@ import com.google.gson.annotations.SerializedName data class ExperimentalFeaturesConfig( @SerializedName("APP_LEVEL_DOWNLOADS") val appLevelDownloadsConfig: AppLevelDownloadsConfig = AppLevelDownloadsConfig(), + @SerializedName("APP_LEVEL_DATES") + val appLevelDatesConfig: AppLevelDatesConfig = AppLevelDatesConfig(), ) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index d6e44cfe2..bcc57d826 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -6,6 +6,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseDatesResponse import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseProgressResponse @@ -64,7 +65,8 @@ interface CourseApi { @GET("/api/course_home/v1/dates/{course_id}") suspend fun getCourseDates( @Path("course_id") courseId: String, - @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true + @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true, + @Query("mobile") mobile: Boolean = true, ): CourseDates @POST("/api/course_experience/v1/reset_course_deadlines") @@ -111,8 +113,17 @@ interface CourseApi { @Path("username") username: String ): List + @GET("/api/mobile/v1/course_dates/{username}/") + suspend fun getUserDates( + @Path("username") username: String, + @Query("page") page: Int + ): CourseDatesResponse + @GET("/api/course_home/progress/{course_id}") suspend fun getCourseProgress( @Path("course_id") courseId: String, ): CourseProgressResponse + + @POST("/api/course_experience/v1/reset_all_relative_course_deadlines/") + suspend fun shiftAllDueDates() } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt new file mode 100644 index 000000000..c86500671 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -0,0 +1,56 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseDate as DomainCourseDate +import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesResponse + +data class CourseDate( + @SerializedName("course_id") + val courseId: String, + @SerializedName("first_component_block_id") + val firstComponentBlockId: String?, + @SerializedName("due_date") + val dueDate: String?, + @SerializedName("assignment_title") + val assignmentTitle: String?, + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean?, + @SerializedName("relative") + val relative: Boolean?, + @SerializedName("course_name") + val courseName: String? +) { + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + firstComponentBlockId = firstComponentBlockId ?: "", + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + courseName = courseName ?: "", + relative = relative ?: false + ) + } +} + +data class CourseDatesResponse( + @SerializedName("count") + val count: Int, + @SerializedName("next") + val next: String?, + @SerializedName("previous") + val previous: String?, + @SerializedName("results") + val results: List +) { + fun mapToDomain(): DomainCourseDatesResponse { + return DomainCourseDatesResponse( + count = count, + next = next, + previous = previous, + results = results.mapNotNull { it.mapToDomain() } + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index 00d55a9b5..6c191ee3a 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -93,22 +93,24 @@ data class CourseProgressResponse( @SerializedName("assignment_colors") val assignmentColors: List? ) { // TODO Temporary solution. Backend will returns color list later - val defaultColors = listOf( - "#D24242", - "#7B9645", - "#5A5AD8", - "#B0842C", - "#2E90C2", - "#D13F88", - "#36A17D", - "#AE5AD8", - "#3BA03B" - ) + companion object { + val DEFAULT_COLORS = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) + } fun mapToRoomEntity() = GradingPolicyDb( assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), gradeRange = gradeRange ?: emptyMap(), - assignmentColors = assignmentColors ?: defaultColors + assignmentColors = assignmentColors ?: DEFAULT_COLORS ) fun mapToDomain() = CourseProgress.GradingPolicy( @@ -116,7 +118,7 @@ data class CourseProgressResponse( gradeRange = gradeRange ?: emptyMap(), assignmentColors = assignmentColors?.map { colorString -> Color(colorString.toColorInt()) - } ?: defaultColors.map { Color(it.toColorInt()) } + } ?: DEFAULT_COLORS.map { Color(it.toColorInt()) } ) data class AssignmentPolicy( diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt new file mode 100644 index 000000000..9d1c1b9a4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt @@ -0,0 +1,60 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.data.model.CourseDate +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseDate as DomainCourseDate + +@Entity(tableName = "course_dates_table") +data class CourseDateEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo("id") + val id: Int, + @ColumnInfo("first_component_block_id") + val firstComponentBlockId: String?, + @ColumnInfo("course_id") + val courseId: String, + @ColumnInfo("due_date") + val dueDate: String?, + @ColumnInfo("assignment_title") + val assignmentTitle: String?, + @ColumnInfo("learner_has_access") + val learnerHasAccess: Boolean?, + @ColumnInfo("relative") + val relative: Boolean?, + @ColumnInfo("course_name") + val courseName: String?, +) { + + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + firstComponentBlockId = firstComponentBlockId ?: "", + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + relative = relative ?: false, + courseName = courseName ?: "" + ) + } + + companion object { + fun createFrom(courseDate: CourseDate): CourseDateEntity { + with(courseDate) { + return CourseDateEntity( + id = 0, + courseId = courseId, + firstComponentBlockId = firstComponentBlockId, + dueDate = dueDate, + assignmentTitle = assignmentTitle, + learnerHasAccess = learnerHasAccess, + relative = relative, + courseName = courseName + ) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt new file mode 100644 index 000000000..5a317b69c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -0,0 +1,20 @@ +package org.openedx.core.domain.model + +import java.util.Date + +data class CourseDatesResponse( + val count: Int, + val next: String?, + val previous: String?, + val results: List +) + +data class CourseDate( + val courseId: String, + val firstComponentBlockId: String, + val dueDate: Date, + val assignmentTitle: String, + val learnerHasAccess: Boolean, + val relative: Boolean, + val courseName: String +) diff --git a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt index d641c79d8..33d884bed 100644 --- a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt @@ -1,6 +1,10 @@ package org.openedx.core.domain.model +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import org.openedx.core.R +import org.openedx.core.ui.theme.appColors enum class DatesSection(val stringResId: Int) { COMPLETED(R.string.core_date_type_completed), @@ -9,5 +13,19 @@ enum class DatesSection(val stringResId: Int) { THIS_WEEK(R.string.core_date_type_this_week), NEXT_WEEK(R.string.core_date_type_next_week), UPCOMING(R.string.core_date_type_upcoming), - NONE(R.string.core_date_type_none) + NONE(R.string.core_date_type_none); + + val color: Color + @Composable + get() { + return when (this) { + COMPLETED -> MaterialTheme.appColors.cardViewBackground + PAST_DUE -> MaterialTheme.appColors.datesSectionBarPastDue + TODAY -> MaterialTheme.appColors.datesSectionBarToday + THIS_WEEK -> MaterialTheme.appColors.datesSectionBarThisWeek + NEXT_WEEK -> MaterialTheme.appColors.datesSectionBarNextWeek + UPCOMING -> MaterialTheme.appColors.datesSectionBarUpcoming + else -> MaterialTheme.appColors.background + } + } } diff --git a/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt b/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt new file mode 100644 index 000000000..016856eb8 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt @@ -0,0 +1,16 @@ +package org.openedx.core.presentation + +enum class ListItemPosition { + FIRST, MIDDLE, LAST, SINGLE; + + companion object { + fun detectPosition(index: Int, list: List): ListItemPosition { + return when { + list.lastIndex == 0 -> SINGLE + index == 0 -> FIRST + index == list.lastIndex -> LAST + else -> MIDDLE + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt new file mode 100644 index 000000000..c57874865 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt @@ -0,0 +1,322 @@ +package org.openedx.core.presentation.dates + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils.formatToString +import org.openedx.core.utils.clearTime +import org.openedx.core.utils.isToday + +@Composable +private fun CourseDateBlockSectionGeneric( + sectionKey: DatesSection = DatesSection.NONE, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.padding(start = 8.dp)) { + if (sectionKey != DatesSection.COMPLETED) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 4.dp), + text = stringResource(id = sectionKey.stringResId), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) // ensures all cards share the height of the tallest one. + ) { + if (sectionKey != DatesSection.COMPLETED) { + DateBullet(section = sectionKey) + } + content() + } + } +} + +@Composable +private fun DateBlockContainer(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 8.dp, end = 8.dp) + ) { + content() + } +} + +@Composable +fun CourseDateBlockSection( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + sectionDates: List, + onItemClick: (CourseDateBlock) -> Unit, +) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { + DateBlock( + dateBlocks = sectionDates, + onItemClick = onItemClick, + useRelativeDates = useRelativeDates + ) + } +} + +@JvmName("CourseDateBlockSectionCourseDates") +@Composable +fun CourseDateBlockSection( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + sectionDates: List, + onItemClick: (CourseDate) -> Unit, +) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { + DateBlock( + dateBlocks = sectionDates, + onItemClick = onItemClick, + useRelativeDates = useRelativeDates + ) + } +} + +@Composable +private fun DateBullet( + section: DatesSection = DatesSection.NONE, +) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .padding(top = 2.dp, bottom = 2.dp) + .background( + color = section.color, + shape = MaterialTheme.shapes.medium + ) + ) +} + +@Composable +private fun DateBlock( + dateBlocks: List, + useRelativeDates: Boolean, + onItemClick: (CourseDateBlock) -> Unit, +) { + DateBlockContainer { + var lastAssignmentDate = dateBlocks.first().date.clearTime() + dateBlocks.forEachIndexed { index, dateBlock -> + val canShowDate = if (index == 0) true else (lastAssignmentDate != dateBlock.date) + CourseDateItem(dateBlock, canShowDate, index != 0, useRelativeDates, onItemClick) + lastAssignmentDate = dateBlock.date + } + } +} + +@JvmName("DateBlockCourseDate") +@Composable +private fun DateBlock( + dateBlocks: List, + useRelativeDates: Boolean, + onItemClick: (CourseDate) -> Unit, +) { + DateBlockContainer { + dateBlocks.forEachIndexed { index, dateBlock -> + CourseDateItem(dateBlock, index != 0, useRelativeDates, onItemClick) + } + } +} + +@Composable +private fun CourseDateItem( + dateBlock: CourseDateBlock, + canShowDate: Boolean, + isMiddleChild: Boolean, + useRelativeDates: Boolean, + onItemClick: (CourseDateBlock) -> Unit, +) { + val context = LocalContext.current + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) { + if (isMiddleChild) { + Spacer(modifier = Modifier.height(20.dp)) + } + if (canShowDate) { + val timeTitle = formatToString(context, dateBlock.date, useRelativeDates) + Text( + text = timeTitle, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + .clickable( + enabled = dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess, + onClick = { onItemClick(dateBlock) } + ) + ) { + dateBlock.dateType.drawableResId?.let { icon -> + Icon( + modifier = Modifier + .padding(end = 4.dp) + .align(Alignment.CenterVertically), + painter = painterResource( + id = if (!dateBlock.learnerHasAccess) { + R.drawable.core_ic_lock + } else { + icon + } + ), + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + } + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = if (!dateBlock.assignmentType.isNullOrEmpty()) { + "${dateBlock.assignmentType}: ${dateBlock.title}" + } else { + dateBlock.title + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(7.dp)) + if (dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.textDark, + contentDescription = "Open Block Arrow", + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + ) + } + } + if (dateBlock.description.isNotEmpty()) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = dateBlock.description, + style = MaterialTheme.appTypography.labelMedium, + ) + } + } +} + +@Composable +private fun CourseDateItem( + dateBlock: CourseDate, + isMiddleChild: Boolean, + useRelativeDates: Boolean, + onItemClick: (CourseDate) -> Unit, +) { + val context = LocalContext.current + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) { + if (isMiddleChild) { + Spacer(modifier = Modifier.height(20.dp)) + } + if (!dateBlock.dueDate.isToday()) { + val timeTitle = formatToString(context, dateBlock.dueDate, useRelativeDates) + Text( + text = timeTitle, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + .clickable( + enabled = dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess, + onClick = { onItemClick(dateBlock) } + ) + ) { + Icon( + modifier = Modifier + .padding(end = 4.dp) + .align(Alignment.CenterVertically), + painter = painterResource(R.drawable.core_ic_assignment), + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = dateBlock.assignmentTitle, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(7.dp)) + if (dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.textDark, + contentDescription = "Open Block Arrow", + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + ) + } + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = dateBlock.courseName, + maxLines = 1, + style = MaterialTheme.appTypography.labelMedium, + ) + } +} 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 eed214567..4230980be 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -214,40 +214,6 @@ fun Toolbar( } } -@Composable -fun MainToolbar( - modifier: Modifier = Modifier, - label: String, - onSettingsClick: () -> Unit, -) { - Box( - modifier = modifier.fillMaxWidth() - ) { - Text( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp), - text = label, - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.headlineBold - ) - IconButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 12.dp), - onClick = { - onSettingsClick() - } - ) { - Icon( - imageVector = Icons.Default.ManageAccounts, - tint = MaterialTheme.appColors.textAccent, - contentDescription = stringResource(id = R.string.core_accessibility_settings) - ) - } - } -} - @Composable fun SearchBar( modifier: Modifier, @@ -1310,6 +1276,51 @@ private fun RoundTab( } } +@Composable +fun MainScreenToolbar( + modifier: Modifier = Modifier, + label: String, + onSettingsClick: () -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + text = label, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp), + onClick = { + onSettingsClick() + } + ) { + Icon( + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, + contentDescription = stringResource(id = R.string.core_accessibility_settings) + ) + } + } +} + +@Preview +@Composable +private fun MainScreenTitlePreview() { + OpenEdXTheme { + MainScreenToolbar( + label = "Title", + onSettingsClick = {} + ) + } +} + @Composable fun OpenEdXDropdownMenuItem( modifier: Modifier = Modifier, 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 f6e39aef3..309db959c 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -55,8 +55,8 @@ val light_success_green = Color(0xFF198571) val light_success_background = Color(0xFF0D7D4D) val light_dates_section_bar_past_due = light_warning val light_dates_section_bar_today = light_info -val light_dates_section_bar_this_week = light_text_primary_variant -val light_dates_section_bar_next_week = light_text_field_border +val light_dates_section_bar_this_week = Color(0xFF3D4964) +val light_dates_section_bar_next_week = Color(0xFF97A5BB) val light_dates_section_bar_upcoming = Color(0xFFCCD4E0) val light_auth_sso_success_background = light_secondary val light_auth_google_button_background = Color.White @@ -131,9 +131,9 @@ val dark_success_green = Color(0xFF198571) val dark_success_background = Color.White val dark_dates_section_bar_past_due = dark_warning val dark_dates_section_bar_today = dark_info -val dark_dates_section_bar_this_week = dark_text_primary_variant -val dark_dates_section_bar_next_week = dark_text_field_border -val dark_dates_section_bar_upcoming = Color(0xFFCCD4E0) +val dark_dates_section_bar_this_week = Color(0xFF8E9BAE) +val dark_dates_section_bar_next_week = Color(0xFF4E5A70) +val dark_dates_section_bar_upcoming = Color(0xFF273346) val dark_auth_sso_success_background = dark_secondary val dark_auth_google_button_background = Color(0xFF19212F) val dark_auth_facebook_button_background = Color(0xFF0866FF) diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 31541459b..80dca9c03 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -64,6 +64,7 @@ import org.openedx.core.NoContentScreenType import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.DatesSection import org.openedx.core.presentation.CoreAnalyticsScreen +import org.openedx.core.presentation.dates.CourseDateBlockSection import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState import org.openedx.core.ui.CircularProgress diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt index d373467a0..13ab7251b 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -34,7 +34,6 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -107,10 +106,6 @@ class CourseContentAllViewModel( getCourseData() } } - - is CourseOpenBlock -> { - _resumeBlockId.emit(event.blockId) - } } } } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index 80c0d5fce..237c8f35a 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -203,7 +203,9 @@ class AllEnrolledCoursesViewModel( dashboardRouter.navigateToCourseOutline( fm = fragmentManager, courseId = courseId, - courseTitle = courseName + courseTitle = courseName, + openTab = "", + resumeBlockId = "" ) } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 780d52569..3e59ee3cd 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -136,6 +136,8 @@ class DashboardListFragment : Fragment() { fm = requireActivity().supportFragmentManager, courseId = it.course.id, courseTitle = it.course.name, + resumeBlockId = "", + openTab = "" ) }, onSwipeRefresh = { diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt index d96744ff1..42251cf05 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -9,8 +9,8 @@ interface DashboardRouter { fm: FragmentManager, courseId: String, courseTitle: String, - openTab: String = "", - resumeBlockId: String = "" + openTab: String, + resumeBlockId: String ) fun navigateToSettings(fm: FragmentManager) diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index b7fe74fd0..1c77ffa72 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -41,7 +41,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.viewBinding -import org.openedx.core.ui.MainToolbar +import org.openedx.core.ui.MainScreenToolbar import org.openedx.core.ui.crop import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -137,7 +137,7 @@ private fun Header( .then(contentWidth), horizontalAlignment = Alignment.CenterHorizontally ) { - MainToolbar( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -240,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - MainToolbar( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = {} ) diff --git a/dates/.gitignore b/dates/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/dates/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/dates/build.gradle b/dates/build.gradle new file mode 100644 index 000000000..605a731bf --- /dev/null +++ b/dates/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" +} + +android { + compileSdk 34 + + defaultConfig { + minSdk 24 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + namespace 'org.openedx.dates' + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + freeCompilerArgs = List.of("-Xstring-concat=inline") + } + + buildFeatures { + viewBinding true + compose true + } + + flavorDimensions += "env" + productFlavors { + prod { + dimension 'env' + } + develop { + dimension 'env' + } + stage { + dimension 'env' + } + } +} + +dependencies { + implementation project(path: ':core') + + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "io.mockk:mockk-android:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" +} diff --git a/dates/consumer-rules.pro b/dates/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/dates/proguard-rules.pro b/dates/proguard-rules.pro new file mode 100644 index 000000000..cdb308aa0 --- /dev/null +++ b/dates/proguard-rules.pro @@ -0,0 +1,7 @@ +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/dates/src/main/AndroidManifest.xml b/dates/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/dates/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt new file mode 100644 index 000000000..f261d312d --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -0,0 +1,38 @@ +package org.openedx.dates.data.repository + +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.CourseDateEntity +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse +import org.openedx.dates.data.storage.DatesDao + +class DatesRepository( + private val api: CourseApi, + private val dao: DatesDao, + private val preferencesManager: CorePreferences +) { + suspend fun getUserDates(page: Int): CourseDatesResponse { + val username = preferencesManager.user?.username ?: "" + val response = api.getUserDates(username, page) + if (page == 1) { + dao.clearCachedData() + } + dao.insertCourseDates(response.results.map { CourseDateEntity.createFrom(it) }) + return response.mapToDomain() + } + + suspend fun getUserDatesFromCache(): List { + return dao.getCourseDates().mapNotNull { it.mapToDomain() } + } + + suspend fun preloadFirstPageCachedDates(): List { + return dao.getCourseDates(PAGE_SIZE).mapNotNull { it.mapToDomain() } + } + + suspend fun shiftAllDueDates() = api.shiftAllDueDates() + + companion object { + private const val PAGE_SIZE = 20 + } +} diff --git a/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt new file mode 100644 index 000000000..e8df66ad2 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -0,0 +1,23 @@ +package org.openedx.dates.data.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.openedx.core.data.model.room.CourseDateEntity + +@Dao +interface DatesDao { + + @Query("SELECT * FROM course_dates_table") + suspend fun getCourseDates(): List + + @Query("SELECT * FROM course_dates_table LIMIT :limit") + suspend fun getCourseDates(limit: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseDates(courseDates: List) + + @Query("DELETE FROM course_dates_table") + suspend fun clearCachedData() +} diff --git a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt new file mode 100644 index 000000000..5bcb8abf1 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -0,0 +1,16 @@ +package org.openedx.dates.domain.interactor + +import org.openedx.dates.data.repository.DatesRepository + +class DatesInteractor( + private val repository: DatesRepository +) { + + suspend fun getUserDates(page: Int) = repository.getUserDates(page) + + suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() + + suspend fun preloadFirstPageCachedDates() = repository.preloadFirstPageCachedDates() + + suspend fun shiftAllDueDates() = repository.shiftAllDueDates() +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt new file mode 100644 index 000000000..1abd002e7 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt @@ -0,0 +1,20 @@ +package org.openedx.dates.presentation + +interface DatesAnalytics { + fun logEvent(event: String, params: Map) +} + +enum class DatesAnalyticsEvent(val eventName: String, val biValue: String) { + ASSIGNMENT_CLICK( + "Dates:Assignment click", + "edx.bi.app.dates.assignment_click" + ), + SHIFT_DUE_DATE_CLICK( + "Dates:Shift due date click", + "edx.bi.app.dates.shift_due_date_click" + ), +} + +enum class DatesAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt new file mode 100644 index 000000000..01e06ed38 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt @@ -0,0 +1,16 @@ +package org.openedx.dates.presentation + +import androidx.fragment.app.FragmentManager + +interface DatesRouter { + + fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + openTab: String, + resumeBlockId: String + ) +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt new file mode 100644 index 000000000..2d28bb389 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -0,0 +1,72 @@ +package org.openedx.dates.presentation.dates + +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.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.theme.OpenEdXTheme + +class DatesFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + DatesScreen( + uiState = uiState, + uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, + useRelativeDates = viewModel.useRelativeDates, + onAction = { action -> + when (action) { + DatesViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DatesViewActions.SwipeRefresh -> { + viewModel.refreshData() + } + + DatesViewActions.LoadMore -> { + viewModel.fetchMore() + } + + DatesViewActions.ShiftDueDate -> { + viewModel.shiftAllDueDates() + } + + is DatesViewActions.OpenEvent -> { + viewModel.navigateToCourseOutline( + requireActivity().supportFragmentManager, + action.date + ) + } + } + } + ) + } + } + } + + companion object { + const val LOAD_MORE_THRESHOLD = 0.8f + } +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt new file mode 100644 index 000000000..21406f700 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -0,0 +1,331 @@ +package org.openedx.dates.presentation.dates + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +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.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +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.derivedStateOf +import androidx.compose.runtime.getValue +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.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.presentation.dates.CourseDateBlockSection +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.MainScreenToolbar +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.dates.R +import org.openedx.dates.presentation.dates.DatesFragment.Companion.LOAD_MORE_THRESHOLD +import org.openedx.foundation.extension.isNotEmptyThenLet +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DatesScreen( + uiState: DatesUIState, + uiMessage: UIMessage?, + hasInternetConnection: Boolean, + useRelativeDates: Boolean, + onAction: (DatesViewActions) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val windowSize = rememberWindowSize() + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { onAction(DatesViewActions.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + val scrollState = rememberLazyListState() + val layoutInfo by remember { derivedStateOf { scrollState.layoutInfo } } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainScreenToolbar( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.dates_title), + onSettingsClick = { + onAction(DatesViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading && uiState.dates.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else if (uiState.dates.isEmpty()) { + EmptyState() + } else { + Box( + modifier = Modifier + .fillMaxSize() + .displayCutoutForLandscape() + .padding(paddingValues) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + LazyColumn( + modifier = contentWidth.fillMaxSize(), + state = scrollState, + contentPadding = PaddingValues(bottom = 48.dp, top = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + uiState.dates.keys.forEach { sectionKey -> + val dates = uiState.dates[sectionKey].orEmpty() + dates.isNotEmptyThenLet { sectionDates -> + val isHavePastRelatedDates = + sectionKey == DatesSection.PAST_DUE && dates.any { it.relative } + if (isHavePastRelatedDates) { + item { + ShiftDueDatesCard( + isButtonEnabled = !uiState.isShiftDueDatesPressed, + onClick = { + onAction(DatesViewActions.ShiftDueDate) + } + ) + } + } + item { + CourseDateBlockSection( + sectionKey = sectionKey, + sectionDates = sectionDates, + onItemClick = { + onAction(DatesViewActions.OpenEvent(it)) + }, + useRelativeDates = useRelativeDates + ) + } + } + } + if (uiState.canLoadMore) { + item { + Box( + Modifier + .fillMaxWidth() + .height(42.dp) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + } + } + val lastVisibleItemIndex = + layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItemsCount = layoutInfo.totalItemsCount + val shouldLoadMore = totalItemsCount > 0 && + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onAction(DatesViewActions.LoadMore) + } + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + PullRefreshIndicator( + uiState.isRefreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DatesViewActions.SwipeRefresh) + } + ) + } + } + } + ) +} + +@Composable +private fun ShiftDueDatesCard( + modifier: Modifier = Modifier, + isButtonEnabled: Boolean, + onClick: () -> Unit +) { + Card( + modifier = modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.cardShape, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.dates_shift_due_date_card_title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.dates_shift_due_date_card_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelLarge, + ) + OpenEdXButton( + text = stringResource(id = R.string.dates_shift_due_date), + enabled = isButtonEnabled, + onClick = onClick + ) + } + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier.size(100.dp), + imageVector = Icons.Outlined.CalendarMonth, + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource(id = R.string.dates_empty_state_title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_description") + .fillMaxWidth(), + text = stringResource(id = R.string.dates_empty_state_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun DatesScreenPreview() { + OpenEdXTheme { + DatesScreen( + uiState = DatesUIState(isLoading = false), + uiMessage = null, + hasInternetConnection = true, + useRelativeDates = true, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun ShiftDueDatesCardPreview() { + OpenEdXTheme { + ShiftDueDatesCard( + isButtonEnabled = true, + onClick = {} + ) + } +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt new file mode 100644 index 000000000..0dd6464b2 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -0,0 +1,12 @@ +package org.openedx.dates.presentation.dates + +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.DatesSection + +data class DatesUIState( + val isLoading: Boolean = true, + val isShiftDueDatesPressed: Boolean = false, + val isRefreshing: Boolean = false, + val canLoadMore: Boolean = false, + val dates: Map> = emptyMap() +) diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt new file mode 100644 index 000000000..518808d49 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt @@ -0,0 +1,279 @@ +package org.openedx.dates.presentation.dates + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +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.R +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.extension.isNotNull +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.isToday +import org.openedx.core.utils.toCalendar +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.dates.domain.interactor.DatesInteractor +import org.openedx.dates.presentation.DatesAnalytics +import org.openedx.dates.presentation.DatesAnalyticsEvent +import org.openedx.dates.presentation.DatesAnalyticsKey +import org.openedx.dates.presentation.DatesRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import java.util.Calendar +import java.util.Date + +class DatesViewModel( + private val datesRouter: DatesRouter, + private val networkConnection: NetworkConnection, + private val resourceManager: ResourceManager, + private val datesInteractor: DatesInteractor, + private val analytics: DatesAnalytics, + private val calendarSyncScheduler: CalendarSyncScheduler, + corePreferences: CorePreferences, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(DatesUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + var useRelativeDates = corePreferences.isRelativeDatesEnabled + + private var page = 1 + private var fetchDataJob: Job? = null + private var lastNavigationTime = 0L + + init { + preloadFirstPageCachedDates() + fetchDates(false) + } + + private fun fetchDates(refresh: Boolean) { + if (refresh) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page = 1 + } + fetchDataJob = viewModelScope.launch { + try { + updateLoadingState(refresh) + val response = datesInteractor.getUserDates(page) + updateUIWithResponse(response, refresh) + } catch (e: Exception) { + page = -1 + updateUIWithCachedResponse() + handleFetchException(e) + } finally { + clearLoadingState() + } + } + } + + private fun updateLoadingState(refresh: Boolean) { + _uiState.update { state -> + state.copy( + isLoading = !refresh, + isRefreshing = refresh + ) + } + } + + private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { + _uiState.update { state -> + if (refresh || page == 1) { + state.copy(dates = groupCourseDates(response.results)) + } else { + val newDates = groupCourseDates(response.results) + state.copy(dates = mergeDates(state.dates, newDates)) + } + } + if (response.next.isNotNull()) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { state -> state.copy(canLoadMore = false) } + } + } + + private suspend fun updateUIWithCachedResponse() { + val cachedList = datesInteractor.getUserDatesFromCache() + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList), + canLoadMore = false + ) + } + } + + private fun preloadFirstPageCachedDates() { + viewModelScope.launch { + val cachedList = datesInteractor.preloadFirstPageCachedDates() + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList), + canLoadMore = true + ) + } + } + } + + private suspend fun handleFetchException(e: Throwable) { + if (e.isInternetError()) { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + ) + } else { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + ) + } + } + + private fun clearLoadingState() { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false + ) + } + } + + fun shiftAllDueDates() { + logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) + viewModelScope.launch { + try { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = true, + ) + } + datesInteractor.shiftAllDueDates() + refreshData() + calendarSyncScheduler.requestImmediateSync() + } catch (e: Exception) { + handleFetchException(e) + } finally { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = false, + ) + } + } + } + } + + fun fetchMore() { + if (!_uiState.value.isLoading && + !_uiState.value.isRefreshing && + _uiState.value.canLoadMore + ) { + fetchDates(false) + } + } + + fun refreshData() { + fetchDataJob?.cancel() + fetchDates(true) + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + datesRouter.navigateToSettings(fragmentManager) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + courseDate: CourseDate, + ) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastNavigationTime < NAVIGATION_DEBOUNCE_MS) { + return + } + lastNavigationTime = currentTime + logEvent(DatesAnalyticsEvent.ASSIGNMENT_CLICK) + datesRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = courseDate.courseId, + courseTitle = courseDate.courseName, + openTab = "", + resumeBlockId = courseDate.firstComponentBlockId + ) + } + + companion object { + private const val NAVIGATION_DEBOUNCE_MS = 500L + } + + private fun groupCourseDates(dates: List): Map> { + val now = Date() + val calendar = Calendar.getInstance().apply { time = now } + return dates.groupBy { courseDate -> + when { + courseDate.dueDate.before(now) -> DatesSection.PAST_DUE + courseDate.dueDate.isToday() -> DatesSection.TODAY + else -> { + val calDue = courseDate.dueDate.toCalendar() + val weekNow = calendar.get(Calendar.WEEK_OF_YEAR) + val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) + val yearNow = calendar.get(Calendar.YEAR) + val yearDue = calDue.get(Calendar.YEAR) + if (weekNow == weekDue && yearNow == yearDue) { + DatesSection.THIS_WEEK + } else if (yearNow == yearDue && weekDue == weekNow + 1) { + DatesSection.NEXT_WEEK + } else { + DatesSection.UPCOMING + } + } + } + } + } + + private fun mergeDates( + oldDates: Map>, + newDates: Map> + ): Map> { + val merged = oldDates.toMutableMap() + newDates.forEach { (section, newList) -> + val existingList = merged[section] ?: emptyList() + merged[section] = existingList + newList + } + return merged + } + + private fun logEvent( + event: DatesAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(DatesAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } +} + +interface DatesViewActions { + object OpenSettings : DatesViewActions + class OpenEvent(val date: CourseDate) : DatesViewActions + object LoadMore : DatesViewActions + object SwipeRefresh : DatesViewActions + object ShiftDueDate : DatesViewActions +} diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml new file mode 100644 index 000000000..93eaa1bc9 --- /dev/null +++ b/dates/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Dates + No Dates + You currently have no active courses with scheduled events. Enroll in a course to view important dates and deadlines. + Missed Some Deadlines? + Don\'t worry - shift our suggested schedule to complete the due assignments without losing any progress. + Shift Due Dates + diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt new file mode 100644 index 000000000..4bb903753 --- /dev/null +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -0,0 +1,378 @@ +package org.openedx.dates + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.fragment.app.FragmentManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.openedx.core.R +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.dates.domain.interactor.DatesInteractor +import org.openedx.dates.presentation.DatesAnalytics +import org.openedx.dates.presentation.DatesRouter +import org.openedx.dates.presentation.dates.DatesViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import java.net.UnknownHostException +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +class DatesViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + private val datesRouter = mockk(relaxed = true) + private val networkConnection = mockk() + private val resourceManager = mockk() + private val datesInteractor = mockk() + private val corePreferences = mockk() + private val calendarSyncScheduler = mockk() + private val analytics = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + + @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 + // By default, assume we have an internet connection + every { networkConnection.isOnline() } returns true + every { corePreferences.isRelativeDatesEnabled } returns true + every { analytics.logEvent(any(), any()) } returns Unit + coEvery { datesInteractor.preloadFirstPageCachedDates() } returns emptyList() + coEvery { datesInteractor.getUserDatesFromCache() } returns emptyList() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `init fetchDates online with pagination`() = runTest { + // Create a dummy CourseDate; grouping is done inside the view model so the exact grouping is not under test. + val courseDate: CourseDate = mockk(relaxed = true) + val courseDatesResponse = CourseDatesResponse( + count = 10, + next = "", + previous = "", + results = listOf(courseDate) + ) + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + + // Instantiate the view model; fetchDates is called in init. + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences, + ) + advanceUntilIdle() + + coVerify(exactly = 1) { datesInteractor.getUserDates(1) } + // Since next is not null and page (1) != count (10), canLoadMore should be true. + assertFalse(viewModel.uiState.value.isLoading) + assertTrue(viewModel.uiState.value.canLoadMore) + } + + @Test + fun `init fetchDates offline uses cache`() = runTest { + every { networkConnection.isOnline() } returns false + val cachedCourseDate: CourseDate = mockk(relaxed = true) + coEvery { datesInteractor.getUserDatesFromCache() } returns listOf(cachedCourseDate) + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + coVerify(exactly = 1) { datesInteractor.getUserDatesFromCache() } + assertFalse(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.canLoadMore) + } + + @Test + fun `fetchDates unknown error emits unknown error message`() = + runTest(UnconfinedTestDispatcher()) { + every { networkConnection.isOnline() } returns true + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + advanceUntilIdle() + + assertEquals(somethingWrong, message.await()?.message) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `fetchDates internet error emits no connection message`() = + runTest(UnconfinedTestDispatcher()) { + every { networkConnection.isOnline() } returns true + coEvery { datesInteractor.getUserDates(any()) } throws UnknownHostException() + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + advanceUntilIdle() + + assertEquals(noInternet, message.await()?.message) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun `shiftAllDueDates success`() = runTest { + every { networkConnection.isOnline() } returns true + // Prepare a dummy CourseDate that qualifies as past due and is marked as relative. + val courseDate: CourseDate = mockk(relaxed = true) { + every { relative } returns true + every { courseId } returns "course-123" + // Set dueDate to yesterday. + every { dueDate } returns Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + val courseDatesResponse = CourseDatesResponse( + count = 1, + next = null, + previous = null, + results = listOf(courseDate) + ) + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + // When refreshData is triggered from shiftDueDate, return the same response. + coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftAllDueDates() + advanceUntilIdle() + + coVerify { datesInteractor.shiftAllDueDates() } + // isShiftDueDatesPressed should be reset to false after processing. + assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) + } + + @Test + fun `shiftAllDueDates error emits error message and resets flag`() = + runTest(UnconfinedTestDispatcher()) { + every { networkConnection.isOnline() } returns true + val courseDate: CourseDate = mockk(relaxed = true) { + every { relative } returns true + every { courseId } returns "course-123" + every { dueDate } returns Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + val courseDatesResponse = CourseDatesResponse( + count = 1, + next = null, + previous = null, + results = listOf(courseDate) + ) + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + coEvery { datesInteractor.shiftAllDueDates() } throws Exception() + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftAllDueDates() + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + } + advanceUntilIdle() + + assertEquals(somethingWrong, message.await()?.message) + assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) + } + + @Test + fun `onSettingsClick navigates to settings`() = runTest { + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val fragmentManager = mockk(relaxed = true) + + viewModel.onSettingsClick(fragmentManager) + verify { datesRouter.navigateToSettings(fragmentManager) } + } + + @Test + fun `navigateToCourseOutline calls router with correct parameters`() = runTest { + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + val fragmentManager = mockk(relaxed = true) + val courseDate: CourseDate = mockk(relaxed = true) { + every { courseId } returns "course-123" + every { courseName } returns "Test Course" + every { firstComponentBlockId } returns "block-1" + } + + viewModel.navigateToCourseOutline(fragmentManager, courseDate) + verify { + datesRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = "course-123", + courseTitle = "Test Course", + openTab = "", + resumeBlockId = "block-1" + ) + } + } + + @Test + fun `fetchMore calls fetchDates when allowed`() = runTest { + every { networkConnection.isOnline() } returns true + val courseDate: CourseDate = mockk(relaxed = true) + val courseDatesResponse = CourseDatesResponse( + count = 10, + next = "", + previous = "", + results = listOf(courseDate) + ) + + // Initial fetch on page 1. + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + // For subsequent fetch, we return a similar response. + coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.fetchMore() + advanceUntilIdle() + + // Expect two calls (one from init and one from fetchMore) + coVerify(exactly = 2) { datesInteractor.getUserDates(any()) } + } + + @Test + fun `refreshData calls fetchDates with refresh true`() = runTest { + every { networkConnection.isOnline() } returns true + val courseDate: CourseDate = mockk(relaxed = true) + val courseDatesResponse = CourseDatesResponse( + count = 1, + next = null, + previous = null, + results = listOf(courseDate) + ) + // Initial fetch. + coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse + // For refresh, return the same response. + coEvery { datesInteractor.getUserDates(any()) } returns courseDatesResponse + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + analytics, + calendarSyncScheduler, + corePreferences + ) + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + // Two calls: one on init, one on refresh. + coVerify(exactly = 2) { datesInteractor.getUserDates(any()) } + // After refresh, isRefreshing should be false. + assertFalse(viewModel.uiState.value.isRefreshing) + } +} diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index a7f265a45..952e041de 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -67,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: false #Platform names PLATFORM_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index a7f265a45..952e041de 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -67,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: false #Platform names PLATFORM_NAME: "OpenEdX" diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt index ae060851c..dafbde1b6 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -78,7 +78,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.DownloadedState.LOADING_COURSE_STRUCTURE import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText -import org.openedx.core.ui.MainToolbar +import org.openedx.core.ui.MainScreenToolbar import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXDropdownMenuItem @@ -130,7 +130,7 @@ fun DownloadsScreen( .fillMaxSize(), backgroundColor = MaterialTheme.appColors.background, topBar = { - MainToolbar( + MainScreenToolbar( modifier = Modifier .statusBarsInset() .displayCutoutForLandscape(), diff --git a/settings.gradle b/settings.gradle index a58940420..eccc1db15 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,4 +46,5 @@ include ':discovery' include ':profile' include ':discussion' include ':whatsnew' +include ':dates' include ':downloads' From 671546a4fc2f0a90bd318654f82d55aaab14d5c7 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:09:53 +0200 Subject: [PATCH 08/16] chore: refactor uiMessage flow (#472) * feat: dates tab UI * feat: added config flag for enabling/disabling dates screen * feat: paging and caching * feat: navigating to block * feat: reuse dates UI from CourseDatesScreen * feat: shift due date card * feat: shift due date request * fix: changes according detekt warnings * feat: pagination * fix: pagination bugs * feat: cache-first logic * fix: changes according code review * feat: according designer feedback * fix: empty state icon * chore: refactor uiMessage flow * feat: dates tab UI * feat: added config flag for enabling/disabling dates screen * feat: paging and caching * feat: navigating to block * feat: reuse dates UI from CourseDatesScreen * feat: shift due date card * feat: shift due date request * fix: changes according detekt warnings * feat: pagination * fix: pagination bugs * feat: cache-first logic * fix: changes according code review * feat: according designer feedback --- .../main/java/org/openedx/app/AppViewModel.kt | 6 +- .../java/org/openedx/app/MainViewModel.kt | 4 +- .../java/org/openedx/app/di/ScreenModule.kt | 216 +++++++++++------- .../test/java/org/openedx/AppViewModelTest.kt | 5 + .../logistration/LogistrationViewModel.kt | 4 +- .../restore/RestorePasswordFragment.kt | 3 +- .../restore/RestorePasswordViewModel.kt | 43 ++-- .../presentation/signin/SignInFragment.kt | 2 +- .../presentation/signin/SignInViewModel.kt | 51 +++-- .../presentation/signup/SignUpViewModel.kt | 40 +--- .../restore/RestorePasswordViewModelTest.kt | 40 ++-- .../signin/SignInViewModelTest.kt | 41 ++-- .../signup/SignUpViewModelTest.kt | 9 +- build.gradle | 2 +- .../module/download/BaseDownloadViewModel.kt | 4 +- .../core/presentation/dates/DatesUI.kt | 3 +- .../SelectDialogViewModel.kt | 6 +- .../settings/video/VideoQualityViewModel.kt | 4 +- .../container/CourseContainerViewModel.kt | 23 +- .../contenttab/ContentTabViewModel.kt | 4 +- .../dates/CourseDatesViewModel.kt | 34 +-- .../handouts/HandoutsViewModel.kt | 4 +- .../presentation/home/CourseHomeViewModel.kt | 35 +-- .../offline/CourseOfflineViewModel.kt | 3 + .../outline/CourseContentAllViewModel.kt | 35 +-- .../progress/CourseProgressViewModel.kt | 12 +- .../section/CourseSectionFragment.kt | 3 +- .../section/CourseSectionViewModel.kt | 20 +- .../container/CourseUnitContainerViewModel.kt | 4 +- .../unit/html/HtmlUnitViewModel.kt | 6 +- .../unit/video/BaseVideoViewModel.kt | 4 +- .../unit/video/EncodedVideoUnitViewModel.kt | 5 +- .../unit/video/VideoUnitViewModel.kt | 4 +- .../presentation/unit/video/VideoViewModel.kt | 4 +- .../videos/CourseVideoViewModel.kt | 12 +- .../download/DownloadQueueViewModel.kt | 5 +- .../container/CourseContainerViewModelTest.kt | 9 +- .../dates/CourseDatesViewModelTest.kt | 13 +- .../handouts/HandoutsViewModelTest.kt | 35 ++- .../home/CourseHomeViewModelTest.kt | 6 +- .../outline/CourseOutlineViewModelTest.kt | 10 +- .../section/CourseSectionViewModelTest.kt | 24 +- .../CourseUnitContainerViewModelTest.kt | 32 ++- .../unit/video/VideoUnitViewModelTest.kt | 7 +- .../unit/video/VideoViewModelTest.kt | 29 ++- .../AllEnrolledCoursesViewModel.kt | 44 +--- .../presentation/DashboardGalleryViewModel.kt | 28 +-- .../presentation/DashboardListFragment.kt | 3 +- .../presentation/DashboardListViewModel.kt | 30 +-- .../learn/presentation/LearnViewModel.kt | 4 +- .../DashboardListViewModelTest.kt | 32 ++- .../presentation/LearnViewModelTest.kt | 50 +++- .../presentation/dates/DatesViewModel.kt | 28 +-- .../org/openedx/dates/DatesViewModelTest.kt | 7 +- default_config/prod/config.yaml | 3 + default_config/stage/config.yaml | 3 + .../presentation/NativeDiscoveryFragment.kt | 3 +- .../presentation/NativeDiscoveryViewModel.kt | 30 +-- .../presentation/WebViewDiscoveryViewModel.kt | 4 +- .../detail/CourseDetailsFragment.kt | 3 +- .../detail/CourseDetailsViewModel.kt | 34 +-- .../presentation/info/CourseInfoViewModel.kt | 15 +- .../presentation/program/ProgramViewModel.kt | 9 +- .../search/CourseSearchFragment.kt | 3 +- .../search/CourseSearchViewModel.kt | 20 +- .../NativeDiscoveryViewModelTest.kt | 42 ++-- .../detail/CourseDetailsViewModelTest.kt | 28 ++- .../search/CourseSearchViewModelTest.kt | 31 ++- .../comments/DiscussionCommentsFragment.kt | 3 +- .../comments/DiscussionCommentsViewModel.kt | 93 +++----- .../responses/DiscussionResponsesFragment.kt | 3 +- .../responses/DiscussionResponsesViewModel.kt | 68 ++---- .../search/DiscussionSearchThreadFragment.kt | 3 +- .../search/DiscussionSearchThreadViewModel.kt | 20 +- .../threads/DiscussionAddThreadFragment.kt | 3 +- .../threads/DiscussionAddThreadViewModel.kt | 25 +- .../threads/DiscussionThreadsFragment.kt | 3 +- .../threads/DiscussionThreadsViewModel.kt | 40 +--- .../topics/DiscussionTopicsViewModel.kt | 17 +- .../DiscussionCommentsViewModelTest.kt | 117 ++++++---- .../DiscussionResponsesViewModelTest.kt | 80 ++++--- .../DiscussionSearchThreadViewModelTest.kt | 28 ++- .../DiscussionAddThreadViewModelTest.kt | 20 +- .../threads/DiscussionThreadsViewModelTest.kt | 34 +-- .../topics/DiscussionTopicsViewModelTest.kt | 6 +- .../download/DownloadsViewModel.kt | 19 +- .../downloads/DownloadsViewModelTest.kt | 10 +- .../AnothersProfileFragment.kt | 3 +- .../AnothersProfileViewModel.kt | 19 +- .../calendar/CalendarViewModel.kt | 4 +- .../calendar/CoursesToSyncViewModel.kt | 36 +-- .../DisableCalendarSyncDialogViewModel.kt | 4 +- .../calendar/NewCalendarDialogFragment.kt | 11 +- .../calendar/NewCalendarDialogViewModel.kt | 31 ++- .../delete/DeleteProfileFragment.kt | 2 +- .../delete/DeleteProfileViewModel.kt | 18 +- .../presentation/edit/EditProfileFragment.kt | 3 +- .../presentation/edit/EditProfileViewModel.kt | 29 +-- .../manageaccount/ManageAccountViewModel.kt | 28 +-- .../presentation/profile/ProfileFragment.kt | 2 +- .../presentation/profile/ProfileViewModel.kt | 19 +- .../settings/SettingsViewModel.kt | 24 +- .../video/VideoSettingsViewModel.kt | 4 +- .../edit/EditProfileViewModelTest.kt | 33 +-- .../profile/AnothersProfileViewModelTest.kt | 22 +- .../profile/CalendarViewModelTest.kt | 14 +- .../profile/ProfileViewModelTest.kt | 26 ++- .../whatsnew/WhatsNewViewModel.kt | 4 +- .../openedx/whatsnew/WhatsNewViewModelTest.kt | 5 +- 109 files changed, 1085 insertions(+), 1167 deletions(-) diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index e195a7940..bafddb19b 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -29,6 +29,7 @@ import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Directories import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @SuppressLint("StaticFieldLeak") @@ -42,8 +43,9 @@ class AppViewModel( private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, private val downloadNotifier: DownloadNotifier, - private val context: Context -) : BaseViewModel() { + private val context: Context, + resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _logoutUser = SingleEventLiveData() val logoutUser: LiveData diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 74f309e68..828b14a39 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -18,13 +18,15 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class MainViewModel( private val config: Config, private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, private val appNotifier: AppNotifier, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _isBottomBarEnabled = MutableLiveData(true) val isBottomBarEnabled: LiveData 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 f2d531918..45a4ecc25 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -1,6 +1,6 @@ package org.openedx.app.di -import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AppViewModel @@ -87,19 +87,20 @@ val screenModule = module { viewModel { AppViewModel( - get(), - get(), - get(), - get(), - get(named("IODispatcher")), - get(), - get(), - get(), - get(), - get(), + config = get(), + appNotifier = get(), + room = get(), + preferencesManager = get(), + dispatcher = get(named("IODispatcher")), + analytics = get(), + deepLinkRouter = get(), + fileUtil = get(), + downloadNotifier = get(), + context = get(), + resourceManager = get(), ) } - viewModel { MainViewModel(get(), get(), get(), get()) } + viewModel { MainViewModel(get(), get(), get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } @@ -112,6 +113,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } @@ -159,20 +161,30 @@ val screenModule = module { viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (windowSize: WindowSize) -> DashboardGalleryViewModel( - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - windowSize + config = get(), + interactor = get(), + resourceManager = get(), + discoveryNotifier = get(), + networkConnection = get(), + fileUtil = get(), + dashboardRouter = get(), + corePreferences = get(), + windowSize = windowSize + ) + } + viewModel { + AllEnrolledCoursesViewModel( + config = get(), + networkConnection = get(), + interactor = get(), + resourceManager = get(), + discoveryNotifier = get(), + analytics = get(), + dashboardRouter = get(), ) } - viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { (openTab: String) -> - LearnViewModel(openTab, get(), get(), get()) + LearnViewModel(openTab, get(), get(), get(), get()) } factory { DiscoveryRepository(get(), get(), get()) } @@ -180,13 +192,14 @@ val screenModule = module { viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (querySearch: String) -> WebViewDiscoveryViewModel( - querySearch, - get(), - get(), - get(), - get(), - get(), - get(), + querySearch = querySearch, + appData = get(), + config = get(), + networkConnection = get(), + corePreferences = get(), + router = get(), + analytics = get(), + resourceManager = get(), ) } @@ -211,8 +224,16 @@ val screenModule = module { account ) } - viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } - viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } + viewModel { VideoSettingsViewModel(get(), get(), get(), get(), get()) } + viewModel { (qualityType: String) -> + VideoQualityViewModel( + qualityType, + get(), + get(), + get(), + get() + ) + } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } viewModel { @@ -230,11 +251,19 @@ val screenModule = module { get(), ) } - viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } - viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { + ManageAccountViewModel( + interactor = get(), + resourceManager = get(), + notifier = get(), + analytics = get(), + profileRouter = get(), + ) + } + viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { CoursesToSyncViewModel(get(), get(), get(), get()) } viewModel { NewCalendarDialogViewModel(get(), get(), get(), get(), get(), get()) } - viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get()) } + viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get(), get()) } factory { CalendarRepository(get(), get(), get()) } factory { CalendarInteractor(get()) } @@ -312,6 +341,7 @@ val screenModule = module { courseId, courseTitle, get(), + get(), ) } viewModel { (courseId: String, courseTitle: String) -> @@ -355,30 +385,31 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String) -> CourseVideoViewModel( - courseId, - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), + courseId = courseId, + config = get(), + interactor = get(), + resourceManager = get(), + networkConnection = get(), + preferencesManager = get(), + courseNotifier = get(), + downloadDialogManager = get(), + fileUtil = get(), + courseRouter = get(), + analytics = get(), + videoPreviewHelper = get(), + coreAnalytics = get(), + downloadDao = get(), + workerController = get(), + downloadHelper = get(), ) } - viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } - viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } + viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get(), get()) } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get(), get()) } viewModel { (courseId: String, videoUrl: String, blockId: String) -> VideoUnitViewModel( courseId, @@ -388,7 +419,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String, videoUrl: String, blockId: String) -> @@ -403,35 +435,37 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String, enrollmentMode: String) -> CourseDatesViewModel( - courseId, - enrollmentMode, - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), + courseId = courseId, + enrollmentMode = enrollmentMode, + courseNotifier = get(), + interactor = get(), + courseAnalytics = get(), + config = get(), + calendarInteractor = get(), + calendarNotifier = get(), + corePreferences = get(), + courseRouter = get(), + calendarRouter = get(), + resourceManager = get(), ) } viewModel { (courseId: String, handoutsType: String) -> HandoutsViewModel( courseId, handoutsType, - get(), - get(), - get(), + config = get(), + interactor = get(), + courseAnalytics = get(), + resourceManager = get(), ) } viewModel { CourseSearchViewModel(get(), get(), get(), get(), get()) } - viewModel { SelectDialogViewModel(get()) } + viewModel { SelectDialogViewModel(get(), get()) } single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } @@ -439,11 +473,11 @@ val screenModule = module { DiscussionTopicsViewModel( courseId, courseTitle, - get(), - get(), - get(), - get(), - get() + interactor = get(), + resourceManager = get(), + analytics = get(), + courseNotifier = get(), + discussionRouter = get(), ) } viewModel { (courseId: String, topicId: String, threadType: String) -> @@ -491,6 +525,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } @@ -503,6 +538,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (blockId: String, courseId: String) -> @@ -515,10 +551,22 @@ val screenModule = module { get(), get(), get(), + get() ) } - viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { + ProgramViewModel( + appData = get(), + config = get(), + networkConnection = get(), + router = get(), + notifier = get(), + edxCookieManager = get(), + resourceManager = get(), + interactor = get(), + ) + } viewModel { (courseId: String, courseTitle: String) -> CourseOfflineViewModel( @@ -533,6 +581,7 @@ val screenModule = module { get(), get(), get(), + get(), get() ) } @@ -540,7 +589,8 @@ val screenModule = module { CourseProgressViewModel( courseId, get(), - get() + get(), + get(), ) } @@ -562,19 +612,19 @@ val screenModule = module { downloadsRouter = get(), networkConnection = get(), interactor = get(), + downloadDialogManager = get(), resourceManager = get(), + fileUtil = get(), config = get(), + analytics = get(), + discoveryNotifier = get(), + courseNotifier = get(), + router = get(), preferencesManager = get(), coreAnalytics = get(), downloadDao = get(), workerController = get(), downloadHelper = get(), - downloadDialogManager = get(), - fileUtil = get(), - analytics = get(), - discoveryNotifier = get(), - courseNotifier = get(), - router = get() ) } viewModel { (courseId: String) -> diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 0271aace3..0dca83a93 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -32,6 +32,7 @@ import org.openedx.core.config.FirebaseConfig import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent +import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @ExperimentalCoroutinesApi @@ -51,6 +52,7 @@ class AppViewModelTest { private val deepLinkRouter = mockk() private val context = mockk() private val downloadNotifier = mockk() + private val resourceManager = mockk() @Before fun before() { @@ -82,6 +84,7 @@ class AppViewModelTest { fileUtil, downloadNotifier, context, + resourceManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -118,6 +121,7 @@ class AppViewModelTest { fileUtil, downloadNotifier, context, + resourceManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -156,6 +160,7 @@ class AppViewModelTest { fileUtil, downloadNotifier, context, + resourceManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index d7ca6e894..0ea8a2f91 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -13,6 +13,7 @@ import org.openedx.core.config.Config import org.openedx.core.utils.Logger import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class LogistrationViewModel( private val courseId: String, @@ -20,7 +21,8 @@ class LogistrationViewModel( private val config: Config, private val analytics: AuthAnalytics, private val browserAuthHelper: BrowserAuthHelper, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val logger = Logger("LogistrationViewModel") diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index 81d216c39..adb8da725 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -28,6 +28,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -90,7 +91,7 @@ class RestorePasswordFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState(RestorePasswordUIState.Initial) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val appUpgradeEvent by viewModel.appUpgradeEventUIState.observeAsState(null) if (appUpgradeEvent == null) { diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt index 6c5e3adf1..53e7be439 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt @@ -4,18 +4,16 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch +import org.openedx.auth.R import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey -import org.openedx.core.R import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.foundation.extension.isEmailValid -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -24,16 +22,12 @@ class RestorePasswordViewModel( private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, private val appNotifier: AppNotifier -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _appUpgradeEvent = MutableLiveData() val appUpgradeEventUIState: LiveData get() = _appUpgradeEvent @@ -53,33 +47,30 @@ class RestorePasswordViewModel( logResetPasswordEvent(true) } else { _uiState.value = RestorePasswordUIState.Initial - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + handleErrorUiMessage( + throwable = null, + ) logResetPasswordEvent(false) } } else { _uiState.value = RestorePasswordUIState.Initial - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) - ) + handleErrorUiMessage( + throwable = null, + defaultErrorRes = R.string.auth_invalid_email, + ) logResetPasswordEvent(false) } } catch (e: Exception) { _uiState.value = RestorePasswordUIState.Initial logResetPasswordEvent(false) - if (e is EdxError.ValidationException) { - _uiMessage.value = UIMessage.SnackBarMessage(e.error) - } else if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) + when (e) { + is EdxError.ValidationException -> sendMessage( + UIMessage.SnackBarMessage(e.error) + ) + + else -> handleErrorUiMessage( + throwable = e, + ) } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index e5da6fbd9..e271b9044 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -40,7 +40,7 @@ class SignInFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() val state by viewModel.uiState.collectAsState() - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) if (appUpgradeEvent == null) { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index f271927e1..11cfa2b67 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -35,10 +35,7 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as CoreRes @@ -60,7 +57,7 @@ class SignInViewModel( val courseId: String?, val infoType: String?, val authCode: String, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val logger = Logger("SignInViewModel") @@ -79,10 +76,6 @@ class SignInViewModel( ) internal val uiState: StateFlow = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _appUpgradeEvent = MutableLiveData() val appUpgradeEvent: LiveData get() = _appUpgradeEvent @@ -95,13 +88,21 @@ class SignInViewModel( fun login(username: String, password: String) { logEvent(AuthAnalyticsEvent.USER_SIGN_IN_CLICKED) if (!validator.isEmailOrUserNameValid(username)) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_email_username)) + viewModelScope.launch { + handleErrorUiMessage( + throwable = null, + defaultErrorRes = R.string.auth_invalid_email_username, + ) + } return } if (!validator.isPasswordValid(password)) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_password)) + viewModelScope.launch { + handleErrorUiMessage( + throwable = null, + defaultErrorRes = R.string.auth_invalid_password, + ) + } return } @@ -126,15 +127,15 @@ class SignInViewModel( ) appNotifier.send(SignInEvent()) } catch (e: Exception) { - if (e is EdxError.InvalidGrantException) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_invalid_grant)) - } else if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_unknown_error)) + when (e) { + is EdxError.InvalidGrantException -> handleErrorUiMessage( + throwable = null, + defaultErrorRes = CoreRes.string.core_error_invalid_grant, + ) + + else -> handleErrorUiMessage( + throwable = e, + ) } } _uiState.update { it.copy(showProgress = false) } @@ -228,9 +229,11 @@ class SignInViewModel( message?.let { logger.e { it() } } - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(CoreRes.string.core_error_unknown_error) - ) + viewModelScope.launch { + handleErrorUiMessage( + throwable = null, + ) + } _uiState.update { it.copy(showProgress = false) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 21e12029e..07987c90c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -4,10 +4,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -31,11 +28,8 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager -import org.openedx.core.R as coreR class SignUpViewModel( private val interactor: AuthInteractor, @@ -49,7 +43,7 @@ class SignUpViewModel( private val router: AuthRouter, val courseId: String?, val infoType: String?, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val logger = Logger("SignUpViewModel") @@ -64,13 +58,6 @@ class SignUpViewModel( ) val uiState = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val uiMessage = _uiMessage.asSharedFlow() - init { collectAppUpgradeEvent() logRegisterScreenEvent() @@ -82,19 +69,9 @@ class SignUpViewModel( try { updateFields(interactor.getRegistrationFields()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(coreR.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(coreR.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } finally { _uiState.update { state -> state.copy(isLoading = false) @@ -212,12 +189,9 @@ class SignUpViewModel( private suspend fun handleRegistrationError(e: Exception) { _uiState.update { it.copy(isButtonLoading = false) } - val errorMessage = if (e.isInternetError()) { - coreR.string.core_error_no_connection - } else { - coreR.string.core_error_unknown_error - } - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + handleErrorUiMessage( + throwable = e, + ) } fun socialAuth(fragment: Fragment, authType: AuthType) { diff --git a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt index 4e780121d..3dcb4aa18 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt @@ -22,12 +22,13 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.R import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class RestorePasswordViewModelTest { @@ -56,8 +57,12 @@ class RestorePasswordViewModelTest { @Before fun before() { 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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) } returns invalidEmail every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_password) } returns invalidPassword every { appNotifier.notifier } returns emptyFlow() @@ -80,10 +85,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(invalidEmail, message?.message) + assertEquals(invalidEmail, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -98,10 +103,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(invalidEmail, message?.message) + assertEquals(invalidEmail, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -116,10 +121,9 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals("error", message?.message) + assertEquals("error", (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -134,10 +138,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(noInternet, message?.message) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -152,10 +156,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -170,10 +174,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -189,10 +193,10 @@ class RestorePasswordViewModelTest { verify(exactly = 1) { appNotifier.notifier } val state = viewModel.uiState.value as? RestorePasswordUIState.Success - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(correctEmail, state?.email) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Success) - assertEquals(null, message) + assertEquals(null, message.await()) } } diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index dee9bde38..b91f8774f 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -43,9 +43,11 @@ import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import org.openedx.core.R as CoreRes +import org.openedx.foundation.R as foundationR @ExperimentalCoroutinesApi class SignInViewModelTest { @@ -80,8 +82,12 @@ class SignInViewModelTest { fun before() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(CoreRes.string.core_error_invalid_grant) } returns invalidCredential - every { resourceManager.getString(CoreRes.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(CoreRes.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { resourceManager.getString(R.string.auth_invalid_email_username) } returns invalidEmailOrUsername every { resourceManager.getString(R.string.auth_invalid_password) } returns invalidPassword every { appNotifier.notifier } returns emptyFlow() @@ -137,9 +143,9 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) val uiState = viewModel.uiState.value - assertEquals(invalidEmailOrUsername, message.message) + assertEquals(invalidEmailOrUsername, (message.await() as UIMessage.SnackBarMessage).message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -173,9 +179,9 @@ class SignInViewModelTest { coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) val uiState = viewModel.uiState.value - assertEquals(invalidEmailOrUsername, message.message) + assertEquals(invalidEmailOrUsername, (message.await() as UIMessage.SnackBarMessage).message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -211,9 +217,9 @@ class SignInViewModelTest { verify(exactly = 0) { analytics.setUserIdForSession(any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) val uiState = viewModel.uiState.value - assertEquals(invalidPassword, message.message) + assertEquals(invalidPassword, (message.await() as UIMessage.SnackBarMessage).message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -251,9 +257,9 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(invalidPassword, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value - assertEquals(invalidPassword, message.message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -297,7 +303,8 @@ class SignInViewModelTest { val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assert(uiState.loginSuccess) - assertEquals(null, viewModel.uiMessage.value) + val message = captureUiMessage(viewModel) + assertEquals(null, message.await()) } @Test @@ -336,11 +343,11 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) - assertEquals(noInternet, message?.message) } @Test @@ -379,11 +386,11 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(invalidCredential, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) - assertEquals(invalidCredential, message.message) } @Test @@ -422,10 +429,10 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) - assertEquals(somethingWrong, message.message) } } diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index 933c57234..6a6a4b2a2 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -46,6 +46,7 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @ExperimentalCoroutinesApi class SignUpViewModelTest { @@ -107,8 +108,12 @@ class SignUpViewModelTest { fun before() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_invalid_grant) } returns "Invalid credentials" - every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(false) } returns null every { config.isSocialAuthEnabled() } returns false diff --git a/build.gradle b/build.gradle index 674a1057f..8570eb77d 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ buildscript { play_services_ads_identifier_version = '18.2.0' install_referrer_version = '2.2' snakeyaml_version = '2.4' - openedx_foundation_version = '1.0.2' + openedx_foundation_version = '1.1.0' openedx_firebase_analytics_version = '1.0.1' braze_sdk_version = '37.0.0' 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 ba87e6ab0..0180a4845 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 @@ -17,6 +17,7 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager abstract class BaseDownloadViewModel( private val downloadDao: DownloadDao, @@ -24,7 +25,8 @@ abstract class BaseDownloadViewModel( private val workerController: DownloadWorkerController, private val analytics: CoreAnalytics, private val downloadHelper: DownloadHelper, -) : BaseViewModel() { + resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { val allBlocks = hashMapOf() diff --git a/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt index c57874865..2833998f9 100644 --- a/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt @@ -36,6 +36,7 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime import org.openedx.core.utils.isToday +import java.util.Date @Composable private fun CourseDateBlockSectionGeneric( @@ -262,7 +263,7 @@ private fun CourseDateItem( if (isMiddleChild) { Spacer(modifier = Modifier.height(20.dp)) } - if (!dateBlock.dueDate.isToday()) { + if (!dateBlock.dueDate.isToday() || dateBlock.dueDate < Date()) { val timeTitle = formatToString(context, dateBlock.dueDate, useRelativeDates) Text( text = timeTitle, diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt index f215974ce..db17aa625 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt @@ -6,10 +6,12 @@ import org.openedx.core.domain.model.RegistrationField import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSubtitleLanguageChanged import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class SelectDialogViewModel( - private val notifier: CourseNotifier -) : BaseViewModel() { + private val notifier: CourseNotifier, + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { var values = mutableListOf() diff --git a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt index 95ecca130..05fc6077e 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt @@ -12,13 +12,15 @@ import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class VideoQualityViewModel( private val qualityType: String, private val preferencesManager: CorePreferences, private val notifier: VideoNotifier, private val analytics: CoreAnalytics, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _videoQuality = MutableLiveData() val videoQuality: LiveData 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 ff9643bd4..98501ae1e 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 @@ -8,11 +8,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope 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.catch import kotlinx.coroutines.flow.combine @@ -54,7 +51,6 @@ import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.util.concurrent.atomic.AtomicReference import org.openedx.core.R as CoreR @@ -73,7 +69,7 @@ class CourseContainerViewModel( private val imageProcessor: ImageProcessor, private val calendarSyncScheduler: CalendarSyncScheduler, val courseRouter: CourseRouter, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _dataReady = MutableLiveData() val dataReady: LiveData @@ -99,10 +95,6 @@ class CourseContainerViewModel( val isNavigationEnabled: StateFlow = _isNavigationEnabled.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private var _courseDetails: CourseEnrollmentDetails? = null val courseDetails: CourseEnrollmentDetails? get() = _courseDetails @@ -150,7 +142,7 @@ class CourseContainerViewModel( is CourseDatesShifted -> { calendarSyncScheduler.requestImmediateSync(courseId) - _uiMessage.emit(DatesShiftedSnackBar()) + sendMessage(DatesShiftedSnackBar()) } is CourseLoading -> { @@ -249,7 +241,9 @@ class CourseContainerViewModel( private fun handleFetchError(e: Throwable) { e.printStackTrace() if (isNetworkRelatedError(e)) { - _errorMessage.value = resourceManager.getString(CoreR.string.core_error_no_connection) + _errorMessage.value = resolveErrorMessage( + throwable = e, + ) } else { _courseAccessStatus.value = CourseAccessError.UNKNOWN } @@ -320,9 +314,10 @@ class CourseContainerViewModel( viewModelScope.launch { try { interactor.getCourseStructure(courseId, isNeedRefresh = true) - } catch (_: Exception) { - _errorMessage.value = - resourceManager.getString(CoreR.string.core_error_unknown_error) + } catch (e: Exception) { + _errorMessage.value = resolveErrorMessage( + throwable = e, + ) } _refreshing.value = false courseNotifier.send(CourseStructureUpdated(courseId)) diff --git a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt index 7aebe86f3..3ec98e6bf 100644 --- a/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/contenttab/ContentTabViewModel.kt @@ -5,12 +5,14 @@ import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.container.CourseContentTab import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class ContentTabViewModel( val courseId: String, private val courseTitle: String, private val analytics: CourseAnalytics, -) : BaseViewModel() { + resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { fun logTabClickEvent(contentTab: CourseContentTab) { analytics.logEvent( 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 91b5c6ee5..c059d1e73 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 @@ -1,11 +1,8 @@ package org.openedx.course.presentation.dates 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 @@ -35,24 +32,22 @@ import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager -import org.openedx.core.R as CoreR class CourseDatesViewModel( val courseId: String, private val enrollmentMode: String, private val courseNotifier: CourseNotifier, private val interactor: CourseInteractor, - private val resourceManager: ResourceManager, private val courseAnalytics: CourseAnalytics, private val config: Config, private val calendarInteractor: CalendarInteractor, private val calendarNotifier: CalendarNotifier, private val corePreferences: CorePreferences, val courseRouter: CourseRouter, - val calendarRouter: CalendarRouter -) : BaseViewModel() { + val calendarRouter: CalendarRouter, + resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { var isSelfPaced = true var useRelativeDates = corePreferences.isRelativeDatesEnabled @@ -61,10 +56,6 @@ class CourseDatesViewModel( val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private var courseBannerType: CourseBannerType = CourseBannerType.BLANK private var courseStructure: CourseStructure? = null @@ -112,8 +103,8 @@ class CourseDatesViewModel( } catch (e: Exception) { _uiState.value = CourseDatesUIState.Error if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) + handleErrorUiMessage( + throwable = e, ) } } finally { @@ -130,17 +121,10 @@ class CourseDatesViewModel( courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) - ) - ) - } + handleErrorUiMessage( + throwable = e, + defaultErrorRes = R.string.core_dates_shift_dates_unsuccessful_msg, + ) onResetDates(false) } } diff --git a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt index 85fad2512..38ea669bc 100644 --- a/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/handouts/HandoutsViewModel.kt @@ -13,6 +13,7 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class HandoutsViewModel( private val courseId: String, @@ -20,7 +21,8 @@ class HandoutsViewModel( private val config: Config, private val interactor: CourseInteractor, private val courseAnalytics: CourseAnalytics, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { val apiHostUrl get() = config.getApiHostURL() diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index 7d1381505..bd72bc19e 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -45,7 +45,6 @@ import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.unit.container.CourseViewMode -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -74,7 +73,8 @@ class CourseHomeViewModel( preferencesManager, workerController, coreAnalytics, - downloadHelper + downloadHelper, + resourceManager, ) { val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled @@ -82,10 +82,6 @@ class CourseHomeViewModel( val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private val _resumeBlockId = MutableSharedFlow() val resumeBlockId: SharedFlow get() = _resumeBlockId.asSharedFlow() @@ -155,7 +151,7 @@ class CourseHomeViewModel( super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { - _uiMessage.emit( + sendMessage( UIMessage.ToastMessage( resourceManager.getString(courseR.string.course_can_download_only_with_wifi) ) @@ -282,11 +278,9 @@ class CourseHomeViewModel( private suspend fun handleCourseDataError(e: Throwable?) { _uiState.value = CourseHomeUIState.Error - val errorMessage = when { - e?.isInternetError() == true -> R.string.core_error_no_connection - else -> R.string.core_error_unknown_error - } - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + handleErrorUiMessage( + throwable = e, + ) } private fun sortBlocks(blocks: List): List { @@ -379,19 +373,10 @@ class CourseHomeViewModel( courseNotifier.send(CourseDatesShifted) onResetDates(true) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) - ) - ) - } + handleErrorUiMessage( + throwable = e, + defaultErrorRes = R.string.core_dates_shift_dates_unsuccessful_msg, + ) onResetDates(false) } } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 58fd12af6..311841c91 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -28,6 +28,7 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureGot import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil class CourseOfflineViewModel( @@ -39,6 +40,7 @@ class CourseOfflineViewModel( private val fileUtil: FileUtil, private val networkConnection: NetworkConnection, private val courseNotifier: CourseNotifier, + private val resourceManager: ResourceManager, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -49,6 +51,7 @@ class CourseOfflineViewModel( workerController, coreAnalytics, downloadHelper, + resourceManager, ) { private val _uiState = MutableStateFlow( CourseOfflineUIState( diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt index 13ab7251b..a30cde02f 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -41,7 +41,6 @@ import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.unit.container.CourseViewMode -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -69,7 +68,8 @@ class CourseContentAllViewModel( preferencesManager, workerController, coreAnalytics, - downloadHelper + downloadHelper, + resourceManager, ) { val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled @@ -78,10 +78,6 @@ class CourseContentAllViewModel( val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private val _resumeBlockId = MutableSharedFlow() val resumeBlockId: SharedFlow get() = _resumeBlockId.asSharedFlow() @@ -138,7 +134,7 @@ class CourseContentAllViewModel( super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { - _uiMessage.emit( + sendMessage( UIMessage.ToastMessage( resourceManager.getString(courseR.string.course_can_download_only_with_wifi) ) @@ -236,11 +232,9 @@ class CourseContentAllViewModel( private suspend fun handleCourseDataError(e: Throwable?) { _uiState.value = CourseContentAllUIState.Error - val errorMessage = when { - e?.isInternetError() == true -> R.string.core_error_no_connection - else -> R.string.core_error_unknown_error - } - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + handleErrorUiMessage( + throwable = e, + ) } private fun sortBlocks(blocks: List): List { @@ -291,19 +285,10 @@ class CourseContentAllViewModel( getCourseData() courseNotifier.send(CourseDatesShifted) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) - ) - ) - } + handleErrorUiMessage( + throwable = e, + defaultErrorRes = R.string.core_dates_shift_dates_unsuccessful_msg, + ) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt index 805f486d1..395ad82f9 100644 --- a/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/progress/CourseProgressViewModel.kt @@ -1,11 +1,8 @@ package org.openedx.course.presentation.progress 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.catch import kotlinx.coroutines.flow.combine @@ -17,22 +14,19 @@ import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshProgress import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class CourseProgressViewModel( val courseId: String, private val interactor: CourseInteractor, private val courseNotifier: CourseNotifier, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _uiState = MutableStateFlow(CourseProgressUIState.Loading) val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - init { collectData(false) collectCourseNotifier() diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 7bfe8a24c..f5c7daec4 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -31,6 +31,7 @@ import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -102,7 +103,7 @@ class CourseSectionFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState(CourseSectionUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) CourseSectionScreen( windowSize = windowSize, uiState = uiState, diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt index 8966ee45e..6e88a28fa 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch import org.openedx.core.BlockType -import org.openedx.core.R import org.openedx.core.domain.model.Block import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged @@ -15,10 +14,7 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.unit.container.CourseViewMode -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class CourseSectionViewModel( @@ -27,16 +23,12 @@ class CourseSectionViewModel( private val resourceManager: ResourceManager, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData(CourseSectionUIState.Loading) val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - var mode = CourseViewMode.FULL override fun onCreate(owner: LifecycleOwner) { @@ -68,13 +60,9 @@ class CourseSectionViewModel( sectionName = sequentialBlock.displayName ) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 81382f9f3..7ae122b48 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -29,6 +29,7 @@ import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.foundation.extension.clearAndAddAll import org.openedx.foundation.extension.indexOfFirstFromIndex import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class CourseUnitContainerViewModel( val courseId: String, @@ -40,7 +41,8 @@ class CourseUnitContainerViewModel( private val analytics: CourseAnalytics, private val networkConnection: NetworkConnection, private val videoPreviewHelper: VideoPreviewHelper, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val blocks = ArrayList() diff --git a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt index 702082746..2e18ccf5c 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/html/HtmlUnitViewModel.kt @@ -16,6 +16,7 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.foundation.extension.readAsText import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class HtmlUnitViewModel( private val blockId: String, @@ -25,8 +26,9 @@ class HtmlUnitViewModel( private val networkConnection: NetworkConnection, private val notifier: CourseNotifier, private val courseInteractor: CourseInteractor, - private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler -) : BaseViewModel() { + private val offlineProgressSyncScheduler: OfflineProgressSyncScheduler, + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _uiState = MutableStateFlow(HtmlUnitUIState.Initialization) val uiState = _uiState.asStateFlow() diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt index 7c67329e6..5344d8787 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/BaseVideoViewModel.kt @@ -4,11 +4,13 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager open class BaseVideoViewModel( private val courseId: String, private val courseAnalytics: CourseAnalytics, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { fun logVideoSpeedEvent(videoUrl: String, speed: Float, currentVideoTime: Long, medium: String) { logVideoEvent( diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 2c2816bc9..87ce7fd2b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -30,6 +30,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.data.repository.CourseRepository import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.foundation.system.ResourceManager import java.util.concurrent.Executors @SuppressLint("StaticFieldLeak") @@ -44,6 +45,7 @@ class EncodedVideoUnitViewModel( networkConnection: NetworkConnection, transcriptManager: TranscriptManager, courseAnalytics: CourseAnalytics, + resourceManager: ResourceManager, ) : VideoUnitViewModel( courseId, videoUrl, @@ -52,7 +54,8 @@ class EncodedVideoUnitViewModel( notifier, networkConnection, transcriptManager, - courseAnalytics + courseAnalytics, + resourceManager, ) { private val _isVideoEnded = MutableLiveData(false) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt index bd9199942..e1f2ac93f 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoUnitViewModel.kt @@ -17,6 +17,7 @@ import org.openedx.core.system.notifier.CourseSubtitleLanguageChanged import org.openedx.core.system.notifier.CourseVideoPositionChanged import org.openedx.course.data.repository.CourseRepository import org.openedx.course.presentation.CourseAnalytics +import org.openedx.foundation.system.ResourceManager import subtitleFile.TimedTextObject open class VideoUnitViewModel( @@ -28,7 +29,8 @@ open class VideoUnitViewModel( private val networkConnection: NetworkConnection, private val transcriptManager: TranscriptManager, courseAnalytics: CourseAnalytics, -) : BaseVideoViewModel(courseId, courseAnalytics) { + resourceManager: ResourceManager, +) : BaseVideoViewModel(courseId, courseAnalytics, resourceManager) { var transcripts = emptyMap() var isPlaying = true diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index c9da7aaec..c0aa13723 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -9,6 +9,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseVideoPositionChanged import org.openedx.course.data.repository.CourseRepository import org.openedx.course.presentation.CourseAnalytics +import org.openedx.foundation.system.ResourceManager class VideoViewModel( private val courseId: String, @@ -16,7 +17,8 @@ class VideoViewModel( private val notifier: CourseNotifier, private val preferencesManager: CorePreferences, courseAnalytics: CourseAnalytics, -) : BaseVideoViewModel(courseId, courseAnalytics) { + resourceManager: ResourceManager, +) : BaseVideoViewModel(courseId, courseAnalytics, resourceManager) { var videoUrl = "" var currentVideoTime = 0L 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 456669cc0..b428404f5 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -3,11 +3,8 @@ package org.openedx.course.presentation.videos import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -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 @@ -59,15 +56,12 @@ class CourseVideoViewModel( workerController, coreAnalytics, downloadHelper, + resourceManager, ) { private val _uiState = MutableStateFlow(CourseVideoUIState.Loading) val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private val courseVideos = mutableMapOf>() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() @@ -107,7 +101,7 @@ class CourseVideoViewModel( super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { - _uiMessage.emit( + sendMessage( UIMessage.ToastMessage( resourceManager.getString(R.string.course_can_download_only_with_wifi) ) @@ -122,7 +116,7 @@ class CourseVideoViewModel( override fun saveAllDownloadModels(folder: String, courseId: String) { if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { viewModelScope.launch { - _uiMessage.emit( + sendMessage( UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) ) } diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt index 67e161378..c97455df5 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -12,6 +12,7 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged +import org.openedx.foundation.system.ResourceManager class DownloadQueueViewModel( private val descendants: List, @@ -21,12 +22,14 @@ class DownloadQueueViewModel( private val downloadNotifier: DownloadNotifier, coreAnalytics: CoreAnalytics, downloadHelper: DownloadHelper, + private val resourceManager: ResourceManager, ) : BaseDownloadViewModel( downloadDao, preferencesManager, workerController, coreAnalytics, - downloadHelper + downloadHelper, + resourceManager, ) { private val _uiState = MutableStateFlow(DownloadQueueUIState.Loading) 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 c64ce59a3..ba23dc140 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 @@ -40,6 +40,7 @@ import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseRouter import org.openedx.course.utils.ImageProcessor import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class CourseContainerViewModelTest { @@ -70,8 +71,12 @@ class CourseContainerViewModelTest { fun setUp() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(id = R.string.platform_name) } returns openEdx - every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { corePreferences.user } returns CoreMocks.mockUser every { corePreferences.appConfig } returns CoreMocks.mockAppConfig every { courseNotifier.notifier } returns emptyFlow() 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 ca9b996a3..f3ca889db 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 @@ -44,6 +44,7 @@ import org.openedx.course.presentation.CourseRouter import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class CourseDatesViewModelTest { @@ -72,8 +73,8 @@ class CourseDatesViewModelTest { fun setUp() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(id = R.string.platform_name) } returns openEdx - every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { resourceManager.getString(foundationR.string.foundation_error_no_connection) } returns noInternet + every { resourceManager.getString(foundationR.string.foundation_error_unknown_error) } returns somethingWrong coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure every { corePreferences.user } returns CoreMocks.mockUser every { corePreferences.appConfig } returns CoreMocks.mockAppConfig @@ -102,7 +103,6 @@ class CourseDatesViewModelTest { "", notifier, interactor, - resourceManager, analytics, config, calendarInteractor, @@ -110,6 +110,7 @@ class CourseDatesViewModelTest { preferencesManager, courseRouter, calendarRouter, + resourceManager, ) coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { @@ -132,7 +133,6 @@ class CourseDatesViewModelTest { "", notifier, interactor, - resourceManager, analytics, config, calendarInteractor, @@ -140,6 +140,7 @@ class CourseDatesViewModelTest { preferencesManager, courseRouter, calendarRouter, + resourceManager, ) coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { @@ -162,7 +163,6 @@ class CourseDatesViewModelTest { "", notifier, interactor, - resourceManager, analytics, config, calendarInteractor, @@ -170,6 +170,7 @@ class CourseDatesViewModelTest { preferencesManager, courseRouter, calendarRouter, + resourceManager, ) coEvery { interactor.getCourseDates(any()) } returns CourseMocks.courseDatesResultWithData val message = async { @@ -192,7 +193,6 @@ class CourseDatesViewModelTest { "", notifier, interactor, - resourceManager, analytics, config, calendarInteractor, @@ -200,6 +200,7 @@ class CourseDatesViewModelTest { preferencesManager, courseRouter, calendarRouter, + resourceManager, ) coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), diff --git a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt index 981c88783..33d445fd1 100644 --- a/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/handouts/HandoutsViewModelTest.kt @@ -22,6 +22,7 @@ import org.openedx.core.domain.model.AnnouncementModel import org.openedx.core.domain.model.HandoutsModel import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -35,6 +36,7 @@ class HandoutsViewModelTest { private val config = mockk() private val interactor = mockk() private val analytics = mockk() + private val resourceManager = mockk() @Before fun setUp() { @@ -49,7 +51,8 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse no internet connection exception`() = runTest { - val viewModel = HandoutsViewModel("", "Handouts", config, interactor, analytics) + val viewModel = + HandoutsViewModel("", "Handouts", config, interactor, analytics, resourceManager) coEvery { interactor.getHandouts(any()) } throws UnknownHostException() advanceUntilIdle() @@ -58,7 +61,8 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse unknown exception`() = runTest { - val viewModel = HandoutsViewModel("", "Handouts", config, interactor, analytics) + val viewModel = + HandoutsViewModel("", "Handouts", config, interactor, analytics, resourceManager) coEvery { interactor.getHandouts(any()) } throws Exception() advanceUntilIdle() @@ -68,7 +72,14 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse handouts success`() = runTest { val viewModel = - HandoutsViewModel("", HandoutsType.Handouts.name, config, interactor, analytics) + HandoutsViewModel( + "", + HandoutsType.Handouts.name, + config, + interactor, + analytics, + resourceManager + ) coEvery { interactor.getHandouts(any()) } returns HandoutsModel("hello") advanceUntilIdle() @@ -81,7 +92,14 @@ class HandoutsViewModelTest { @Test fun `getEnrolledCourse announcements success`() = runTest { val viewModel = - HandoutsViewModel("", HandoutsType.Announcements.name, config, interactor, analytics) + HandoutsViewModel( + "", + HandoutsType.Announcements.name, + config, + interactor, + analytics, + resourceManager + ) coEvery { interactor.getAnnouncements(any()) } returns listOf( AnnouncementModel( "date", @@ -99,7 +117,14 @@ class HandoutsViewModelTest { @Test fun `injectDarkMode test`() = runTest { val viewModel = - HandoutsViewModel("", HandoutsType.Announcements.name, config, interactor, analytics) + HandoutsViewModel( + "", + HandoutsType.Announcements.name, + config, + interactor, + analytics, + resourceManager + ) coEvery { interactor.getAnnouncements(any()) } returns listOf( AnnouncementModel( "date", diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt index 5387d7965..a150639c3 100644 --- a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -46,6 +46,7 @@ import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException import org.openedx.course.R as courseR +import org.openedx.foundation.R as foundationR @Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) @@ -79,9 +80,8 @@ class CourseHomeViewModelTest { @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 { resourceManager.getString(foundationR.string.foundation_error_no_connection) } returns noInternet + every { resourceManager.getString(foundationR.string.foundation_error_unknown_error) } returns somethingWrong every { resourceManager.getString(courseR.string.course_can_download_only_with_wifi) } returns cantDownload 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 381e09948..33ae2dcb9 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 @@ -29,7 +29,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.CoreMocks -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseComponentStatus @@ -50,6 +49,7 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class CourseOutlineViewModelTest { @@ -81,8 +81,12 @@ class CourseOutlineViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload 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 3f08ae795..a7c994cf9 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 @@ -24,7 +24,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.CoreMocks -import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao @@ -36,8 +35,10 @@ import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class CourseSectionViewModelTest { @@ -64,8 +65,8 @@ class CourseSectionViewModelTest { @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 { resourceManager.getString(foundationR.string.foundation_error_no_connection) } returns noInternet + every { resourceManager.getString(foundationR.string.foundation_error_unknown_error) } returns somethingWrong every { resourceManager.getString(org.openedx.course.R.string.course_can_download_only_with_wifi) } returns cantDownload @@ -96,8 +97,8 @@ class CourseSectionViewModelTest { coVerify(exactly = 1) { interactor.getCourseStructure(any()) } coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is CourseSectionUIState.Loading) } @@ -121,8 +122,8 @@ class CourseSectionViewModelTest { coVerify(exactly = 1) { interactor.getCourseStructure(any()) } coVerify(exactly = 0) { interactor.getCourseStructureForVideos(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is CourseSectionUIState.Loading) } @@ -151,7 +152,8 @@ class CourseSectionViewModelTest { coVerify(exactly = 0) { interactor.getCourseStructure(any()) } coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is CourseSectionUIState.Blocks) } @@ -174,7 +176,8 @@ class CourseSectionViewModelTest { advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) } @Test @@ -196,7 +199,8 @@ class CourseSectionViewModelTest { advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) } @Test diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 9c4f71685..7e9324a9c 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -25,6 +25,7 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @OptIn(ExperimentalCoroutinesApi::class) @@ -41,6 +42,7 @@ class CourseUnitContainerViewModelTest { private val analytics = mockk() private val networkConnection = mockk() private val videoPreviewHelper = mockk() + private val resourceManager = mockk() @Before fun setUp() { @@ -65,7 +67,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() @@ -89,7 +92,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() @@ -113,7 +117,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure @@ -139,7 +144,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure @@ -163,7 +169,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure @@ -189,7 +196,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure @@ -215,7 +223,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure @@ -241,7 +250,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure @@ -267,7 +277,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure("") } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureForVideos("") } returns CoreMocks.mockCourseStructure @@ -293,7 +304,8 @@ class CourseUnitContainerViewModelTest { notifier, analytics, networkConnection, - videoPreviewHelper + videoPreviewHelper, + resourceManager ) coEvery { interactor.getCourseStructure(any()) } returns CoreMocks.mockCourseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns CoreMocks.mockCourseStructure diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt index 1d8524a7b..614e3517a 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt @@ -29,6 +29,7 @@ import org.openedx.core.system.notifier.CourseVideoPositionChanged import org.openedx.course.data.repository.CourseRepository import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.foundation.system.ResourceManager @OptIn(ExperimentalCoroutinesApi::class) class VideoUnitViewModelTest { @@ -43,6 +44,7 @@ class VideoUnitViewModelTest { private val networkConnection = mockk() private val transcriptManager = mockk() private val courseAnalytics = mockk() + private val resourceManager = mockk() @Before fun setUp() { @@ -64,7 +66,8 @@ class VideoUnitViewModelTest { notifier, networkConnection, transcriptManager, - courseAnalytics + courseAnalytics, + resourceManager ) coEvery { courseRepository.markBlocksCompletion( @@ -106,6 +109,7 @@ class VideoUnitViewModelTest { networkConnection, transcriptManager, courseAnalytics, + resourceManager ) coEvery { courseRepository.markBlocksCompletion( @@ -147,6 +151,7 @@ class VideoUnitViewModelTest { networkConnection, transcriptManager, courseAnalytics, + resourceManager ) coEvery { notifier.notifier } returns flow { emit( diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index ae954c5f7..223d9c59c 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -24,6 +24,7 @@ import org.openedx.core.system.notifier.CourseVideoPositionChanged import org.openedx.course.data.repository.CourseRepository import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.foundation.system.ResourceManager @OptIn(ExperimentalCoroutinesApi::class) class VideoViewModelTest { @@ -37,6 +38,7 @@ class VideoViewModelTest { private val notifier = mockk() private val preferenceManager = mockk() private val courseAnalytics = mockk() + private val resourceManager = mockk() @Before fun setUp() { @@ -51,7 +53,14 @@ class VideoViewModelTest { @Test fun `sendTime test`() = runTest { val viewModel = - VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) + VideoViewModel( + "", + courseRepository, + notifier, + preferenceManager, + courseAnalytics, + resourceManager + ) coEvery { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() @@ -62,7 +71,14 @@ class VideoViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { val viewModel = - VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) + VideoViewModel( + "", + courseRepository, + notifier, + preferenceManager, + courseAnalytics, + resourceManager + ) coEvery { courseRepository.markBlocksCompletion( any(), @@ -95,7 +111,14 @@ class VideoViewModelTest { @Test fun `markBlockCompleted success`() = runTest { val viewModel = - VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) + VideoViewModel( + "", + courseRepository, + notifier, + preferenceManager, + courseAnalytics, + resourceManager + ) coEvery { courseRepository.markBlocksCompletion( any(), diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index 237c8f35a..7f1a904a8 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -3,15 +3,11 @@ package org.openedx.courses.presentation import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job -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.R import org.openedx.core.config.Config import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.system.connection.NetworkConnection @@ -21,9 +17,7 @@ import org.openedx.dashboard.domain.CourseStatusFilter import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class AllEnrolledCoursesViewModel( @@ -34,7 +28,7 @@ class AllEnrolledCoursesViewModel( private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, private val dashboardRouter: DashboardRouter -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { val apiHostUrl get() = config.getApiHostURL() val hasInternetConnection: Boolean @@ -48,10 +42,6 @@ class AllEnrolledCoursesViewModel( val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private val currentFilter: MutableStateFlow = MutableStateFlow(CourseStatusFilter.ALL) private var job: Job? = null @@ -98,19 +88,9 @@ class AllEnrolledCoursesViewModel( coursesList.addAll(response.courses) _uiState.update { it.copy(courses = coursesList.toList()) } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } _uiState.update { it.copy(refreshing = false, showProgress = false) } isLoading = false @@ -148,19 +128,9 @@ class AllEnrolledCoursesViewModel( } _uiState.update { it.copy(courses = coursesList.toList()) } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } _uiState.update { it.copy(refreshing = false, showProgress = false) } isLoading = false diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index 0ca8f4a6e..0ec58503a 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -2,14 +2,10 @@ package org.openedx.courses.presentation import androidx.fragment.app.FragmentManager 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.R import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CorePreferences @@ -20,9 +16,7 @@ import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardRouter -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -37,7 +31,7 @@ class DashboardGalleryViewModel( private val dashboardRouter: DashboardRouter, private val corePreferences: CorePreferences, private val windowSize: WindowSize, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { val apiHostUrl get() = config.getApiHostURL() @@ -46,10 +40,6 @@ class DashboardGalleryViewModel( val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private val _updating = MutableStateFlow(false) val updating: StateFlow get() = _updating.asStateFlow() @@ -99,19 +89,9 @@ class DashboardGalleryViewModel( } } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } finally { _updating.value = false isLoading = false diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 3e59ee3cd..6eea1d9bf 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -42,6 +42,7 @@ 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.mutableIntStateOf @@ -115,7 +116,7 @@ class DashboardListFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState() - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) val refreshing by viewModel.updating.observeAsState(false) val canLoadMore by viewModel.canLoadMore.observeAsState(false) diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index 58f83b8f2..b09f8446c 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -5,17 +5,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class DashboardListViewModel( @@ -25,7 +21,7 @@ class DashboardListViewModel( private val resourceManager: ResourceManager, private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val coursesList = mutableListOf() private var page = 1 @@ -37,10 +33,6 @@ class DashboardListViewModel( val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _updating = MutableLiveData() val updating: LiveData get() = _updating @@ -98,13 +90,9 @@ class DashboardListViewModel( _uiState.value = DashboardUIState.Courses(ArrayList(coursesList)) } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } _updating.value = false isLoading = false @@ -141,13 +129,9 @@ class DashboardListViewModel( _uiState.value = DashboardUIState.Courses(ArrayList(coursesList)) } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } _updating.value = false isLoading = false diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt index 21e746374..05dc94c05 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -14,6 +14,7 @@ import org.openedx.dashboard.presentation.DashboardAnalyticsEvent import org.openedx.dashboard.presentation.DashboardAnalyticsKey import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager import org.openedx.learn.LearnType class LearnViewModel( @@ -21,7 +22,8 @@ class LearnViewModel( private val config: Config, private val dashboardRouter: DashboardRouter, private val analytics: DashboardAnalytics, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _uiState = MutableStateFlow( LearnUIState( if (openTab == LearnTab.PROGRAMS.name) { diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt index fae8a9455..123c59d82 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt @@ -22,7 +22,6 @@ 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.config.Config import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.Pagination @@ -33,6 +32,7 @@ import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class DashboardListViewModelTest { @@ -60,8 +60,12 @@ class DashboardListViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { config.getApiHostURL() } returns "http://localhost:8000" } @@ -70,6 +74,10 @@ class DashboardListViewModelTest { Dispatchers.resetMain() } + private fun DashboardListViewModel.lastUiMessage(): UIMessage? { + return uiMessage.replayCache.lastOrNull() + } + @Test fun `getCourses no internet connection`() = runTest { val viewModel = DashboardListViewModel( @@ -87,7 +95,7 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) assert(viewModel.uiState.value is DashboardUIState.Loading) } @@ -109,7 +117,7 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) assert(viewModel.uiState.value is DashboardUIState.Loading) } @@ -132,7 +140,7 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.uiState.value is DashboardUIState.Courses) } @@ -162,7 +170,7 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.uiState.value is DashboardUIState.Courses) } @@ -184,7 +192,7 @@ class DashboardListViewModelTest { coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.uiState.value is DashboardUIState.Courses) } @@ -208,7 +216,7 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Loading) @@ -234,7 +242,7 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Loading) @@ -258,7 +266,7 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) } @@ -288,7 +296,7 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) } diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt index c82df34d8..6e596b3d4 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/LearnViewModelTest.kt @@ -18,6 +18,7 @@ import org.junit.Test import org.openedx.DashboardNavigator import org.openedx.core.config.Config import org.openedx.core.config.DashboardConfig +import org.openedx.foundation.system.ResourceManager import org.openedx.learn.presentation.LearnTab import org.openedx.learn.presentation.LearnViewModel @@ -29,6 +30,7 @@ class LearnViewModelTest { private val config = mockk() private val dashboardRouter = mockk(relaxed = true) private val analytics = mockk(relaxed = true) + private val resourceManager = mockk() private val fragmentManager = mockk() @Before @@ -43,14 +45,26 @@ class LearnViewModelTest { @Test fun `onSettingsClick calls navigateToSettings`() = runTest { - val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + val viewModel = LearnViewModel( + LearnTab.COURSES.name, + config, + dashboardRouter, + analytics, + resourceManager + ) viewModel.onSettingsClick(fragmentManager) verify { dashboardRouter.navigateToSettings(fragmentManager) } } @Test fun `getDashboardFragment returns correct fragment based on dashboardType`() = runTest { - val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + val viewModel = LearnViewModel( + LearnTab.COURSES.name, + config, + dashboardRouter, + analytics, + resourceManager + ) DashboardConfig.DashboardType.entries.forEach { type -> every { config.getDashboardConfig().getType() } returns type val dashboardFragment = viewModel.getDashboardFragment @@ -60,21 +74,39 @@ class LearnViewModelTest { @Test fun `getProgramFragment returns correct program fragment`() = runTest { - val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + val viewModel = LearnViewModel( + LearnTab.COURSES.name, + config, + dashboardRouter, + analytics, + resourceManager + ) viewModel.getProgramFragment verify { dashboardRouter.getProgramFragment() } } @Test fun `isProgramTypeWebView returns correct view type`() = runTest { - val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + val viewModel = LearnViewModel( + LearnTab.COURSES.name, + config, + dashboardRouter, + analytics, + resourceManager + ) every { config.getProgramConfig().isViewTypeWebView() } returns true assertTrue(viewModel.isProgramTypeWebView) } @Test fun `logMyCoursesTabClickedEvent logs correct analytics event`() = runTest { - val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + val viewModel = LearnViewModel( + LearnTab.COURSES.name, + config, + dashboardRouter, + analytics, + resourceManager + ) viewModel.logMyCoursesTabClickedEvent() verify { @@ -89,7 +121,13 @@ class LearnViewModelTest { @Test fun `logMyProgramsTabClickedEvent logs correct analytics event`() = runTest { - val viewModel = LearnViewModel(LearnTab.COURSES.name, config, dashboardRouter, analytics) + val viewModel = LearnViewModel( + LearnTab.COURSES.name, + config, + dashboardRouter, + analytics, + resourceManager + ) viewModel.logMyProgramsTabClickedEvent() verify { diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt index 518808d49..59fa58d75 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt @@ -3,15 +3,11 @@ package org.openedx.dates.presentation.dates import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job -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.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse @@ -26,9 +22,7 @@ import org.openedx.dates.presentation.DatesAnalytics import org.openedx.dates.presentation.DatesAnalyticsEvent import org.openedx.dates.presentation.DatesAnalyticsKey import org.openedx.dates.presentation.DatesRouter -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.util.Calendar import java.util.Date @@ -41,16 +35,12 @@ class DatesViewModel( private val analytics: DatesAnalytics, private val calendarSyncScheduler: CalendarSyncScheduler, corePreferences: CorePreferences, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableStateFlow(DatesUIState()) val uiState: StateFlow get() = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -78,7 +68,7 @@ class DatesViewModel( } catch (e: Exception) { page = -1 updateUIWithCachedResponse() - handleFetchException(e) + handleErrorUiMessage(e) } finally { clearLoadingState() } @@ -133,18 +123,6 @@ class DatesViewModel( } } - private suspend fun handleFetchException(e: Throwable) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - ) - } - } - private fun clearLoadingState() { _uiState.update { state -> state.copy( @@ -167,7 +145,7 @@ class DatesViewModel( refreshData() calendarSyncScheduler.requestImmediateSync() } catch (e: Exception) { - handleFetchException(e) + handleErrorUiMessage(e) } finally { _uiState.update { state -> state.copy( diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index 4bb903753..ee591922a 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -25,7 +25,6 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test -import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse @@ -39,6 +38,7 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import java.util.Date +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class DatesViewModelTest { @@ -62,9 +62,8 @@ class DatesViewModelTest { @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 - // By default, assume we have an internet connection + every { resourceManager.getString(foundationR.string.foundation_error_no_connection) } returns noInternet + every { resourceManager.getString(foundationR.string.foundation_error_unknown_error) } returns somethingWrong every { networkConnection.isOnline() } returns true every { corePreferences.isRelativeDatesEnabled } returns true every { analytics.logEvent(any(), any()) } returns Unit diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 952e041de..ac06ef7ba 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +APP_LEVEL_DATES: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 952e041de..ac06ef7ba 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +APP_LEVEL_DATES: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 6f0337d09..720971ff4 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -33,6 +33,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf @@ -96,7 +97,7 @@ class NativeDiscoveryFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState() - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) val querySearch = arguments?.getString(ARG_SEARCH_QUERY, "") ?: "" diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 70acffbd8..32cfde436 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -4,16 +4,12 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.system.connection.NetworkConnection import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class NativeDiscoveryViewModel( @@ -23,7 +19,7 @@ class NativeDiscoveryViewModel( private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, private val corePreferences: CorePreferences, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null @@ -34,10 +30,6 @@ class NativeDiscoveryViewModel( val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _canLoadMore = MutableLiveData() val canLoadMore: LiveData get() = _canLoadMore @@ -86,13 +78,9 @@ class NativeDiscoveryViewModel( } _uiState.value = DiscoveryUIState.Courses(ArrayList(coursesList)) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } finally { isLoading = false } @@ -129,13 +117,9 @@ class NativeDiscoveryViewModel( coursesList.addAll(response.results) _uiState.value = DiscoveryUIState.Courses(ArrayList(coursesList)) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } finally { isLoading = false _isUpdating.value = false diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt index f15588ff9..90a4ecdd1 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/WebViewDiscoveryViewModel.kt @@ -11,6 +11,7 @@ import org.openedx.core.presentation.global.ErrorType import org.openedx.core.presentation.global.webview.WebViewUIState import org.openedx.core.system.connection.NetworkConnection import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.UrlUtils class WebViewDiscoveryViewModel( @@ -21,7 +22,8 @@ class WebViewDiscoveryViewModel( private val corePreferences: CorePreferences, private val router: DiscoveryRouter, private val analytics: DiscoveryAnalytics, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _uiState = MutableStateFlow(WebViewUIState.Loading) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 8e4ba7fb9..e04e00739 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -45,6 +45,7 @@ import androidx.compose.material.icons.outlined.Report import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf @@ -126,7 +127,7 @@ class CourseDetailsFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState() - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val colorBackgroundValue = MaterialTheme.appColors.background.value val colorTextValue = MaterialTheme.appColors.textPrimary.value diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt index b212c588f..995062c03 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.system.connection.NetworkConnection @@ -16,10 +15,7 @@ import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import org.openedx.discovery.presentation.DiscoveryAnalyticsKey -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class CourseDetailsViewModel( @@ -32,7 +28,7 @@ class CourseDetailsViewModel( private val notifier: DiscoveryNotifier, private val analytics: DiscoveryAnalytics, private val calendarSyncScheduler: CalendarSyncScheduler, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null val isRegistrationEnabled: Boolean get() = config.isRegistrationEnabled() @@ -40,9 +36,6 @@ class CourseDetailsViewModel( private val _uiState = MutableLiveData(CourseDetailsUIState.Loading) val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage private var course: Course? = null @@ -68,17 +61,14 @@ class CourseDetailsViewModel( isUserLoggedIn = isUserLoggedIn ) } ?: run { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + handleErrorUiMessage( + throwable = null, + ) } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -99,13 +89,9 @@ class CourseDetailsViewModel( notifier.send(CourseDashboardUpdate()) } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } 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 184001160..985efe871 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 @@ -33,7 +33,6 @@ import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.util.concurrent.atomic.AtomicReference -import org.openedx.core.R as CoreR class CourseInfoViewModel( val pathId: String, @@ -47,7 +46,7 @@ class CourseInfoViewModel( private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, corePreferences: CorePreferences, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableStateFlow( @@ -62,10 +61,6 @@ class CourseInfoViewModel( val webViewState get() = _webViewUIState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private val _showAlert = MutableSharedFlow() val showAlert: SharedFlow get() = _showAlert.asSharedFlow() @@ -103,7 +98,7 @@ class CourseInfoViewModel( }.isEnrolled if (isCourseEnrolled) { - _uiMessage.emit( + sendMessage( UIMessage.ToastMessage(resourceManager.getString(R.string.discovery_you_are_already_enrolled)) ) _uiState.update { it.copy(enrollmentSuccess = AtomicReference(courseId)) } @@ -113,14 +108,14 @@ class CourseInfoViewModel( interactor.enrollInACourse(courseId) courseEnrollSuccessEvent(courseId) notifier.send(CourseDashboardUpdate()) - _uiMessage.emit( + sendMessage( UIMessage.ToastMessage(resourceManager.getString(R.string.discovery_enrolled_successfully)) ) _uiState.update { it.copy(enrollmentSuccess = AtomicReference(courseId)) } } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage(resourceManager.getString(CoreR.string.core_error_no_connection)) + handleErrorUiMessage( + throwable = e, ) } else { _showAlert.emit(true) diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt index fd954df30..494208851 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramViewModel.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.ErrorType @@ -31,7 +30,7 @@ class ProgramViewModel( private val edxCookieManager: AppCookieManager, private val resourceManager: ResourceManager, private val interactor: DiscoveryInteractor, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { val uriScheme: String get() = config.getUriScheme() val programConfig get() = config.getProgramConfig().webViewConfig @@ -62,7 +61,11 @@ class ProgramViewModel( if (e.isInternetError()) { _uiState.emit( ProgramUIState.UiMessage( - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + UIMessage.SnackBarMessage( + resolveErrorMessage( + throwable = e, + ) + ) ) ) } else { diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt index 77f6aec83..06290673a 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchFragment.kt @@ -32,6 +32,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -105,7 +106,7 @@ class CourseSearchFragment : Fragment() { 0 ) ) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) val querySearch = arguments?.getString(ARG_SEARCH_QUERY, "") ?: "" diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt index f001b46eb..3bd4605d1 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/search/CourseSearchViewModel.kt @@ -8,16 +8,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.DiscoveryAnalytics -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class CourseSearchViewModel( @@ -26,7 +22,7 @@ class CourseSearchViewModel( private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { val apiHostUrl get() = config.getApiHostURL() val isUserLoggedIn get() = corePreferences.user != null @@ -37,10 +33,6 @@ class CourseSearchViewModel( val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _canLoadMore = MutableLiveData() val canLoadMore: LiveData get() = _canLoadMore @@ -123,13 +115,9 @@ class CourseSearchViewModel( coursesList.addAll(response.results) _uiState.value = CourseSearchUIState.Courses(coursesList, response.pagination.count) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } finally { isLoading = false _isUpdating.value = false diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index d6270fe7b..f1375fa36 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -18,7 +18,6 @@ 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.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination @@ -26,8 +25,10 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.CourseList import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class NativeDiscoveryViewModelTest { @@ -50,8 +51,12 @@ class NativeDiscoveryViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { corePreferences.user } returns null every { config.getApiHostURL() } returns "http://localhost:8000" every { config.isPreLoginExperienceEnabled() } returns false @@ -79,8 +84,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscoveryUIState.Loading) assert(viewModel.canLoadMore.value == null) } @@ -102,8 +107,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscoveryUIState.Loading) assert(viewModel.canLoadMore.value == null) } @@ -125,7 +130,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 0) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 1) { interactor.getCoursesListFromCache() } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscoveryUIState.Courses) assert(viewModel.canLoadMore.value == false) } @@ -155,7 +161,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscoveryUIState.Courses) assert(viewModel.canLoadMore.value == true) } @@ -185,7 +192,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscoveryUIState.Courses) assert(viewModel.canLoadMore.value == false) } @@ -207,8 +215,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 2) { interactor.getCoursesList(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == null) assert(viewModel.uiState.value is DiscoveryUIState.Loading) @@ -231,8 +239,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 2) { interactor.getCoursesList(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == null) assert(viewModel.uiState.value is DiscoveryUIState.Loading) @@ -263,7 +271,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 2) { interactor.getCoursesList(any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == true) assert(viewModel.uiState.value is DiscoveryUIState.Courses) @@ -294,7 +303,8 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 2) { interactor.getCoursesList(any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) assert(viewModel.uiState.value is DiscoveryUIState.Courses) diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt index 2c9f282b3..34f55ac7b 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/detail/CourseDetailsViewModelTest.kt @@ -21,7 +21,6 @@ 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.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.system.connection.NetworkConnection @@ -35,6 +34,7 @@ import org.openedx.discovery.presentation.DiscoveryAnalyticsEvent import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class CourseDetailsViewModelTest { @@ -59,8 +59,12 @@ class CourseDetailsViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { config.getApiHostURL() } returns "http://localhost:8000" every { calendarSyncScheduler.requestImmediateSync(any()) } returns Unit } @@ -70,6 +74,10 @@ class CourseDetailsViewModelTest { Dispatchers.resetMain() } + private fun CourseDetailsViewModel.lastUiMessage(): UIMessage? { + return uiMessage.replayCache.lastOrNull() + } + @Test fun `getCourseDetails no internet connection exception`() = runTest { val viewModel = CourseDetailsViewModel( @@ -89,7 +97,7 @@ class CourseDetailsViewModelTest { coVerify(exactly = 1) { interactor.getCourseDetails(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) assert(viewModel.uiState.value is CourseDetailsUIState.Loading) @@ -114,7 +122,7 @@ class CourseDetailsViewModelTest { coVerify(exactly = 1) { interactor.getCourseDetails(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) assert(viewModel.uiState.value is CourseDetailsUIState.Loading) @@ -142,7 +150,7 @@ class CourseDetailsViewModelTest { coVerify(exactly = 1) { interactor.getCourseDetails(any()) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) } @@ -169,7 +177,7 @@ class CourseDetailsViewModelTest { coVerify(exactly = 0) { interactor.getCourseDetails(any()) } coVerify(exactly = 1) { interactor.getCourseDetailsFromCache(any()) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) } @@ -200,7 +208,7 @@ class CourseDetailsViewModelTest { coVerify(exactly = 1) { interactor.enrollInACourse(any()) } verify { analytics.logEvent(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) } @@ -242,7 +250,7 @@ class CourseDetailsViewModelTest { ) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) } @@ -297,7 +305,7 @@ class CourseDetailsViewModelTest { ) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.uiState.value is CourseDetailsUIState.CourseData) } diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt index 392923eb2..e0c9056fd 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/search/CourseSearchViewModelTest.kt @@ -15,11 +15,11 @@ 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.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination @@ -28,8 +28,10 @@ import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.CourseList import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class CourseSearchViewModelTest { @@ -51,8 +53,8 @@ class CourseSearchViewModelTest { @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 { resourceManager.getString(foundationR.string.foundation_error_no_connection) } returns noInternet + every { resourceManager.getString(foundationR.string.foundation_error_unknown_error) } returns somethingWrong every { config.getApiHostURL() } returns "http://localhost:8000" } @@ -73,7 +75,8 @@ class CourseSearchViewModelTest { assert(uiState.courses.isEmpty()) assert(uiState.numCourses == 0) - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) } @Test @@ -87,9 +90,9 @@ class CourseSearchViewModelTest { coVerify(exactly = 1) { interactor.getCoursesListByQuery(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assert(viewModel.uiState.value is CourseSearchUIState.Loading) - assert(message.message == noInternet) + assert((message.await() as UIMessage.SnackBarMessage).message == noInternet) } @Test @@ -103,9 +106,9 @@ class CourseSearchViewModelTest { coVerify(exactly = 1) { interactor.getCoursesListByQuery(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assert(viewModel.uiState.value is CourseSearchUIState.Loading) - assert(message.message == somethingWrong) + assert((message.await() as UIMessage.SnackBarMessage).message == somethingWrong) } @Test @@ -131,7 +134,8 @@ class CourseSearchViewModelTest { verify(exactly = 1) { analytics.discoveryCourseSearchEvent(any(), any()) } assert(viewModel.uiState.value is CourseSearchUIState.Courses) - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) } @@ -166,7 +170,8 @@ class CourseSearchViewModelTest { assert(viewModel.uiState.value is CourseSearchUIState.Courses) assert((viewModel.uiState.value as CourseSearchUIState.Courses).courses.size == 3) - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) } @@ -203,7 +208,8 @@ class CourseSearchViewModelTest { assert(viewModel.uiState.value is CourseSearchUIState.Courses) assert((viewModel.uiState.value as CourseSearchUIState.Courses).courses.size == 2) - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == true) } @@ -229,7 +235,8 @@ class CourseSearchViewModelTest { assert(viewModel.uiState.value is CourseSearchUIState.Courses) assert((viewModel.uiState.value as CourseSearchUIState.Courses).courses.isEmpty()) - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.isUpdating.value == null) } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index 46f3eab18..36c430340 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -42,6 +42,7 @@ 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 @@ -117,7 +118,7 @@ class DiscussionCommentsFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState(DiscussionCommentsUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt index fbd5b464e..2abe78c38 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.DiscussionType @@ -13,9 +12,7 @@ import org.openedx.discussion.system.notifier.DiscussionCommentAdded import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -24,7 +21,7 @@ class DiscussionCommentsViewModel( private val resourceManager: ResourceManager, private val notifier: DiscussionNotifier, thread: org.openedx.discussion.domain.model.Thread, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { val title = resourceManager.getString(thread.type.resId) @@ -36,10 +33,6 @@ class DiscussionCommentsViewModel( val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _canLoadMore = MutableLiveData() val canLoadMore: LiveData get() = _canLoadMore @@ -65,10 +58,11 @@ class DiscussionCommentsViewModel( commentCount ) } else { - _uiMessage.value = + sendMessage( UIMessage.ToastMessage( resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) ) + ) } thread = thread.copy(commentCount = thread.commentCount + 1) sendThreadUpdated() @@ -125,13 +119,9 @@ class DiscussionCommentsViewModel( markRead() } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } finally { isLoading = false _isUpdating.value = false @@ -181,13 +171,9 @@ class DiscussionCommentsViewModel( DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) sendThreadUpdated() } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -201,13 +187,9 @@ class DiscussionCommentsViewModel( DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) sendThreadUpdated() } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -221,13 +203,9 @@ class DiscussionCommentsViewModel( DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) sendThreadUpdated() } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -236,21 +214,15 @@ class DiscussionCommentsViewModel( viewModelScope.launch { try { val response = interactor.setCommentVoted(commentId, vote) - val index = comments.indexOfFirst { - it.id == response.id - } + val index = comments.indexOfFirst { it.id == response.id } comments[index] = comments[index].copy(voted = response.voted, voteCount = response.voteCount) _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -259,20 +231,14 @@ class DiscussionCommentsViewModel( viewModelScope.launch { try { val response = interactor.setCommentFlagged(commentId, vote) - val index = comments.indexOfFirst { - it.id == response.id - } + val index = comments.indexOfFirst { it.id == response.id } comments[index] = comments[index].copy(abuseFlagged = response.abuseFlagged) _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -286,21 +252,18 @@ class DiscussionCommentsViewModel( if (page == -1) { comments.add(response) } else { - _uiMessage.value = + sendMessage( UIMessage.ToastMessage( resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) ) + ) } _uiState.value = DiscussionCommentsUIState.Success(thread, comments.toList(), commentCount) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index 171d5ff31..ecb7c5fa8 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -43,6 +43,7 @@ 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.mutableIntStateOf @@ -124,7 +125,7 @@ class DiscussionResponsesFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState(DiscussionResponsesUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt index e4c675609..9097f307b 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModel.kt @@ -4,14 +4,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged import org.openedx.discussion.system.notifier.DiscussionNotifier -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -20,16 +17,12 @@ class DiscussionResponsesViewModel( private val resourceManager: ResourceManager, private val notifier: DiscussionNotifier, private var comment: DiscussionComment, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _canLoadMore = MutableLiveData() val canLoadMore: LiveData get() = _canLoadMore @@ -85,17 +78,9 @@ class DiscussionResponsesViewModel( comments.addAll(response.results) _uiState.value = DiscussionResponsesUIState.Success(comment, comments.toList()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - } + handleErrorUiMessage( + throwable = e, + ) } finally { isLoading = false _isUpdating.value = false @@ -119,17 +104,9 @@ class DiscussionResponsesViewModel( } _uiState.value = DiscussionResponsesUIState.Success(comment, comments.toList()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -149,17 +126,9 @@ class DiscussionResponsesViewModel( } _uiState.value = DiscussionResponsesUIState.Success(comment, comments.toList()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -173,25 +142,18 @@ class DiscussionResponsesViewModel( if (page == -1) { comments.add(response) } else { - _uiMessage.value = + sendMessage( UIMessage.ToastMessage( resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) ) + ) } _uiState.value = DiscussionResponsesUIState.Success(comment, comments.toList()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - } + handleErrorUiMessage( + throwable = e, + ) } } } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt index 6e69f2a4f..eee3115fa 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt @@ -32,6 +32,7 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -108,7 +109,7 @@ class DiscussionSearchThreadFragment : Fragment() { 0 ) ) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt index d95dcba9e..0fe2929dc 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModel.kt @@ -15,14 +15,10 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class DiscussionSearchThreadViewModel( @@ -30,7 +26,7 @@ class DiscussionSearchThreadViewModel( private val resourceManager: ResourceManager, private val notifier: DiscussionNotifier, val courseId: String -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData( DiscussionSearchThreadUIState.Threads( @@ -41,10 +37,6 @@ class DiscussionSearchThreadViewModel( val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _canLoadMore = MutableLiveData() val canLoadMore: LiveData get() = _canLoadMore @@ -155,13 +147,9 @@ class DiscussionSearchThreadViewModel( isLoading = false _isUpdating.value = false }.catch { e -> - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) isLoading = false _isUpdating.value = false } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt index bda4e3730..7c7215e35 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -43,6 +43,7 @@ import androidx.compose.material.rememberModalBottomSheetState 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 @@ -109,7 +110,7 @@ class DiscussionAddThreadFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val isLoading by viewModel.isLoading.observeAsState(false) val success by viewModel.newThread.observeAsState() diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt index b16b9f300..1c9e52d21 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModel.kt @@ -4,14 +4,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.discussion.domain.interactor.DiscussionInteractor +import org.openedx.discussion.domain.model.Thread import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadAdded -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class DiscussionAddThreadViewModel( @@ -19,16 +16,12 @@ class DiscussionAddThreadViewModel( private val resourceManager: ResourceManager, private val notifier: DiscussionNotifier, private val courseId: String -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { - private val _newThread = MutableLiveData() - val newThread: LiveData + private val _newThread = MutableLiveData() + val newThread: LiveData get() = _newThread - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _isLoading = MutableLiveData() val isLoading: LiveData get() = _isLoading @@ -45,13 +38,9 @@ class DiscussionAddThreadViewModel( try { _newThread.value = interactor.createThread(topicId, courseId, type, title, rawBody, follow) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } _isLoading.value = false } diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index 65a1f24bc..7e7af161e 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -43,6 +43,7 @@ import androidx.compose.material.rememberModalBottomSheetState 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 @@ -132,7 +133,7 @@ class DiscussionThreadsFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState(DiscussionThreadsUIState.Loading) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) 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 e79c7672b..b60582e1c 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 @@ -5,16 +5,12 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.discussion.domain.interactor.DiscussionInteractor 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 org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class DiscussionThreadsViewModel( @@ -24,16 +20,12 @@ class DiscussionThreadsViewModel( val courseId: String, val topicId: String, private val threadType: String -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { 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 @@ -161,13 +153,9 @@ class DiscussionThreadsViewModel( threadsList.addAll(response.results) _uiState.value = DiscussionThreadsUIState.Threads(threadsList.toList()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } _isUpdating.value = false isLoading = false @@ -188,13 +176,9 @@ class DiscussionThreadsViewModel( threadsList.addAll(response.results) _uiState.value = DiscussionThreadsUIState.Threads(threadsList.toList()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } _isUpdating.value = false isLoading = false @@ -216,13 +200,9 @@ class DiscussionThreadsViewModel( threadsList.addAll(response.results) _uiState.value = DiscussionThreadsUIState.Threads(threadsList.toList()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } _isUpdating.value = false isLoading = false 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 84a5d3e15..abe52f2ae 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,11 +3,7 @@ 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.R import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.RefreshDiscussions @@ -16,7 +12,6 @@ import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class DiscussionTopicsViewModel( @@ -27,16 +22,12 @@ class DiscussionTopicsViewModel( private val analytics: DiscussionAnalytics, private val courseNotifier: CourseNotifier, val discussionRouter: DiscussionRouter, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - init { collectCourseNotifier() @@ -55,10 +46,8 @@ class DiscussionTopicsViewModel( } catch (e: Exception) { _uiState.value = DiscussionTopicsUIState.Error if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) + handleErrorUiMessage( + throwable = e, ) } } finally { diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt index 74f940396..797452c39 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt @@ -19,12 +19,11 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert +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.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.discussion.DiscussionMocks @@ -36,8 +35,10 @@ import org.openedx.discussion.system.notifier.DiscussionCommentDataChanged import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) @@ -71,8 +72,12 @@ class DiscussionCommentsViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) } returns commentAddedSuccessfully @@ -100,9 +105,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 0) { interactor.getThreadQuestionComments(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Loading) assert(viewModel.isUpdating.value == false) } @@ -125,9 +129,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 0) { interactor.getThreadComments(any(), any()) } coVerify(exactly = 1) { interactor.getThreadQuestionComments(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Loading) assert(viewModel.isUpdating.value == false) } @@ -155,7 +158,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.getThreadQuestionComments(any(), any(), any()) } coVerify(exactly = 1) { interactor.setThreadRead(any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == true) @@ -188,7 +192,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 0) { interactor.getThreadQuestionComments(any(), any(), any()) } coVerify(exactly = 1) { interactor.setThreadRead(any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) @@ -222,7 +227,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 0) { interactor.getThreadQuestionComments(any(), any(), any()) } coVerify(exactly = 1) { interactor.setThreadRead(any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) @@ -256,7 +262,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 0) { interactor.getThreadQuestionComments(any(), any(), any()) } coVerify(exactly = 1) { interactor.setThreadRead(any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) @@ -289,7 +296,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 2) { interactor.getThreadComments(any(), any()) } coVerify(exactly = 0) { interactor.getThreadQuestionComments(any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) @@ -318,8 +326,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadVoted(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -347,8 +355,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadVoted(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -376,7 +384,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadVoted(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -404,8 +413,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setCommentFlagged(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assert(noInternet == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -433,8 +442,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setCommentFlagged(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assert(somethingWrong == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -463,7 +472,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setCommentFlagged(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -490,8 +500,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setCommentVoted(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assert(noInternet == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -518,8 +528,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setCommentVoted(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assert(somethingWrong == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -551,7 +561,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setCommentVoted(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -578,8 +589,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadFlagged(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assert(noInternet == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -606,8 +617,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadFlagged(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assert(somethingWrong == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -634,7 +645,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadFlagged(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -661,8 +673,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadFollowed(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assert(noInternet == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -689,8 +701,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadFollowed(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assert(somethingWrong == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -717,7 +729,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.setThreadFollowed(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -750,7 +763,8 @@ class DiscussionCommentsViewModelTest { advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -783,8 +797,11 @@ class DiscussionCommentsViewModelTest { advanceUntilIdle() - val message = viewModel.uiMessage.value as? UIMessage.ToastMessage - assert(commentAddedSuccessfully == message?.message) + val message = captureUiMessage(viewModel) + assertEquals( + commentAddedSuccessfully, + (message.await() as? UIMessage.ToastMessage)?.message + ) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -817,7 +834,8 @@ class DiscussionCommentsViewModelTest { advanceUntilIdle() - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } @@ -843,8 +861,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - Assert.assertEquals(noInternet, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -869,8 +887,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - Assert.assertEquals(somethingWrong, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -896,7 +914,8 @@ class DiscussionCommentsViewModelTest { coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - assert(viewModel.uiMessage.value != null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value is DiscussionCommentsUIState.Success) } diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt index 90b83a448..7c11ace6f 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt @@ -8,18 +8,21 @@ 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.test.TestScope 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 import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule -import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.discussion.DiscussionMocks @@ -29,6 +32,7 @@ import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class DiscussionResponsesViewModelTest { @@ -55,8 +59,12 @@ class DiscussionResponsesViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { resourceManager.getString(org.openedx.discussion.R.string.discussion_comment_added) } returns commentAddedSuccessfully @@ -68,6 +76,10 @@ class DiscussionResponsesViewModelTest { clearAllMocks() } + private fun TestScope.captureUiMessage(viewModel: DiscussionResponsesViewModel) = async { + withTimeoutOrNull(5_000) { viewModel.uiMessage.first() } + } + @Test fun `loadCommentResponses no internet connection exception`() = runTest { coEvery { interactor.getCommentsResponses(any(), any()) } throws UnknownHostException() @@ -82,8 +94,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.getCommentsResponses(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assert(noInternet == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionResponsesUIState.Loading) } @@ -103,8 +115,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.getCommentsResponses(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assert(somethingWrong == (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionResponsesUIState.Loading) } @@ -126,7 +138,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.getCommentsResponses(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == true) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) @@ -148,7 +161,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.getCommentsResponses(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) @@ -171,7 +185,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.getCommentsResponses(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) @@ -198,7 +213,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 2) { interactor.getCommentsResponses(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) @@ -222,8 +238,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentVoted(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assert(noInternet == (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -244,8 +260,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentVoted(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assert(somethingWrong == (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -272,7 +288,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentVoted(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) } @@ -300,7 +317,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentVoted(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) } @@ -322,8 +340,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentFlagged(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(noInternet == message?.message) + val message = captureUiMessage(viewModel) + assert(noInternet == (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -344,8 +362,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentFlagged(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assert(somethingWrong == message?.message) + val message = captureUiMessage(viewModel) + assert(somethingWrong == (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -368,7 +386,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentFlagged(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) } @@ -394,7 +413,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.setCommentFlagged(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) } @@ -417,8 +437,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - Assert.assertEquals(noInternet, message?.message) + val message = captureUiMessage(viewModel) + Assert.assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -440,8 +460,11 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - Assert.assertEquals(somethingWrong, message?.message) + val message = captureUiMessage(viewModel) + Assert.assertEquals( + somethingWrong, + (message.await() as? UIMessage.SnackBarMessage)?.message + ) } @Test @@ -463,7 +486,8 @@ class DiscussionResponsesViewModelTest { coVerify(exactly = 1) { interactor.createComment(any(), any(), any()) } - assert(viewModel.uiMessage.value != null) + val message = captureUiMessage(viewModel) + assert(message.await() != null) assert(viewModel.uiState.value is DiscussionResponsesUIState.Success) } diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt index 9817ea242..6687d3400 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt @@ -22,7 +22,6 @@ 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.domain.model.Pagination import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor @@ -32,6 +31,7 @@ import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class DiscussionSearchThreadViewModelTest { @@ -51,8 +51,12 @@ class DiscussionSearchThreadViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong } @After @@ -60,6 +64,10 @@ class DiscussionSearchThreadViewModelTest { Dispatchers.resetMain() } + private fun DiscussionSearchThreadViewModel.lastUiMessage(): UIMessage? { + return uiMessage.replayCache.lastOrNull() + } + @Test fun `search empty query`() = runTest { val viewModel = DiscussionSearchThreadViewModel(interactor, resourceManager, notifier, "") @@ -71,7 +79,7 @@ class DiscussionSearchThreadViewModelTest { assert(uiState.data.isEmpty()) assert(uiState.count == 0) - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) } @Test @@ -84,7 +92,7 @@ class DiscussionSearchThreadViewModelTest { coVerify(exactly = 1) { interactor.searchThread(any(), any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as UIMessage.SnackBarMessage assert(viewModel.uiState.value is DiscussionSearchThreadUIState.Loading) assert(message.message == noInternet) } @@ -99,7 +107,7 @@ class DiscussionSearchThreadViewModelTest { coVerify(exactly = 1) { interactor.searchThread(any(), any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as UIMessage.SnackBarMessage assert(viewModel.uiState.value is DiscussionSearchThreadUIState.Loading) assert(message.message == somethingWrong) } @@ -125,7 +133,7 @@ class DiscussionSearchThreadViewModelTest { coVerify(exactly = 1) { interactor.searchThread(any(), any(), any()) } assert(viewModel.uiState.value is DiscussionSearchThreadUIState.Threads) - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == false) } @@ -159,7 +167,7 @@ class DiscussionSearchThreadViewModelTest { assert(viewModel.uiState.value is DiscussionSearchThreadUIState.Threads) assert((viewModel.uiState.value as DiscussionSearchThreadUIState.Threads).data.size == 3) - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == false) } @@ -195,7 +203,7 @@ class DiscussionSearchThreadViewModelTest { assert(viewModel.uiState.value is DiscussionSearchThreadUIState.Threads) assert((viewModel.uiState.value as DiscussionSearchThreadUIState.Threads).data.size == 2) - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.canLoadMore.value == true) } @@ -221,7 +229,7 @@ class DiscussionSearchThreadViewModelTest { assert(viewModel.uiState.value is DiscussionSearchThreadUIState.Threads) assert((viewModel.uiState.value as DiscussionSearchThreadUIState.Threads).data.isEmpty()) - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == null) } diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt index d46df5e53..37800eb74 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt @@ -18,7 +18,6 @@ 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.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.system.notifier.DiscussionNotifier @@ -26,6 +25,7 @@ import org.openedx.discussion.system.notifier.DiscussionThreadAdded import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class DiscussionAddThreadViewModelTest { @@ -57,8 +57,12 @@ class DiscussionAddThreadViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong } @After @@ -67,6 +71,10 @@ class DiscussionAddThreadViewModelTest { clearAllMocks() } + private fun DiscussionAddThreadViewModel.lastUiMessage(): UIMessage? { + return uiMessage.replayCache.lastOrNull() + } + @Test fun `createThread no internet connection exception`() = runTest { val viewModel = DiscussionAddThreadViewModel(interactor, resourceManager, notifier, "") @@ -86,7 +94,7 @@ class DiscussionAddThreadViewModelTest { coVerify(exactly = 1) { interactor.createThread(any(), any(), any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assert(noInternet == message?.message) assert(viewModel.newThread.value == null) assert(viewModel.isLoading.value == false) @@ -111,7 +119,7 @@ class DiscussionAddThreadViewModelTest { coVerify(exactly = 1) { interactor.createThread(any(), any(), any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assert(somethingWrong == message?.message) assert(viewModel.newThread.value == null) assert(viewModel.isLoading.value == false) @@ -136,7 +144,7 @@ class DiscussionAddThreadViewModelTest { coVerify(exactly = 1) { interactor.createThread(any(), any(), any(), any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.newThread.value != null) assert(viewModel.isLoading.value == false) } 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 ecb7e5f53..52e2bbdaa 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 @@ -24,7 +24,6 @@ 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.domain.model.Pagination import org.openedx.discussion.DiscussionMocks import org.openedx.discussion.domain.interactor.DiscussionInteractor @@ -37,6 +36,7 @@ import org.openedx.discussion.system.notifier.DiscussionThreadDataChanged import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class DiscussionThreadsViewModelTest { @@ -65,8 +65,12 @@ class DiscussionThreadsViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong } @After @@ -75,6 +79,10 @@ class DiscussionThreadsViewModelTest { clearAllMocks() } + private fun DiscussionThreadsViewModel.lastUiMessage(): UIMessage? { + return uiMessage.replayCache.lastOrNull() + } + @Test fun `getThreadByType AllThreads no internet connection`() = runTest { coEvery { @@ -97,7 +105,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Loading) @@ -118,7 +126,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Loading) @@ -148,7 +156,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getAllThreads(any(), any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Threads) } @@ -176,7 +184,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Loading) @@ -205,7 +213,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Loading) @@ -251,7 +259,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getFollowingThreads(any(), any(), any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Threads) } @@ -279,7 +287,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Loading) @@ -300,7 +308,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = viewModel.lastUiMessage() as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Loading) @@ -330,7 +338,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 1) { interactor.getThreads(any(), any(), any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Threads) } @@ -420,7 +428,7 @@ class DiscussionThreadsViewModelTest { coVerify(exactly = 2) { interactor.getThreads(any(), any(), any(), any(), any()) } - assert(viewModel.uiMessage.value == null) + assert(viewModel.lastUiMessage() == null) assert(viewModel.isUpdating.value == false) assert(viewModel.uiState.value is DiscussionThreadsUIState.Threads) } 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 3a180c7ab..ab4ea8cc2 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 @@ -23,7 +23,6 @@ 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.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.discussion.DiscussionMocks @@ -33,6 +32,7 @@ import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class DiscussionTopicsViewModelTest { @@ -53,7 +53,9 @@ class DiscussionTopicsViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) - every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) coEvery { courseNotifier.send(any()) } returns Unit } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index 24381a2a5..c0f17dcf7 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -5,18 +5,14 @@ import androidx.compose.material.icons.filled.School import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job -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.catch import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.BlockType -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseStructure @@ -40,8 +36,6 @@ import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.downloads.domain.interactor.DownloadInteractor import org.openedx.downloads.presentation.DownloadsRouter -import org.openedx.foundation.extension.isInternetError -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -68,15 +62,13 @@ class DownloadsViewModel( workerController, coreAnalytics, downloadHelper, + resourceManager, ) { val apiHostUrl get() = config.getApiHostURL() private val _uiState = MutableStateFlow(DownloadsUIState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow = _uiMessage.asSharedFlow() - private val courseBlockIds = mutableMapOf>() val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -207,13 +199,8 @@ class DownloadsViewModel( private fun emitErrorMessage(e: Throwable) { viewModelScope.launch { - val text = if (e.isInternetError()) { - R.string.core_error_no_connection - } else { - R.string.core_error_unknown_error - } - _uiMessage.emit( - UIMessage.SnackBarMessage(resourceManager.getString(text)) + handleErrorUiMessage( + throwable = e, ) } } diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 42506c7a3..3d080b589 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -24,7 +24,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.CoreMocks -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.DownloadCoursePreview @@ -45,6 +44,7 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR class DownloadsViewModelTest { @@ -87,8 +87,12 @@ class DownloadsViewModelTest { fun setUp() { Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" - every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns unknownError + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns unknownError every { networkConnection.isOnline() } returns true coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt index 489695eb2..b65401dd2 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileFragment.kt @@ -23,6 +23,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -78,7 +79,7 @@ class AnothersProfileFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState - val uiMessage by viewModel.uiMessage + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) AnothersProfileScreen( windowSize = windowSize, diff --git a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt index 90559aa9b..16bbbe355 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/anothersaccount/AnothersProfileViewModel.kt @@ -4,10 +4,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor @@ -15,16 +12,12 @@ class AnothersProfileViewModel( private val interactor: ProfileInteractor, private val resourceManager: ResourceManager, val username: String -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = mutableStateOf(AnothersProfileUIState.Loading) val uiState: State get() = _uiState - private val _uiMessage = mutableStateOf(null) - val uiMessage: State - get() = _uiMessage - init { getAccount(username) } @@ -36,13 +29,9 @@ class AnothersProfileViewModel( val account = interactor.getAccount(username) _uiState.value = AnothersProfileUIState.Data(account) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt index dcc31d04e..1bf4d10a3 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarViewModel.kt @@ -23,6 +23,7 @@ import org.openedx.core.system.notifier.calendar.CalendarSynced import org.openedx.core.system.notifier.calendar.CalendarSyncing import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.presentation.ProfileRouter class CalendarViewModel( @@ -34,7 +35,8 @@ class CalendarViewModel( private val corePreferences: CorePreferences, private val profileRouter: ProfileRouter, private val networkConnection: NetworkConnection, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val calendarInitState: CalendarUIState get() = CalendarUIState( diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt index 015df8e2b..3a8ee86c1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CoursesToSyncViewModel.kt @@ -1,21 +1,15 @@ package org.openedx.profile.presentation.calendar 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.R import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.worker.CalendarSyncScheduler -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager class CoursesToSyncViewModel( @@ -23,7 +17,7 @@ class CoursesToSyncViewModel( private val calendarPreferences: CalendarPreferences, private val calendarSyncScheduler: CalendarSyncScheduler, private val resourceManager: ResourceManager, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableStateFlow( CoursesToSyncUIState( @@ -34,10 +28,6 @@ class CoursesToSyncViewModel( ) ) - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - val uiState: StateFlow get() = _uiState.asStateFlow() @@ -69,10 +59,8 @@ class CoursesToSyncViewModel( _uiState.update { it.copy(coursesCalendarState = coursesCalendarState) } } catch (e: Exception) { e.printStackTrace() - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) + handleErrorUiMessage( + throwable = e, ) } } @@ -84,21 +72,9 @@ class CoursesToSyncViewModel( val enrollmentsStatus = calendarInteractor.getEnrollmentsStatus() _uiState.update { it.copy(enrollmentsStatus = enrollmentsStatus) } } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString( - R.string.core_error_no_connection - ) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } finally { _uiState.update { it.copy(isLoading = false) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt index 3d0cf94a8..976157f69 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/DisableCalendarSyncDialogViewModel.kt @@ -14,13 +14,15 @@ import org.openedx.core.system.CalendarManager import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.system.notifier.calendar.CalendarSyncDisabled import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class DisableCalendarSyncDialogViewModel( private val calendarNotifier: CalendarNotifier, private val calendarManager: CalendarManager, private val calendarPreferences: CalendarPreferences, private val calendarInteractor: CalendarInteractor, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _deletionState = MutableStateFlow(null) val deletionState: StateFlow = _deletionState.asStateFlow() diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt index 162f0d10b..d52cca4ff 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -96,10 +96,8 @@ class NewCalendarDialogFragment : DialogFragment() { val viewModel: NewCalendarDialogViewModel = koinViewModel() LaunchedEffect(Unit) { - viewModel.uiMessage.collect { message -> - if (message.isNotEmpty()) { - context.toastMessage(message) - } + viewModel.uiMessage.collect { uiMessage -> + context.toastMessage(uiMessage.message) } } @@ -115,9 +113,8 @@ class NewCalendarDialogFragment : DialogFragment() { val showLocalCalendarSection by viewModel.showLocalCalendarSection.collectAsState() NewCalendarDialog( - newCalendarDialogType = requireArguments().parcelable( - ARG_DIALOG_TYPE - ) + newCalendarDialogType = requireArguments() + .parcelable(ARG_DIALOG_TYPE) ?: NewCalendarDialogType.CREATE_NEW, googleCalendars = googleCalendars, showLocalCalendarSection = showLocalCalendarSection, diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt index eb95d1650..a98f74b44 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogViewModel.kt @@ -20,7 +20,9 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.calendar.CalendarCreated import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager +import java.net.UnknownHostException class NewCalendarDialogViewModel( private val calendarManager: CalendarManager, @@ -29,12 +31,7 @@ class NewCalendarDialogViewModel( private val calendarInteractor: CalendarInteractor, private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, -) : BaseViewModel() { - - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - +) : BaseViewModel(resourceManager) { private val _isSuccess = MutableSharedFlow() val isSuccess: SharedFlow get() = _isSuccess.asSharedFlow() @@ -86,10 +83,14 @@ class NewCalendarDialogViewModel( } _isSuccess.emit(true) } else { - _uiMessage.emit(resourceManager.getString(R.string.core_error_unknown_error)) + handleErrorUiMessage( + throwable = null, + ) } } else { - _uiMessage.emit(resourceManager.getString(R.string.core_error_no_connection)) + handleErrorUiMessage( + throwable = UnknownHostException(), + ) } } } @@ -97,12 +98,22 @@ class NewCalendarDialogViewModel( fun syncWithGoogleCalendar(calendarId: Long) { viewModelScope.launch { if (!networkConnection.isOnline()) { - _uiMessage.emit(resourceManager.getString(R.string.core_error_no_connection)) + sendMessage( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_error_no_connection) + ) + ) return@launch } if (!calendarManager.isCalendarExist(calendarId)) { - _uiMessage.emit(resourceManager.getString(R.string.core_error_unknown_error)) + sendMessage( + UIMessage.SnackBarMessage( + resourceManager.getString( + R.string.core_error_unknown_error + ) + ) + ) return@launch } diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt index 770e67b40..fa0bbdd25 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileFragment.kt @@ -98,7 +98,7 @@ class DeleteProfileFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState(DeleteProfileFragmentUIState.Initial) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val logoutSuccess by logoutViewModel.successLogout.collectAsState(false) DeleteProfileScreen( diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index 8ab22c87e..648efe291 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -24,16 +24,12 @@ class DeleteProfileViewModel( private val notifier: ProfileNotifier, private val validator: Validator, private val analytics: ProfileAnalytics, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = MutableLiveData() - val uiMessage: LiveData - get() = _uiMessage - fun deleteProfile(password: String) { logDeleteProfileClickedEvent() if (!validator.isPasswordValid(password)) { @@ -52,12 +48,16 @@ class DeleteProfileViewModel( notifier.send(AccountDeactivated()) } catch (e: Exception) { if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + handleErrorUiMessage( + throwable = e, + ) _uiState.value = DeleteProfileFragmentUIState.Initial } else if (e is EdxError.UserNotActiveException) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_user_not_active)) + sendMessage( + UIMessage.SnackBarMessage( + resourceManager.getString(R.string.core_user_not_active) + ) + ) _uiState.value = DeleteProfileFragmentUIState.Initial } else { _uiState.value = diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index abc042aff..b95663681 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -58,6 +58,7 @@ import androidx.compose.material.rememberModalBottomSheetState 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.mutableStateMapOf @@ -172,7 +173,7 @@ class EditProfileFragment : Fragment() { isLimited = viewModel.isLimitedProfile ) ) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val selectedImageUri by viewModel.selectedImageUri.observeAsState() val isImageDeleted by viewModel.deleteImage.observeAsState(false) val leaveDialog by viewModel.showLeaveDialog.observeAsState(false) diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index dd8781cf9..8ce70cebc 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -5,11 +5,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch -import org.openedx.core.R import org.openedx.core.config.Config -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account @@ -27,16 +24,12 @@ class EditProfileViewModel( private val analytics: ProfileAnalytics, val config: Config, account: Account, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = MutableLiveData() - val uiMessage: LiveData - get() = _uiMessage - var account = account private set @@ -93,13 +86,9 @@ class EditProfileViewModel( _selectedImageUri.value = null } catch (e: Exception) { _uiState.value = EditProfileUIState(account.copy(), isLimited = isLimitedProfile) - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } @@ -118,13 +107,9 @@ class EditProfileViewModel( sendAccountUpdated() } catch (e: Exception) { _uiState.value = EditProfileUIState(account.copy(), isLimited = isLimitedProfile) - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt index d8297d1bd..56bba040f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/manageaccount/ManageAccountViewModel.kt @@ -3,17 +3,11 @@ package org.openedx.profile.presentation.manageaccount import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner 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.R -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics @@ -29,15 +23,11 @@ class ManageAccountViewModel( private val notifier: ProfileNotifier, private val analytics: ProfileAnalytics, val profileRouter: ProfileRouter -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState: MutableStateFlow = MutableStateFlow(ManageAccountUIState.Loading) internal val uiState: StateFlow = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - private val _isUpdating = MutableStateFlow(false) val isUpdating: StateFlow get() = _isUpdating.asStateFlow() @@ -74,19 +64,9 @@ class ManageAccountViewModel( account = account ) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } finally { _isUpdating.value = false } diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index 581bdc63f..6940055ec 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt @@ -34,7 +34,7 @@ class ProfileFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.collectAsState() - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val refreshing by viewModel.isUpdating.observeAsState(false) ProfileView( diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index b2d4ccb4e..38c681bb5 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -9,10 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.openedx.core.R -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.presentation.ProfileAnalytics @@ -28,15 +25,11 @@ class ProfileViewModel( private val notifier: ProfileNotifier, private val analytics: ProfileAnalytics, val profileRouter: ProfileRouter -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState: MutableStateFlow = MutableStateFlow(ProfileUIState.Loading) internal val uiState: StateFlow = _uiState.asStateFlow() - private val _uiMessage = MutableLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _isUpdating = MutableLiveData() val isUpdating: LiveData get() = _isUpdating @@ -73,13 +66,9 @@ class ProfileViewModel( account = account ) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - } + handleErrorUiMessage( + throwable = e, + ) } finally { _isUpdating.value = false } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index c21f72df3..4c418f705 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -23,9 +23,7 @@ import org.openedx.core.system.AppCookieManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.EmailUtil -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Configuration @@ -48,7 +46,7 @@ class SettingsViewModel( private val calendarRouter: CalendarRouter, private val appNotifier: AppNotifier, private val profileNotifier: ProfileNotifier, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState: MutableStateFlow = MutableStateFlow(SettingsUIState.Data(configuration)) internal val uiState: StateFlow = _uiState.asStateFlow() @@ -57,10 +55,6 @@ class SettingsViewModel( val successLogout: SharedFlow get() = _successLogout.asSharedFlow() - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private val configuration @@ -90,19 +84,9 @@ class SettingsViewModel( } ) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } finally { cookieManager.clearWebViewCookie() appNotifier.send(LogoutEvent(false)) diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt index 670447ddb..0b1778ded 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsViewModel.kt @@ -13,6 +13,7 @@ import org.openedx.core.presentation.settings.video.VideoQualityType import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileAnalyticsEvent import org.openedx.profile.presentation.ProfileAnalyticsKey @@ -23,7 +24,8 @@ class VideoSettingsViewModel( private val notifier: VideoNotifier, private val analytics: ProfileAnalytics, private val router: ProfileRouter, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _videoSettings = MutableLiveData() val videoSettings: LiveData diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index 131ec237b..e5d5784e9 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -19,9 +19,9 @@ 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.config.Config import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.ProfileMocks import org.openedx.profile.domain.interactor.ProfileInteractor @@ -30,6 +30,7 @@ import org.openedx.profile.system.notifier.account.AccountUpdated import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.io.File import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class EditProfileViewModelTest { @@ -53,8 +54,12 @@ class EditProfileViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { analytics.logScreenEvent(any(), any()) } returns Unit } @@ -80,8 +85,8 @@ class EditProfileViewModelTest { coVerify(exactly = 1) { interactor.updateAccount(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value?.isUpdating == false) } @@ -103,8 +108,8 @@ class EditProfileViewModelTest { coVerify(exactly = 1) { interactor.updateAccount(any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.uiState.value?.isUpdating == false) } @@ -128,7 +133,8 @@ class EditProfileViewModelTest { verify { analytics.logEvent(any(), any()) } coVerify(exactly = 1) { interactor.updateAccount(any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) assert(viewModel.uiState.value?.isUpdating == false) } @@ -153,8 +159,8 @@ class EditProfileViewModelTest { coVerify(exactly = 0) { interactor.updateAccount(any()) } coVerify(exactly = 1) { interactor.setProfileImage(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(noInternet, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.selectedImageUri.value == null) assert(viewModel.uiState.value?.isUpdating == false) } @@ -180,8 +186,8 @@ class EditProfileViewModelTest { coVerify(exactly = 0) { interactor.updateAccount(any()) } coVerify(exactly = 1) { interactor.setProfileImage(any(), any()) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(somethingWrong, message?.message) + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.selectedImageUri.value == null) assert(viewModel.uiState.value?.isUpdating == false) } @@ -210,7 +216,8 @@ class EditProfileViewModelTest { coVerify(exactly = 1) { interactor.updateAccount(any()) } coVerify(exactly = 1) { interactor.setProfileImage(any(), any()) } - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assertEquals(null, (message.await() as? UIMessage.SnackBarMessage)?.message) assert(viewModel.selectedImageUri.value == null) assert(viewModel.uiState.value?.isUpdating == false) } diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt index fa0b67bdc..f0d24dd0e 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/AnothersProfileViewModelTest.kt @@ -18,8 +18,8 @@ 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.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.ProfileMocks import org.openedx.profile.domain.interactor.ProfileInteractor @@ -27,6 +27,7 @@ import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.anothersaccount.AnothersProfileUIState import org.openedx.profile.presentation.anothersaccount.AnothersProfileViewModel import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class AnothersProfileViewModelTest { @@ -46,8 +47,12 @@ class AnothersProfileViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong } @After @@ -67,9 +72,9 @@ class AnothersProfileViewModelTest { coVerify(exactly = 1) { interactor.getAccount(username) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assert(viewModel.uiState.value is AnothersProfileUIState.Loading) - assertEquals(noInternet, message?.message) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -84,9 +89,9 @@ class AnothersProfileViewModelTest { coVerify(exactly = 1) { interactor.getAccount(username) } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assert(viewModel.uiState.value is AnothersProfileUIState.Loading) - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -104,6 +109,7 @@ class AnothersProfileViewModelTest { coVerify(exactly = 1) { interactor.getAccount(username) } assert(viewModel.uiState.value is AnothersProfileUIState.Data) - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) } } diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt index 7fd8977a1..80dfac5bb 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/CalendarViewModelTest.kt @@ -27,6 +27,7 @@ import org.openedx.core.system.notifier.calendar.CalendarCreated import org.openedx.core.system.notifier.calendar.CalendarNotifier import org.openedx.core.system.notifier.calendar.CalendarSynced import org.openedx.core.worker.CalendarSyncScheduler +import org.openedx.foundation.system.ResourceManager import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.calendar.CalendarViewModel @@ -43,6 +44,7 @@ class CalendarViewModelTest { private val calendarInteractor = mockk(relaxed = true) private val corePreferences = mockk(relaxed = true) private val profileRouter = mockk() + private val resourceManager = mockk() private val networkConnection = mockk() private val permissionLauncher = mockk>>() private val fragmentManager = mockk() @@ -59,7 +61,8 @@ class CalendarViewModelTest { calendarInteractor = calendarInteractor, corePreferences = corePreferences, profileRouter = profileRouter, - networkConnection = networkConnection + networkConnection = networkConnection, + resourceManager = resourceManager, ) } @@ -111,7 +114,8 @@ class CalendarViewModelTest { calendarInteractor, corePreferences, profileRouter, - networkConnection + networkConnection, + resourceManager, ) assertEquals(CalendarSyncState.OFFLINE, viewModel.uiState.value.calendarSyncState) @@ -129,7 +133,8 @@ class CalendarViewModelTest { calendarInteractor, corePreferences, profileRouter, - networkConnection + networkConnection, + resourceManager, ) assertEquals(CalendarSyncState.SYNCED, viewModel.uiState.value.calendarSyncState) @@ -150,7 +155,8 @@ class CalendarViewModelTest { calendarInteractor, corePreferences, profileRouter, - networkConnection + networkConnection, + resourceManager, ) assertTrue(viewModel.uiState.value.isCalendarExist) diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index 6ffcf1355..2b1cdc077 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -23,10 +23,10 @@ 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.config.Config import org.openedx.core.domain.model.AgreementUrls import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import org.openedx.profile.ProfileMocks import org.openedx.profile.domain.interactor.ProfileInteractor @@ -35,6 +35,7 @@ import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.system.notifier.account.AccountUpdated import org.openedx.profile.system.notifier.profile.ProfileNotifier import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class ProfileViewModelTest { @@ -57,8 +58,12 @@ class ProfileViewModelTest { @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 { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { config.isPreLoginExperienceEnabled() } returns false every { config.getFeedbackEmailAddress() } returns "" every { config.getAgreement(Locale.current.language) } returns AgreementUrls() @@ -85,9 +90,9 @@ class ProfileViewModelTest { coVerify(exactly = 1) { interactor.getAccount() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assert(viewModel.uiState.value is ProfileUIState.Loading) - assertEquals(noInternet, message?.message) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -107,9 +112,9 @@ class ProfileViewModelTest { coVerify(exactly = 1) { interactor.getAccount() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assert(viewModel.uiState.value is ProfileUIState.Data) - assertEquals(noInternet, message?.message) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -127,9 +132,9 @@ class ProfileViewModelTest { coVerify(exactly = 1) { interactor.getAccount() } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assert(viewModel.uiState.value is ProfileUIState.Loading) - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -150,7 +155,8 @@ class ProfileViewModelTest { coVerify(exactly = 1) { interactor.getAccount() } assert(viewModel.uiState.value is ProfileUIState.Data) - assert(viewModel.uiMessage.value == null) + val message = captureUiMessage(viewModel) + assert(message.await() == null) } @Test diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt index dbbbdda2f..986a58fcb 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewViewModel.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.global.AppData import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences @@ -21,7 +22,8 @@ class WhatsNewViewModel( private val router: WhatsNewRouter, private val preferencesManager: WhatsNewPreferences, private val appData: AppData, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _whatsNewItem = mutableStateOf(null) val whatsNewItem: State diff --git a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt index d99555c49..015fa7490 100644 --- a/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt +++ b/whatsnew/src/test/java/org/openedx/whatsnew/WhatsNewViewModelTest.kt @@ -6,6 +6,7 @@ import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Test import org.openedx.core.presentation.global.AppData +import org.openedx.foundation.system.ResourceManager import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.domain.model.WhatsNewItem import org.openedx.whatsnew.presentation.WhatsNewAnalytics @@ -18,6 +19,7 @@ class WhatsNewViewModelTest { private val router = mockk() private val preferencesManager = mockk() private val appData = mockk() + private val resourceManager = mockk() private val whatsNewItem = WhatsNewItem( version = "1.0.0", @@ -35,7 +37,8 @@ class WhatsNewViewModelTest { analytics, router, preferencesManager, - appData + appData, + resourceManager ) verify(exactly = 1) { whatsNewManager.getNewestData() } From 4b622a7e5ad074fcb6d2b6ea703154d00d9564d8 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:19:21 +0200 Subject: [PATCH 09/16] fix: Remove shift due date banner from home and content all tabs (#474) --- .../container/CourseContainerFragment.kt | 3 - .../CourseCompletionHomePagerCardContent.kt | 1 - .../presentation/home/CourseHomeScreen.kt | 34 ----- .../presentation/home/CourseHomeUIState.kt | 2 - .../presentation/home/CourseHomeViewModel.kt | 25 ---- .../outline/CourseContentAllScreen.kt | 31 ----- .../outline/CourseContentAllUIState.kt | 2 - .../outline/CourseContentAllViewModel.kt | 25 +--- .../home/CourseHomeViewModelTest.kt | 116 ------------------ 9 files changed, 1 insertion(+), 238 deletions(-) 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 57e0a3be4..80bbe2091 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 @@ -477,9 +477,6 @@ private fun DashboardPager( ), fragmentManager = fragmentManager, homePagerState = homePagerState, - onResetDatesClick = { - viewModel.onRefresh(CourseContainerTab.DATES) - }, onNavigateToContent = { contentTab -> scope.launch { // First scroll to CONTENT tab diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt index 8fcc08e07..bb247ee16 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseCompletionHomePagerCardContent.kt @@ -142,7 +142,6 @@ private fun CourseCompletionHomePagerCardContentPreview() { resumeUnitTitle = "Resumed Unit", courseSubSections = mapOf(), subSectionsDownloadsCount = mapOf(), - datesBannerInfo = CoreMocks.mockCourseDatesBannerInfo, useRelativeDates = true, courseVideos = mapOf(), courseAssignments = emptyList(), diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt index 48e449625..4e89ac1b4 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeScreen.kt @@ -62,8 +62,6 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.container.CourseContentTab -import org.openedx.course.presentation.ui.CourseDatesBanner -import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.ResumeCourseButton import org.openedx.course.presentation.unit.container.CourseViewMode @@ -80,7 +78,6 @@ fun CourseHomeScreen( viewModel: CourseHomeViewModel, fragmentManager: FragmentManager, homePagerState: PagerState, - onResetDatesClick: () -> Unit, onNavigateToContent: (CourseContentTab) -> Unit = {}, onNavigateToProgress: () -> Unit = {}, ) { @@ -136,13 +133,6 @@ fun CourseHomeScreen( fragmentManager = fragmentManager, ) }, - onResetDatesClick = { - viewModel.resetCourseDatesBanner( - onResetDates = { - onResetDatesClick() - } - ) - }, onCertificateClick = { viewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() @@ -185,7 +175,6 @@ private fun CourseHomeUI( onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, onDownloadClick: (blockIds: List) -> Unit, - onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, onVideoClick: (Block) -> Unit, onAssignmentClick: (Block) -> Unit, @@ -234,25 +223,6 @@ private fun CourseHomeUI( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { - if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { - Box( - modifier = Modifier - .padding(all = 8.dp) - ) { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } else { - CourseDatesBanner( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } - } - } - val certificate = uiState.courseStructure.certificate if (certificate?.isCertificateEarned() == true) { CourseMessage( @@ -459,7 +429,6 @@ private fun CourseHomeScreenPreview() { resumeUnitTitle = "Resumed Unit", courseSubSections = mapOf(), subSectionsDownloadsCount = mapOf(), - datesBannerInfo = CoreMocks.mockCourseDatesBannerInfo, useRelativeDates = true, courseVideos = mapOf(), courseAssignments = emptyList(), @@ -471,7 +440,6 @@ private fun CourseHomeScreenPreview() { onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, - onResetDatesClick = {}, onCertificateClick = {}, onVideoClick = {}, onAssignmentClick = {}, @@ -506,7 +474,6 @@ private fun CourseHomeScreenTabletPreview() { resumeUnitTitle = "Resumed Unit", courseSubSections = mapOf(), subSectionsDownloadsCount = mapOf(), - datesBannerInfo = CoreMocks.mockCourseDatesBannerInfo, useRelativeDates = true, courseVideos = mapOf(), courseAssignments = emptyList(), @@ -518,7 +485,6 @@ private fun CourseHomeScreenTabletPreview() { onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, - onResetDatesClick = {}, onCertificateClick = {}, onVideoClick = {}, onAssignmentClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt index 773cb07df..c43a504fa 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeUIState.kt @@ -1,7 +1,6 @@ package org.openedx.course.presentation.home import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState @@ -17,7 +16,6 @@ sealed class CourseHomeUIState { val resumeUnitTitle: String, val courseSubSections: Map>, val subSectionsDownloadsCount: Map, - val datesBannerInfo: CourseDatesBannerInfo, val useRelativeDates: Boolean, val courseVideos: Map>, val courseAssignments: List, diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index bd72bc19e..5a3ac9fed 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -14,13 +14,11 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.openedx.core.BlockType -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus -import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getChapterBlocks @@ -34,7 +32,6 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseProgressLoaded @@ -129,7 +126,6 @@ class CourseHomeViewModel( resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled, next = state.next, courseProgress = state.courseProgress, @@ -182,13 +178,11 @@ class CourseHomeViewModel( ) { courseStructure, courseStatus, courseDatesResult, courseProgress -> if (courseStructure == null) return@combine val blocks = courseStructure.blockData - val datesBannerInfo = courseDatesResult.courseBanner initializeCourseData( blocks, courseStructure, courseStatus, - datesBannerInfo, courseProgress ) }.catch { e -> @@ -201,7 +195,6 @@ class CourseHomeViewModel( blocks: List, courseStructure: CourseStructure, courseStatus: CourseComponentStatus, - datesBannerInfo: CourseDatesBannerInfo, courseProgress: CourseProgress ) { setBlocks(blocks) @@ -253,7 +246,6 @@ class CourseHomeViewModel( resumeUnitTitle = resumeVerticalBlock?.displayName ?: "", courseSubSections = courseSubSections, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled, courseProgress = courseProgress, courseVideos = courseVideos, @@ -365,23 +357,6 @@ class CourseHomeViewModel( return sequentialBlocks.find { !it.isCompleted() } } - fun resetCourseDatesBanner(onResetDates: (Boolean) -> Unit) { - viewModelScope.launch { - try { - interactor.resetCourseDates(courseId = courseId) - getCourseData() - courseNotifier.send(CourseDatesShifted) - onResetDates(true) - } catch (e: Exception) { - handleErrorUiMessage( - throwable = e, - defaultErrorRes = R.string.core_dates_shift_dates_unsuccessful_msg, - ) - onResetDates(false) - } - } - } - fun openBlock(fragmentManager: FragmentManager, blockId: String) { viewModelScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt index 751033ca2..ea86e5060 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllScreen.kt @@ -47,8 +47,6 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentAllEmptyState -import org.openedx.course.presentation.ui.CourseDatesBanner -import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.CourseProgress import org.openedx.course.presentation.ui.CourseSection @@ -130,9 +128,6 @@ fun CourseContentAllScreen( fragmentManager = fragmentManager, ) }, - onResetDatesClick = { - viewModel.resetCourseDatesBanner() - }, onCertificateClick = { viewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() @@ -151,7 +146,6 @@ private fun CourseContentAllUI( onSubSectionClick: (Block) -> Unit, onResumeClick: (String) -> Unit, onDownloadClick: (blockIds: List) -> Unit, - onResetDatesClick: () -> Unit, onCertificateClick: (String) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -214,27 +208,6 @@ private fun CourseContentAllUI( modifier = Modifier.fillMaxSize(), contentPadding = listBottomPadding ) { - if (uiState.datesBannerInfo.isBannerAvailableForDashboard()) { - item { - Box( - modifier = Modifier - .padding(all = 8.dp) - ) { - if (windowSize.isTablet) { - CourseDatesBannerTablet( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } else { - CourseDatesBanner( - banner = uiState.datesBannerInfo, - resetDates = onResetDatesClick, - ) - } - } - } - } - val certificate = uiState.courseStructure.certificate if (certificate?.isCertificateEarned() == true) { item { @@ -363,7 +336,6 @@ private fun CourseOutlineScreenPreview() { mapOf(), mapOf(), mapOf(), - CoreMocks.mockCourseDatesBannerInfo, true ), uiMessage = null, @@ -371,7 +343,6 @@ private fun CourseOutlineScreenPreview() { onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, - onResetDatesClick = {}, onCertificateClick = {}, onNavigateToHome = {}, ) @@ -393,7 +364,6 @@ private fun CourseContentAllScreenTabletPreview() { mapOf(), mapOf(), mapOf(), - CoreMocks.mockCourseDatesBannerInfo, true ), uiMessage = null, @@ -401,7 +371,6 @@ private fun CourseContentAllScreenTabletPreview() { onSubSectionClick = {}, onResumeClick = {}, onDownloadClick = {}, - onResetDatesClick = {}, onCertificateClick = {}, onNavigateToHome = {}, ) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt index 9a2deed32..0d8aa77c4 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllUIState.kt @@ -1,7 +1,6 @@ package org.openedx.course.presentation.outline import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState @@ -14,7 +13,6 @@ sealed class CourseContentAllUIState { val courseSubSections: Map>, val courseSectionsState: Map, val subSectionsDownloadsCount: Map, - val datesBannerInfo: CourseDatesBannerInfo, val useRelativeDates: Boolean, ) : CourseContentAllUIState() diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt index a30cde02f..18e2901b6 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseContentAllViewModel.kt @@ -12,13 +12,11 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.openedx.core.BlockType -import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock -import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.extension.getChapterBlocks import org.openedx.core.extension.getSequentialBlocks @@ -32,7 +30,6 @@ import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent -import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor @@ -118,7 +115,6 @@ class CourseContentAllViewModel( courseSubSections = courseSubSections, courseSectionsState = state.courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled ) } @@ -164,7 +160,6 @@ class CourseContentAllViewModel( courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled ) @@ -191,12 +186,11 @@ class CourseContentAllViewModel( }.collect { (courseStructure, courseStatus, courseDates) -> if (courseStructure == null) return@collect val blocks = courseStructure.blockData - val datesBannerInfo = courseDates.courseBanner checkIfCalendarOutOfDate(courseDates.datesSection.values.flatten()) updateOutdatedOfflineXBlocks(courseStructure) - initializeCourseData(blocks, courseStructure, courseStatus, datesBannerInfo) + initializeCourseData(blocks, courseStructure, courseStatus) } } } @@ -205,7 +199,6 @@ class CourseContentAllViewModel( blocks: List, courseStructure: CourseStructure, courseStatus: CourseComponentStatus, - datesBannerInfo: CourseDatesBannerInfo ) { setBlocks(blocks) courseSubSections.clear() @@ -225,7 +218,6 @@ class CourseContentAllViewModel( courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = datesBannerInfo, useRelativeDates = preferencesManager.isRelativeDatesEnabled ) } @@ -278,21 +270,6 @@ class CourseContentAllViewModel( return resumeBlock } - fun resetCourseDatesBanner() { - viewModelScope.launch { - try { - interactor.resetCourseDates(courseId = courseId) - getCourseData() - courseNotifier.send(CourseDatesShifted) - } catch (e: Exception) { - handleErrorUiMessage( - throwable = e, - defaultErrorRes = R.string.core_dates_shift_dates_unsuccessful_msg, - ) - } - } - } - fun openBlock(fragmentManager: FragmentManager, blockId: String) { viewModelScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt index a150639c3..0b661dece 100644 --- a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -32,7 +32,6 @@ import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseProgressLoaded @@ -103,7 +102,6 @@ class CourseHomeViewModelTest { every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { courseNotifier.notifier } returns flow { } - coEvery { courseNotifier.send(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit every { coreAnalytics.logEvent(any(), any()) } returns Unit @@ -334,120 +332,6 @@ class CourseHomeViewModelTest { coVerify(exactly = 0) { workerController.saveModels(any()) } } - @Test - fun `resetCourseDatesBanner success`() = runTest { - coEvery { interactor.resetCourseDates(courseId) } returns CoreMocks.mockResetCourseDates - coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { - emit( - CoreMocks.mockCourseStructure - ) - } - coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { - emit( - CoreMocks.mockCourseComponentStatus - ) - } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } - coEvery { - interactor.getCourseProgress( - courseId, - false, - true - ) - } returns flow { emit(CoreMocks.mockCourseProgress) } - - val viewModel = CourseHomeViewModel( - courseId = courseId, - courseTitle = courseTitle, - config = config, - interactor = interactor, - resourceManager = resourceManager, - courseNotifier = courseNotifier, - networkConnection = networkConnection, - preferencesManager = preferencesManager, - analytics = analytics, - downloadDialogManager = downloadDialogManager, - fileUtil = fileUtil, - courseRouter = courseRouter, - videoPreviewHelper = videoPreviewHelper, - coreAnalytics = coreAnalytics, - downloadDao = downloadDao, - workerController = workerController, - downloadHelper = downloadHelper - ) - - advanceUntilIdle() - - var resetResult: Boolean? = null - - viewModel.resetCourseDatesBanner { success -> - resetResult = success - } - - advanceUntilIdle() - - coVerify { interactor.resetCourseDates(courseId) } - coVerify { courseNotifier.send(CourseDatesShifted) } - assertEquals(true, resetResult) - } - - @Test - fun `resetCourseDatesBanner with internet error`() = runTest { - coEvery { interactor.resetCourseDates(courseId) } throws UnknownHostException() - coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { - emit( - CoreMocks.mockCourseStructure - ) - } - coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { - emit( - CoreMocks.mockCourseComponentStatus - ) - } - coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(CoreMocks.mockCourseDatesResult) } - coEvery { - interactor.getCourseProgress( - courseId, - false, - true - ) - } returns flow { emit(CoreMocks.mockCourseProgress) } - - val viewModel = CourseHomeViewModel( - courseId = courseId, - courseTitle = courseTitle, - config = config, - interactor = interactor, - resourceManager = resourceManager, - courseNotifier = courseNotifier, - networkConnection = networkConnection, - preferencesManager = preferencesManager, - analytics = analytics, - downloadDialogManager = downloadDialogManager, - fileUtil = fileUtil, - courseRouter = courseRouter, - videoPreviewHelper = videoPreviewHelper, - coreAnalytics = coreAnalytics, - downloadDao = downloadDao, - workerController = workerController, - downloadHelper = downloadHelper - ) - - advanceUntilIdle() - - var resetResult: Boolean? = null - - viewModel.resetCourseDatesBanner { success -> - resetResult = success - } - - advanceUntilIdle() - - coVerify { interactor.resetCourseDates(courseId) } - coVerify(exactly = 0) { courseNotifier.send(CourseDatesShifted) } - assertEquals(false, resetResult) - } - @Test fun `logVideoClick analytics event`() = runTest { coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { From e7fc168b2b04773fd3074d6d2d597d272280a508 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:34:49 +0200 Subject: [PATCH 10/16] chore: removed unused APP_LEVEL_DATES flag, added missing PRE_LOGIN EXPERIENCE ENABLED flag (#476) --- default_config/dev/config.yaml | 2 ++ default_config/prod/config.yaml | 5 ++--- default_config/stage/config.yaml | 5 ++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 952e041de..f2868eb78 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -83,6 +83,8 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #feature flag to enable registration from app REGISTRATION_ENABLED: true +#Enables the pre login courses discovery experience (LogistrationFragment). +PRE_LOGIN_EXPERIENCE_ENABLED: false #feature flag to do the authentication flow in the browser to log in BROWSER_LOGIN: false #feature flag to do the registration for in the browser diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index ac06ef7ba..f2868eb78 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,9 +31,6 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -APP_LEVEL_DATES: - ENABLED: true - FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false @@ -86,6 +83,8 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #feature flag to enable registration from app REGISTRATION_ENABLED: true +#Enables the pre login courses discovery experience (LogistrationFragment). +PRE_LOGIN_EXPERIENCE_ENABLED: false #feature flag to do the authentication flow in the browser to log in BROWSER_LOGIN: false #feature flag to do the registration for in the browser diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index ac06ef7ba..f2868eb78 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,9 +31,6 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -APP_LEVEL_DATES: - ENABLED: true - FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false @@ -86,6 +83,8 @@ WHATS_NEW_ENABLED: false SOCIAL_AUTH_ENABLED: false #feature flag to enable registration from app REGISTRATION_ENABLED: true +#Enables the pre login courses discovery experience (LogistrationFragment). +PRE_LOGIN_EXPERIENCE_ENABLED: false #feature flag to do the authentication flow in the browser to log in BROWSER_LOGIN: false #feature flag to do the registration for in the browser From 5843dd1e011d1a7221be9467c211ab5a9655b78f Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:23:20 +0200 Subject: [PATCH 11/16] fix: restart app crash (#478) --- app/src/main/java/org/openedx/app/AppRouter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index c168a9b5a..bf5a611d4 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -408,7 +408,7 @@ class AppRouter : if (isLogistrationEnabled) { replaceFragment(fm, LogistrationFragment()) } else { - replaceFragment(fm, SignInFragment()) + replaceFragment(fm, SignInFragment.newInstance(null, null)) } } } From 4c59055dccd2f2f815733c4c9f62212288a5861f Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:36:50 +0200 Subject: [PATCH 12/16] Chore: Migration to maretial3 (#475) --- .../logistration/LogistrationFragment.kt | 19 +- .../restore/RestorePasswordFragment.kt | 28 +- .../presentation/signin/compose/SignInView.kt | 38 +- .../presentation/signup/compose/SignUpView.kt | 117 ++- .../signup/compose/SocialSignedView.kt | 8 +- .../openedx/auth/presentation/ui/AuthUI.kt | 34 +- .../auth/presentation/ui/CheckboxField.kt | 8 +- .../auth/presentation/ui/SocialAuthView.kt | 6 +- build.gradle | 2 +- .../openedx/core/domain/model/DatesSection.kt | 2 +- .../core/presentation/dates/DatesUI.kt | 6 +- .../core/presentation/dialog/DialogUI.kt | 4 +- .../dialog/alert/ActionDialogFragment.kt | 4 +- .../dialog/alert/InfoDialogFragment.kt | 4 +- .../dialog/appreview/AppReviewUI.kt | 26 +- .../DownloadConfirmDialogFragment.kt | 4 +- .../DownloadErrorDialogFragment.kt | 4 +- .../DownloadStorageErrorDialogFragment.kt | 4 +- .../dialog/downloaddialog/DownloadView.kt | 6 +- .../SelectBottomDialogFragment.kt | 4 +- .../global/appupgrade/AppUpdateUI.kt | 21 +- .../calendarsync/CalendarSyncDialog.kt | 14 +- .../calendarsync/CalendarSyncState.kt | 2 +- .../settings/video/VideoQualityFragment.kt | 17 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 81 ++- .../java/org/openedx/core/ui/HTMLRenderer.kt | 4 +- .../java/org/openedx/core/ui/PageIndicator.kt | 2 +- .../org/openedx/core/ui/WebContentScreen.kt | 13 +- .../org/openedx/core/ui/theme/AppColors.kt | 65 +- .../org/openedx/core/ui/theme/AppShapes.kt | 6 +- .../openedx/core/ui/theme/AppTypography.kt | 2 +- .../java/org/openedx/core/ui/theme/Theme.kt | 67 +- core/src/main/res/values/themes.xml | 2 +- .../org/openedx/core/ui/theme/LocalShapes.kt | 12 +- .../ui/theme/compose/LogistrationLogoView.kt | 2 +- .../presentation/ChapterEndFragmentDialog.kt | 19 +- .../CourseContentAssignmentScreen.kt | 43 +- .../container/CollapsingLayout.kt | 9 +- .../container/CourseContainerFragment.kt | 184 +++-- .../presentation/container/HeaderContent.kt | 4 +- .../NoAccessCourseContainerFragment.kt | 17 +- .../contenttab/ContentTabEmptyState.kt | 6 +- .../contenttab/ContentTabScreen.kt | 14 +- .../presentation/dates/CourseDatesScreen.kt | 21 +- .../presentation/handouts/HandoutsScreen.kt | 21 +- .../handouts/HandoutsWebViewFragment.kt | 13 +- .../home/AssignmentsHomePagerCardContent.kt | 25 +- .../CourseCompletionHomePagerCardContent.kt | 4 +- .../presentation/home/CourseHomeScreen.kt | 30 +- .../home/GradesHomePagerCardContent.kt | 15 +- .../home/VideosHomePagerCardContent.kt | 71 +- .../offline/CourseOfflineScreen.kt | 30 +- .../outline/CourseContentAllScreen.kt | 15 +- .../progress/CourseProgressScreen.kt | 29 +- .../section/CourseSectionFragment.kt | 27 +- .../course/presentation/ui/CourseUI.kt | 89 +-- .../unit/NotAvailableUnitFragment.kt | 19 +- .../container/CourseUnitContainerFragment.kt | 8 +- .../unit/html/HtmlUnitFragment.kt | 8 +- .../videos/CourseContentVideoScreen.kt | 19 +- .../download/DownloadQueueFragment.kt | 22 +- .../src/main/java/org/openedx/DashboardUI.kt | 4 +- .../presentation/AllEnrolledCoursesView.kt | 70 +- .../presentation/DashboardGalleryView.kt | 203 +++--- .../presentation/DashboardListFragment.kt | 53 +- .../learn/presentation/LearnFragment.kt | 72 +- .../dates/presentation/dates/DatesScreen.kt | 182 +++-- .../presentation/NativeDiscoveryFragment.kt | 55 +- .../presentation/WebViewDiscoveryFragment.kt | 15 +- .../detail/AuthorizationDialogFragment.kt | 15 +- .../detail/CourseDetailsFragment.kt | 27 +- .../presentation/info/CourseInfoFragment.kt | 21 +- .../presentation/program/ProgramFragment.kt | 21 +- .../search/CourseSearchFragment.kt | 73 +- .../discovery/presentation/ui/DiscoveryUI.kt | 14 +- .../comments/DiscussionCommentsFragment.kt | 66 +- .../responses/DiscussionResponsesFragment.kt | 66 +- .../search/DiscussionSearchThreadFragment.kt | 52 +- .../threads/DiscussionAddThreadFragment.kt | 454 ++++++------ .../threads/DiscussionThreadsFragment.kt | 674 ++++++++---------- .../topics/DiscussionTopicsScreen.kt | 25 +- .../presentation/ui/DiscussionUI.kt | 23 +- .../presentation/download/DownloadsScreen.kt | 216 +++--- .../AnothersProfileFragment.kt | 19 +- .../calendar/CalendarAccessDialogFragment.kt | 4 +- .../calendar/CalendarSetUpView.kt | 22 +- .../calendar/CalendarSettingsView.kt | 38 +- .../presentation/calendar/CalendarView.kt | 19 +- .../calendar/CoursesToSyncFragment.kt | 45 +- .../DisableCalendarSyncDialogFragment.kt | 6 +- .../calendar/NewCalendarDialogFragment.kt | 197 +++-- .../delete/DeleteProfileFragment.kt | 17 +- .../presentation/edit/EditProfileFragment.kt | 605 ++++++++-------- .../compose/ManageAccountView.kt | 48 +- .../profile/compose/ProfileView.kt | 47 +- .../presentation/settings/SettingsScreenUI.kt | 32 +- .../profile/presentation/ui/ProfileUI.kt | 11 +- .../profile/presentation/ui/SettingsUI.kt | 10 +- .../video/VideoSettingsFragment.kt | 29 +- .../whatsnew/presentation/ui/WhatsNewUI.kt | 18 +- .../presentation/whatsnew/WhatsNewFragment.kt | 15 +- 101 files changed, 2498 insertions(+), 2485 deletions(-) diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index f8dbba635..fc82eb886 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -6,6 +6,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,11 +14,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -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.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -118,23 +118,22 @@ private fun LogistrationScreen( var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } - val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } - .fillMaxSize() - .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + .fillMaxSize(), + containerColor = MaterialTheme.appColors.background, + contentWindowInsets = WindowInsets() ) { Surface( modifier = Modifier .padding(it) .fillMaxSize() .verticalScroll(scrollState) + .navigationBarsPadding() .displayCutoutForLandscape(), color = MaterialTheme.appColors.background ) { diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index adb8da725..beebf4eaa 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,13 +21,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -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.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -127,21 +129,22 @@ private fun RestorePasswordScreen( onBackClick: () -> Unit, onRestoreButtonClick: (String) -> Unit, ) { - val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() var email by rememberSaveable { mutableStateOf("") } var isEmailError by rememberSaveable { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current + val snackbarHostState = remember { SnackbarHostState() } Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } .fillMaxSize() .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + contentWindowInsets = WindowInsets() ) { paddingValues -> val contentPaddings by remember { @@ -192,10 +195,7 @@ private fun RestorePasswordScreen( contentDescription = null ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) + HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) Column( modifier = Modifier diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index e182f51d7..69e3af16d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,14 +21,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -88,18 +90,19 @@ internal fun LoginScreen( uiMessage: UIMessage?, onEvent: (AuthEvent) -> Unit, ) { - val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } .fillMaxSize() .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + contentWindowInsets = WindowInsets() ) { val contentPaddings by remember { mutableStateOf( @@ -133,10 +136,7 @@ internal fun LoginScreen( contentScale = ContentScale.FillBounds, contentDescription = null ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) + HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) if (state.isLogistrationEnabled) { Box( modifier = Modifier @@ -364,9 +364,11 @@ private fun PasswordTextField( passwordTextFieldValue = it onValueChanged(it.text.trim()) }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.textFieldText, ), diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 8b917ebaa..5354a081a 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,15 +21,16 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -74,8 +76,6 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.SheetContent import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.isImeVisibleState -import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.rememberSaveableMap import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -88,7 +88,7 @@ import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable internal fun SignUpView( windowSize: WindowSize, @@ -99,12 +99,9 @@ internal fun SignUpView( onRegisterClick: (authType: AuthType) -> Unit, onHyperLinkClick: (Map, String) -> Unit, ) { - val scaffoldState = rememberScaffoldState() val focusManager = LocalFocusManager.current - val bottomSheetScaffoldState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true - ) + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet by rememberSaveable { mutableStateOf(false) } val coroutine = rememberCoroutineScope() val keyboardController = LocalSoftwareKeyboardController.current var expandedList by rememberSaveable { @@ -123,6 +120,7 @@ internal fun SignUpView( mutableStateMapOf() } val scrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } val haptic = LocalHapticFeedback.current @@ -136,8 +134,6 @@ internal fun SignUpView( mutableStateOf(TextFieldValue()) } - val isImeVisible by isImeVisibleState() - LaunchedEffect(uiState.validationError) { if (uiState.validationError) { coroutine.launch { @@ -156,22 +152,23 @@ internal fun SignUpView( } } - LaunchedEffect(bottomSheetScaffoldState.isVisible) { - if (!bottomSheetScaffoldState.isVisible) { + LaunchedEffect(showBottomSheet) { + if (!showBottomSheet) { focusManager.clearFocus() searchValue = TextFieldValue("") } } Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } .fillMaxSize() .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + contentWindowInsets = WindowInsets() ) { val topBarPadding by remember { mutableStateOf( @@ -209,21 +206,14 @@ internal fun SignUpView( ) } - ModalBottomSheetLayout( - modifier = Modifier - .padding(bottom = if (isImeVisible && bottomSheetScaffoldState.isVisible) 120.dp else 0.dp) - .noRippleClickable { - if (bottomSheetScaffoldState.isVisible) { - coroutine.launch { - bottomSheetScaffoldState.hide() - } - } - }, - sheetState = bottomSheetScaffoldState, - sheetShape = MaterialTheme.appShapes.screenBackgroundShape, - scrimColor = Color.Black.copy(alpha = 0.4f), - sheetBackgroundColor = MaterialTheme.appColors.background, - sheetContent = { + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = bottomSheetState, + shape = MaterialTheme.appShapes.screenBackgroundShape, + scrimColor = Color.Black.copy(alpha = 0.4f), + containerColor = MaterialTheme.appColors.background, + ) { SheetContent( title = bottomDialogTitle, searchValue = searchValue, @@ -233,7 +223,8 @@ internal fun SignUpView( onFieldUpdated(serverFieldName.value, item.value) selectableNamesMap[serverFieldName.value] = item.name coroutine.launch { - bottomSheetScaffoldState.hide() + bottomSheetState.hide() + showBottomSheet = false } }, searchValueChanged = { @@ -241,20 +232,18 @@ internal fun SignUpView( } ) } - ) { - Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null - ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) - Column( + } + + Image( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.3f), + painter = painterResource(id = coreR.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) + Column( Modifier .fillMaxWidth() .padding(it) @@ -355,12 +344,13 @@ internal fun SignUpView( serverFieldName.value = serverName expandedList = list coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() + if (showBottomSheet) { + bottomSheetState.hide() + showBottomSheet = false } else { bottomDialogTitle = field.label showErrorMap[field.name] = false - bottomSheetScaffoldState.show() + showBottomSheet = true } } }, @@ -385,12 +375,13 @@ internal fun SignUpView( serverName expandedList = list coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() + if (showBottomSheet) { + bottomSheetState.hide() + showBottomSheet = false } else { bottomDialogTitle = field.label showErrorMap[field.name] = false - bottomSheetScaffoldState.show() + showBottomSheet = true } } }, @@ -408,12 +399,13 @@ internal fun SignUpView( serverFieldName.value = serverName expandedList = list coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() + if (showBottomSheet) { + bottomSheetState.hide() + showBottomSheet = false } else { bottomDialogTitle = field.label showErrorMap[field.name] = false - bottomSheetScaffoldState.show() + showBottomSheet = true } } }, @@ -463,7 +455,6 @@ internal fun SignUpView( } } } - } } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt index 2045297a5..30cdf50e4 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -48,7 +48,7 @@ internal fun SocialSignedView(authType: AuthType) { Text( fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.primary, + color = MaterialTheme.appColors.primary, text = stringResource( id = R.string.auth_social_signed_title, authType.methodName diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 61d8f7450..726f14e1b 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -16,17 +16,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -261,9 +261,11 @@ fun LoginTextField( loginTextFieldValue = it onValueChanged(it.text.trim()) }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.textFieldText, ), @@ -373,9 +375,11 @@ fun InputRegistrationField( onValueChanged(registrationField.name, it.trim(), true) } }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, focusedBorderColor = MaterialTheme.appColors.textFieldBorder, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.textFieldText, @@ -459,11 +463,13 @@ fun SelectableRegisterField( enabled = false, singleLine = true, value = initialValue, - colors = TextFieldDefaults.outlinedTextFieldColors( + colors = OutlinedTextFieldDefaults.colors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, disabledBorderColor = MaterialTheme.appColors.textFieldBorder, disabledTextColor = MaterialTheme.appColors.textPrimary, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, + disabledContainerColor = MaterialTheme.appColors.textFieldBackground, disabledPlaceholderColor = MaterialTheme.appColors.textFieldHint ), shape = MaterialTheme.appShapes.textFieldShape, diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt index b134cb59a..ffc46caca 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt @@ -2,10 +2,10 @@ package org.openedx.auth.presentation.ui import android.content.res.Configuration import androidx.compose.foundation.layout.Row -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index e4962d072..ceb52dfb4 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/build.gradle b/build.gradle index 8570eb77d..4e0b4d12e 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ buildscript { play_services_ads_identifier_version = '18.2.0' install_referrer_version = '2.2' snakeyaml_version = '2.4' - openedx_foundation_version = '1.1.0' + openedx_foundation_version = '1.1.1' openedx_firebase_analytics_version = '1.0.1' braze_sdk_version = '37.0.0' diff --git a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt index 33d884bed..01a769819 100644 --- a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt @@ -1,6 +1,6 @@ package org.openedx.core.domain.model -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import org.openedx.core.R diff --git a/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt index 2833998f9..98b4bb892 100644 --- a/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt @@ -14,11 +14,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt index 17b1d2874..9b49b2ae8 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt @@ -7,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt index 28f357896..9f0b00c24 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt @@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt index 77c413924..de172374e 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt @@ -12,8 +12,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt index 632669c11..e7645b0af 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -13,16 +13,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Star import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableState @@ -170,10 +170,12 @@ fun FeedbackDialog( style = MaterialTheme.appTypography.labelLarge, ) }, - colors = TextFieldDefaults.outlinedTextFieldColors( - backgroundColor = MaterialTheme.appColors.cardViewBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.appColors.cardViewBackground, + unfocusedContainerColor = MaterialTheme.appColors.cardViewBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.textFieldText + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText ), ) @@ -254,7 +256,7 @@ fun TransparentTextButton( modifier = Modifier .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = Color.Transparent + containerColor = Color.Transparent ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -288,7 +290,7 @@ fun DefaultTextButton( modifier = Modifier .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = backgroundColor, + containerColor = backgroundColor, contentColor = textColor ), elevation = null, diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index 5ab8db529..7881b054a 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -16,11 +16,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index f7bbe6ea5..e6bdf8e64 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -16,8 +16,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index 8c026bdf2..67c556043 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt index 58a5f9d22..14ab4e66c 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt @@ -4,9 +4,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt index 3890aa360..11e007e51 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt @@ -12,8 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable diff --git a/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt index e0cbae480..e755e0aa5 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt @@ -16,13 +16,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -267,7 +268,7 @@ fun TransparentTextButton( .testTag("btn_secondary") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = Color.Transparent + containerColor = Color.Transparent ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -292,7 +293,7 @@ fun DefaultTextButton( .testTag("btn_primary") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.primaryButtonBackground + containerColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -326,7 +327,7 @@ fun AppUpgradeRecommendedBox( onClick() }, shape = MaterialTheme.appShapes.cardShape, - backgroundColor = MaterialTheme.appColors.primary + colors = CardDefaults.cardColors(containerColor = MaterialTheme.appColors.primary) ) { Row( modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt index 15f94d338..10c4cf73a 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt @@ -7,11 +7,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.AlertDialog -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -124,7 +124,7 @@ private fun CalendarAlertDialog(dialogProperties: DialogProperties, onDismiss: ( shape = MaterialTheme.appShapes.cardShape ), shape = MaterialTheme.appShapes.cardShape, - backgroundColor = MaterialTheme.appColors.background, + containerColor = MaterialTheme.appColors.background, properties = AlertDialogProperties( dismissOnBackPress = false, @@ -133,7 +133,7 @@ private fun CalendarAlertDialog(dialogProperties: DialogProperties, onDismiss: ( onDismissRequest = onDismiss, title = dialogProperties.title.takeIfNotEmpty()?.let { - @Composable { + { Text( text = dialogProperties.title, color = MaterialTheme.appColors.textPrimary, diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt index 95a851442..5ad386dee 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt @@ -1,12 +1,12 @@ package org.openedx.core.presentation.settings.calendarsync import androidx.annotation.StringRes -import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudSync import androidx.compose.material.icons.filled.SyncDisabled import androidx.compose.material.icons.rounded.EventRepeat import androidx.compose.material.icons.rounded.FreeCancellation +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color diff --git a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt index b370cd56d..94dbad546 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,14 +19,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -129,7 +129,6 @@ private fun VideoQualityScreen( onQualityChanged: (VideoQuality) -> Unit, onBackClick: () -> Unit ) { - val scaffoldState = rememberScaffoldState() Scaffold( modifier = Modifier .fillMaxSize() @@ -137,7 +136,7 @@ private fun VideoQualityScreen( .semantics { testTagsAsResourceId = true }, - scaffoldState = scaffoldState, + contentWindowInsets = WindowInsets() ) { paddingValues -> val topBarWidth by remember(key1 = windowSize) { @@ -249,7 +248,7 @@ private fun QualityOption( ) } } - Divider() + HorizontalDivider() } @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) 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 4230980be..734e7c951 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -37,18 +37,6 @@ import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.OutlinedTextField -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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountCircle @@ -56,6 +44,18 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonRestartableComposable @@ -258,17 +258,16 @@ fun SearchBar( textFieldValue = it } }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textPrimary, - backgroundColor = if (isFocused) { - MaterialTheme.appColors.background - } else { - MaterialTheme.appColors.textFieldBackground - }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textPrimary, + unfocusedTextColor = MaterialTheme.appColors.textPrimary, + focusedContainerColor = MaterialTheme.appColors.background, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.primary, - leadingIconColor = MaterialTheme.appColors.textPrimary + focusedLeadingIconColor = MaterialTheme.appColors.textPrimary, + unfocusedLeadingIconColor = MaterialTheme.appColors.textPrimary ), placeholder = { Text( @@ -353,17 +352,16 @@ fun SearchBarStateless( onValueChanged(it) } }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textPrimary, - backgroundColor = if (isFocused) { - MaterialTheme.appColors.background - } else { - MaterialTheme.appColors.textFieldBackground - }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textPrimary, + unfocusedTextColor = MaterialTheme.appColors.textPrimary, + focusedContainerColor = MaterialTheme.appColors.background, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.primary, - leadingIconColor = MaterialTheme.appColors.textPrimary + focusedLeadingIconColor = MaterialTheme.appColors.textPrimary, + unfocusedLeadingIconColor = MaterialTheme.appColors.textPrimary ), placeholder = { Text( @@ -409,13 +407,13 @@ fun SearchBarStateless( @NonRestartableComposable fun HandleUIMessage( uiMessage: UIMessage?, - scaffoldState: ScaffoldState, + snackbarHostState: SnackbarHostState, ) { val context = LocalContext.current LaunchedEffect(uiMessage) { when (uiMessage) { is UIMessage.SnackBarMessage -> { - scaffoldState.snackbarHostState.showSnackbar( + snackbarHostState.showSnackbar( message = uiMessage.message, duration = uiMessage.duration ) @@ -567,7 +565,7 @@ fun SheetContent( style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center ) - Divider(modifier = Modifier.padding(horizontal = 16.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } } @@ -633,7 +631,7 @@ fun SheetContent( style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center ) - Divider(modifier = Modifier.padding(horizontal = 16.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } } @@ -684,10 +682,13 @@ fun OpenEdXOutlinedTextField( inputFieldValue = it onValueChanged(it.text) }, - colors = TextFieldDefaults.outlinedTextFieldColors( + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, errorBorderColor = MaterialTheme.appColors.error, ), shape = MaterialTheme.appShapes.textFieldShape, @@ -966,7 +967,7 @@ fun OpenEdXButton( .then(modifier), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( - backgroundColor = backgroundColor + containerColor = backgroundColor ), enabled = enabled, onClick = onClick @@ -1004,7 +1005,7 @@ fun OpenEdXOutlinedButton( enabled = enabled, border = BorderStroke(1.dp, borderColor), shape = MaterialTheme.appShapes.buttonShape, - colors = ButtonDefaults.outlinedButtonColors(backgroundColor = backgroundColor) + colors = ButtonDefaults.outlinedButtonColors(containerColor = backgroundColor) ) { if (content == null) { Text( @@ -1136,14 +1137,16 @@ fun AuthButtonsPanel( onSignInClick: () -> Unit, showRegisterButton: Boolean, ) { - Row { + Row( + verticalAlignment = Alignment.CenterVertically + ) { OpenEdXOutlinedButton( modifier = Modifier .testTag("btn_sign_in") .then( if (showRegisterButton) { Modifier - .width(100.dp) + .width(120.dp) .padding(end = 16.dp) } else { Modifier.weight(1f) diff --git a/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt index 0105e2cff..34980354c 100644 --- a/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt +++ b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt @@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt index 8e9f4f40b..411de5736 100644 --- a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt +++ b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index 70f320368..7bec499c7 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -9,14 +9,14 @@ import android.webkit.WebViewClient import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -54,7 +54,6 @@ fun WebContentScreen( htmlBody: String? = null, contentUrl: String? = null, ) { - val scaffoldState = rememberScaffoldState() Scaffold( modifier = Modifier .fillMaxSize() @@ -62,8 +61,8 @@ fun WebContentScreen( .semantics { testTagsAsResourceId = true }, - scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + contentWindowInsets = WindowInsets() ) { val screenWidth by remember(key1 = windowSize) { mutableStateOf( 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 bf20366d9..daf1e649e 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 @@ -1,10 +1,10 @@ package org.openedx.core.ui.theme -import androidx.compose.material.Colors +import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color data class AppColors( - val material: Colors, + val material3: ColorScheme, val textPrimary: Color, val textPrimaryVariant: Color, @@ -83,17 +83,52 @@ data class AppColors( val gradeProgressBarBackground: Color, val assignmentCardBorder: Color, ) { - val primary: Color get() = material.primary - val primaryVariant: Color get() = material.primaryVariant - val secondary: Color get() = material.secondary - val secondaryVariant: Color get() = material.secondaryVariant - val background: Color get() = material.background - val surface: Color get() = material.surface - val error: Color get() = material.error - val onPrimary: Color get() = material.onPrimary - val onSecondary: Color get() = material.onSecondary - val onBackground: Color get() = material.onBackground - val onSurface: Color get() = material.onSurface - val onError: Color get() = material.onError - val isLight: Boolean get() = material.isLight + // Material 3 ColorScheme accessors + val primary: Color get() = material3.primary + val onPrimary: Color get() = material3.onPrimary + val primaryContainer: Color get() = material3.primaryContainer + val onPrimaryContainer: Color get() = material3.onPrimaryContainer + val secondary: Color get() = material3.secondary + val onSecondary: Color get() = material3.onSecondary + val secondaryContainer: Color get() = material3.secondaryContainer + val onSecondaryContainer: Color get() = material3.onSecondaryContainer + val tertiary: Color get() = material3.tertiary + val onTertiary: Color get() = material3.onTertiary + val tertiaryContainer: Color get() = material3.tertiaryContainer + val onTertiaryContainer: Color get() = material3.onTertiaryContainer + val background: Color get() = material3.background + val onBackground: Color get() = material3.onBackground + val surface: Color get() = material3.surface + val onSurface: Color get() = material3.onSurface + val surfaceVariant: Color get() = material3.surfaceVariant + val onSurfaceVariant: Color get() = material3.onSurfaceVariant + val error: Color get() = material3.error + val onError: Color get() = material3.onError + val errorContainer: Color get() = material3.errorContainer + val onErrorContainer: Color get() = material3.onErrorContainer + val outline: Color get() = material3.outline + val outlineVariant: Color get() = material3.outlineVariant + val inverseSurface: Color get() = material3.inverseSurface + val inverseOnSurface: Color get() = material3.inverseOnSurface + val inversePrimary: Color get() = material3.inversePrimary + val surfaceTint: Color get() = material3.surfaceTint + val scrim: Color get() = material3.scrim + + // Backward compatibility accessors for M1 color names + @Deprecated("Use primary instead", ReplaceWith("primary")) + val primaryVariant: Color get() = material3.primaryContainer + + @Deprecated("Use secondary instead", ReplaceWith("secondary")) + val secondaryVariant: Color get() = material3.secondaryContainer + + // Helper to determine if this is a light theme + val isLight: Boolean get() = material3.background.luminance() > 0.5f + + private fun Color.luminance(): Float { + val r = red + val g = green + val b = blue + @Suppress("MagicNumber") + return 0.299f * r + 0.587f * g + 0.114f * b + } } diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt index 1a45681f9..214ebdf5b 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt @@ -1,13 +1,13 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable data class AppShapes( - val material: Shapes, + val material3: Shapes, val buttonShape: CornerBasedShape, val navigationButtonShape: CornerBasedShape, val textFieldShape: CornerBasedShape, diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt b/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt index 52d9adebb..5fc36d480 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt @@ -1,6 +1,6 @@ package org.openedx.core.ui.theme -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf 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 9b42c90ac..ec7997c72 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 @@ -3,27 +3,40 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf + +internal val LocalAppColors = staticCompositionLocalOf { + error("No AppColors provided") +} private val DarkColorPalette = AppColors( - material = darkColors( + material3 = darkColorScheme( primary = dark_primary, - primaryVariant = dark_primary_variant, - secondary = dark_secondary, - secondaryVariant = dark_secondary_variant, - background = dark_background, - surface = dark_surface, - error = dark_error, onPrimary = dark_onPrimary, + primaryContainer = dark_primary_variant, + onPrimaryContainer = dark_onPrimary, + secondary = dark_secondary, onSecondary = dark_onSecondary, + secondaryContainer = dark_secondary_variant, + onSecondaryContainer = dark_onSecondary, + tertiary = dark_secondary, + onTertiary = dark_onSecondary, + background = dark_background, onBackground = dark_onBackground, + surface = dark_surface, onSurface = dark_onSurface, - onError = dark_onError + surfaceVariant = dark_surface, + onSurfaceVariant = dark_onSurface, + error = dark_error, + onError = dark_onError, + outline = dark_text_field_border, + outlineVariant = dark_divider, ), textPrimary = dark_text_primary, textPrimaryVariant = dark_text_primary_variant, @@ -103,19 +116,27 @@ private val DarkColorPalette = AppColors( ) private val LightColorPalette = AppColors( - material = lightColors( + material3 = lightColorScheme( primary = light_primary, - primaryVariant = light_primary_variant, - secondary = light_secondary, - secondaryVariant = light_secondary_variant, - background = light_background, - surface = light_surface, - error = light_error, onPrimary = light_onPrimary, + primaryContainer = light_primary_variant, + onPrimaryContainer = light_onPrimary, + secondary = light_secondary, onSecondary = light_onSecondary, + secondaryContainer = light_secondary_variant, + onSecondaryContainer = light_onSecondary, + tertiary = light_secondary, + onTertiary = light_onSecondary, + background = light_background, onBackground = light_onBackground, + surface = light_surface, onSurface = light_onSurface, - onError = light_onError + surfaceVariant = light_surface, + onSurfaceVariant = light_onSurface, + error = light_error, + onError = light_onError, + outline = light_text_field_border, + outlineVariant = light_divider, ), textPrimary = light_text_primary, textPrimaryVariant = light_text_primary_variant, @@ -197,7 +218,7 @@ private val LightColorPalette = AppColors( val MaterialTheme.appColors: AppColors @Composable @ReadOnlyComposable - get() = if (colors.isLight) LightColorPalette else DarkColorPalette + get() = LocalAppColors.current @OptIn(ExperimentalFoundationApi::class) @Composable @@ -209,11 +230,11 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl } MaterialTheme( - colors = colors.material, - // typography = LocalTypography.current.material, - shapes = LocalShapes.current.material, + colorScheme = colors.material3, + shapes = LocalShapes.current.material3, ) { CompositionLocalProvider( + LocalAppColors provides colors, LocalOverscrollFactory provides null, content = content ) diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml index e43010475..a55cddceb 100644 --- a/core/src/main/res/values/themes.xml +++ b/core/src/main/res/values/themes.xml @@ -1,6 +1,6 @@ -