From 99d4cbc171fd0ab730eb9fcd3f0c92da5a601d90 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Feb 2025 20:20:17 +0200 Subject: [PATCH 01/41] feat: dates tab UI --- app/build.gradle | 1 + .../main/java/org/openedx/app/AppAnalytics.kt | 4 + .../main/java/org/openedx/app/AppRouter.kt | 4 +- .../java/org/openedx/app/MainViewModel.kt | 4 + .../java/org/openedx/app/deeplink/HomeTab.kt | 1 + .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 8 +- app/src/main/res/menu/bottom_view_menu.xml | 0 app/src/main/res/values/strings.xml | 1 + .../core/presentation/ListItemPosition.kt | 16 + .../java/org/openedx/core/ui/ComposeCommon.kt | 79 ++-- .../learn/presentation/LearnFragment.kt | 6 +- dates/.gitignore | 1 + dates/build.gradle | 64 ++++ dates/consumer-rules.pro | 0 dates/proguard-rules.pro | 21 ++ dates/src/main/AndroidManifest.xml | 4 + .../openedx/dates/presentation/DatesRouter.kt | 8 + .../dates/presentation/dates/DatesFragment.kt | 355 ++++++++++++++++++ .../dates/presentation/dates/DatesUIState.kt | 6 + .../presentation/dates/DatesViewModel.kt | 58 +++ .../presentation/dates/DueDateCategory.kt | 31 ++ dates/src/main/res/layout/fragment_dates.xml | 6 + dates/src/main/res/values/strings.xml | 11 + settings.gradle | 1 + 25 files changed, 653 insertions(+), 39 deletions(-) create mode 100644 app/src/main/res/menu/bottom_view_menu.xml create mode 100644 core/src/main/java/org/openedx/core/presentation/ListItemPosition.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/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/DatesUIState.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt create mode 100644 dates/src/main/res/layout/fragment_dates.xml create mode 100644 dates/src/main/res/values/strings.xml 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/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/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 8723d6dbe..5ca4ec153 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -65,6 +65,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/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..f680b33e5 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,7 @@ 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.DatesRouter import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -131,6 +132,7 @@ val appModule = module { single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } single { get() } + single { get() } single { NetworkConnection(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..cf9026767 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,7 @@ 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.presentation.dates.DatesViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -237,7 +238,6 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } - single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -583,4 +583,10 @@ val screenModule = module { analytics = get() ) } + + viewModel { + DatesViewModel( + datesRouter = get(), + ) + } } diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml new file mode 100644 index 000000000..e69de29bb 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/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/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index eed214567..6243dae74 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 MainScreenTitle( + 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 { + MainScreenTitle( + label = "Title", + onSettingsClick = {} + ) + } +} + @Composable fun OpenEdXDropdownMenuItem( modifier: Modifier = Modifier, 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..54e4402ee 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.MainScreenTitle 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( + MainScreenTitle( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -240,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - MainToolbar( + MainScreenTitle( 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..481bb4348 --- /dev/null +++ b/dates/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file 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/presentation/DatesRouter.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt new file mode 100644 index 000000000..78f7472ba --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt @@ -0,0 +1,8 @@ +package org.openedx.dates.presentation + +import androidx.fragment.app.FragmentManager + +interface DatesRouter { + + fun navigateToSettings(fm: FragmentManager) +} 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..8cd818edf --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -0,0 +1,355 @@ +package org.openedx.dates.presentation.dates + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.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.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +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.automirrored.filled.ArrowForwardIos +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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 androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.presentation.ListItemPosition +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.MainScreenTitle +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.appTypography +import org.openedx.dates.R +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +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, + onAction = { action -> + when (action) { + DatesViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DatesViewActions.ReloadData -> { + + } + + is DatesViewActions.OpenEvent -> { + + } + } + } + ) + } + } + } + +} + +@Composable +private fun DatesScreen( + uiState: DatesUIState, + uiMessage: UIMessage?, + 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(), + ) + ) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainScreenTitle( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.dates), + onSettingsClick = { + onAction(DatesViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + if (uiState.isLoading) { + 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, + contentPadding = PaddingValues(bottom = 20.dp) + ) { + uiState.dates.keys.forEach { dueDateCategory -> + val dates = uiState.dates[dueDateCategory] ?: emptyList() + if (dates.isNotEmpty()) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, top = 20.dp), + text = stringResource(id = dueDateCategory.label), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + } + itemsIndexed(dates) { index, date -> + val itemPosition = ListItemPosition.detectPosition(index, dates) + DateItem( + date = date, + lineColor = dueDateCategory.color, + itemPosition = itemPosition, + onClick = { + onAction(DatesViewActions.OpenEvent()) + } + ) + } + } + } + } + } + } + } + ) +} + +@Composable +private fun DateItem( + modifier: Modifier = Modifier, + date: String, + lineColor: Color, + itemPosition: ListItemPosition, + onClick: () -> Unit, +) { + val boxCornerWidth = 8.dp + val boxCornerRadius = boxCornerWidth / 2 + val infoPadding = 8.dp + + val boxCornerShape = remember(itemPosition) { + when (itemPosition) { + ListItemPosition.SINGLE -> RoundedCornerShape(boxCornerRadius) + ListItemPosition.MIDDLE -> RectangleShape + ListItemPosition.FIRST -> RoundedCornerShape( + topStart = boxCornerRadius, + topEnd = boxCornerRadius + ) + + ListItemPosition.LAST -> RoundedCornerShape( + bottomStart = boxCornerRadius, + bottomEnd = boxCornerRadius + ) + } + } + + val infoPaddingModifier = remember(itemPosition) { + when (itemPosition) { + ListItemPosition.SINGLE -> Modifier + ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) + ListItemPosition.LAST -> Modifier.padding(top = infoPadding) + ListItemPosition.MIDDLE -> Modifier.padding(vertical = infoPadding) + } + } + + val arrowOffset = remember(itemPosition) { + when (itemPosition) { + ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) + ListItemPosition.LAST -> Modifier.padding(top = infoPadding) + else -> Modifier + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically + ) { + // Colored line box + Box( + modifier = Modifier + .width(boxCornerWidth) + .fillMaxHeight() + .background(color = lineColor, shape = boxCornerShape) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier + .weight(1f) + .then(infoPaddingModifier), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = date, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_assignment), + contentDescription = null, + tint = MaterialTheme.appColors.textDark, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = date, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + } + Text( + text = date, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = null, + tint = MaterialTheme.appColors.textDark, + modifier = arrowOffset.size(16.dp) + ) + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), + 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, + onAction = {} + ) + } +} 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..205c34978 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -0,0 +1,6 @@ +package org.openedx.dates.presentation.dates + +data class DatesUIState( + val isLoading: Boolean = true, + 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..0295cf015 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt @@ -0,0 +1,58 @@ +package org.openedx.dates.presentation.dates + +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.flow.update +import kotlinx.coroutines.launch +import org.openedx.dates.presentation.DatesRouter +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage + +class DatesViewModel( + private val datesRouter: DatesRouter, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(DatesUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + init { + fetchDates() + } + + private fun fetchDates() { + viewModelScope.launch { + _uiState.update { state -> + state.copy( + isLoading = false, + dates = mapOf( + DueDateCategory.PAST_DUE to listOf("Date1", "Date2", "Date3"), + DueDateCategory.TODAY to listOf("Date1"), + DueDateCategory.THIS_WEEK to listOf("Date1", "Date2"), + DueDateCategory.UPCOMING to listOf("Date1", "Date2", "Date3", "Date4"), + ) + ) + } + } + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + datesRouter.navigateToSettings(fragmentManager) + } +} + +interface DatesViewActions { + object OpenSettings : DatesViewActions + class OpenEvent() : DatesViewActions + object ReloadData : DatesViewActions +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt new file mode 100644 index 000000000..78ebda298 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt @@ -0,0 +1,31 @@ +package org.openedx.dates.presentation.dates + +import androidx.annotation.StringRes +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import org.openedx.core.ui.theme.appColors +import org.openedx.dates.R + +enum class DueDateCategory( + @StringRes + val label: Int, +) { + PAST_DUE(R.string.dates_category_past_due), + TODAY(R.string.dates_category_today), + THIS_WEEK(R.string.dates_category_this_week), + NEXT_WEEK(R.string.dates_category_next_week), + UPCOMING(R.string.dates_category_upcoming); + + val color: Color + @Composable + get() { + return when (this) { + PAST_DUE -> MaterialTheme.appColors.warning + TODAY -> MaterialTheme.appColors.info + THIS_WEEK -> MaterialTheme.appColors.textPrimaryVariant + NEXT_WEEK -> MaterialTheme.appColors.textFieldBorder + UPCOMING -> MaterialTheme.appColors.divider + } + } +} diff --git a/dates/src/main/res/layout/fragment_dates.xml b/dates/src/main/res/layout/fragment_dates.xml new file mode 100644 index 000000000..77d9ef65f --- /dev/null +++ b/dates/src/main/res/layout/fragment_dates.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml new file mode 100644 index 000000000..3187e2b97 --- /dev/null +++ b/dates/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + Dates + Past Due + Today + This Week + Next Week + Upcoming + No Dates + You currently have no active courses with scheduled events. Enroll in a course to view important dates and deadlines. + \ No newline at end of file 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 c3dba4d0a2ee701ccaf15aef53df3c88315877be Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 12:27:53 +0200 Subject: [PATCH 02/41] feat: added config flag for enabling/disabling dates screen --- app/src/main/java/org/openedx/app/MainViewModel.kt | 1 + core/src/main/java/org/openedx/core/config/Config.kt | 4 ++++ core/src/main/java/org/openedx/core/config/DatesConfig.kt | 8 ++++++++ default_config/dev/config.yaml | 3 +++ default_config/prod/config.yaml | 3 +++ default_config/stage/config.yaml | 3 +++ 6 files changed, 22 insertions(+) create mode 100644 core/src/main/java/org/openedx/core/config/DatesConfig.kt diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 5ca4ec153..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) { 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/DatesConfig.kt b/core/src/main/java/org/openedx/core/config/DatesConfig.kt new file mode 100644 index 000000000..0e48a5ed5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DatesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DatesConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 952e041de..e6ab8bce2 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +DATES: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index a7f265a45..c013c2a99 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +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 a7f265a45..c013c2a99 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +DATES: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false From 46860102bd24fa9ac95f33a8d65ca2c59e6dc4ed Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 13:24:28 +0200 Subject: [PATCH 03/41] feat: pull to refresh --- .../dates/presentation/dates/DatesFragment.kt | 121 +++++++++++------- .../dates/presentation/dates/DatesUIState.kt | 1 + .../presentation/dates/DatesViewModel.kt | 12 +- 3 files changed, 84 insertions(+), 50 deletions(-) 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 index 8cd818edf..44ae32834 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -24,12 +24,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape 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.automirrored.filled.ArrowForwardIos +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -92,8 +96,8 @@ class DatesFragment : Fragment() { viewModel.onSettingsClick(requireActivity().supportFragmentManager) } - DatesViewActions.ReloadData -> { - + DatesViewActions.SwipeRefresh -> { + viewModel.refreshData() } is DatesViewActions.OpenEvent -> { @@ -108,6 +112,7 @@ class DatesFragment : Fragment() { } +@OptIn(ExperimentalMaterialApi::class) @Composable private fun DatesScreen( uiState: DatesUIState, @@ -124,10 +129,15 @@ private fun DatesScreen( ) ) } + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { onAction(DatesViewActions.SwipeRefresh) } + ) Scaffold( scaffoldState = scaffoldState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), backgroundColor = MaterialTheme.appColors.background, topBar = { MainScreenTitle( @@ -141,58 +151,71 @@ private fun DatesScreen( ) }, content = { paddingValues -> - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - if (uiState.isLoading) { - 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, - contentPadding = PaddingValues(bottom = 20.dp) + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - uiState.dates.keys.forEach { dueDateCategory -> - val dates = uiState.dates[dueDateCategory] ?: emptyList() - if (dates.isNotEmpty()) { - item { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp, top = 20.dp), - text = stringResource(id = dueDateCategory.label), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.titleMedium, - ) - } - itemsIndexed(dates) { index, date -> - val itemPosition = ListItemPosition.detectPosition(index, dates) - DateItem( - date = date, - lineColor = dueDateCategory.color, - itemPosition = itemPosition, - onClick = { - onAction(DatesViewActions.OpenEvent()) - } - ) + 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, + contentPadding = PaddingValues(bottom = 20.dp) + ) { + uiState.dates.keys.forEach { dueDateCategory -> + val dates = uiState.dates[dueDateCategory] ?: emptyList() + if (dates.isNotEmpty()) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, top = 20.dp), + text = stringResource(id = dueDateCategory.label), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + } + itemsIndexed(dates) { index, date -> + val itemPosition = + ListItemPosition.detectPosition(index, dates) + DateItem( + date = date, + lineColor = dueDateCategory.color, + itemPosition = itemPosition, + onClick = { + onAction(DatesViewActions.OpenEvent()) + } + ) + } } } } } } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + PullRefreshIndicator( + uiState.isRefreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) } } ) 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 index 205c34978..24fa97736 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -2,5 +2,6 @@ package org.openedx.dates.presentation.dates data class DatesUIState( val isLoading: Boolean = true, + val isRefreshing: 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 index 0295cf015..3e88e97c1 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 @@ -35,6 +35,7 @@ class DatesViewModel( _uiState.update { state -> state.copy( isLoading = false, + isRefreshing = false, dates = mapOf( DueDateCategory.PAST_DUE to listOf("Date1", "Date2", "Date3"), DueDateCategory.TODAY to listOf("Date1"), @@ -46,6 +47,15 @@ class DatesViewModel( } } + fun refreshData() { + _uiState.update { state -> + state.copy( + isRefreshing = true, + ) + } + fetchDates() + } + fun onSettingsClick(fragmentManager: FragmentManager) { datesRouter.navigateToSettings(fragmentManager) } @@ -54,5 +64,5 @@ class DatesViewModel( interface DatesViewActions { object OpenSettings : DatesViewActions class OpenEvent() : DatesViewActions - object ReloadData : DatesViewActions + object SwipeRefresh : DatesViewActions } From b12d92dd428286e27870a46751f3e2bf73df1ea2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 13:29:27 +0200 Subject: [PATCH 04/41] feat: offline mode dialog --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../dates/presentation/dates/DatesFragment.kt | 24 +++++++++++++++++++ .../presentation/dates/DatesViewModel.kt | 5 ++++ 3 files changed, 30 insertions(+) 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 cf9026767..9694e90e2 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -587,6 +587,7 @@ val screenModule = module { viewModel { DatesViewModel( datesRouter = get(), + networkConnection = get() ) } } 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 index 44ae32834..b3d9fc5ee 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -40,6 +40,8 @@ import androidx.compose.runtime.collectAsState 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.graphics.Color @@ -58,6 +60,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.presentation.ListItemPosition import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle +import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -90,6 +93,7 @@ class DatesFragment : Fragment() { DatesScreen( uiState = uiState, uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, onAction = { action -> when (action) { DatesViewActions.OpenSettings -> { @@ -117,6 +121,7 @@ class DatesFragment : Fragment() { private fun DatesScreen( uiState: DatesUIState, uiMessage: UIMessage?, + hasInternetConnection: Boolean, onAction: (DatesViewActions) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -133,6 +138,9 @@ private fun DatesScreen( refreshing = uiState.isRefreshing, onRefresh = { onAction(DatesViewActions.SwipeRefresh) } ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } Scaffold( scaffoldState = scaffoldState, @@ -216,6 +224,21 @@ private fun DatesScreen( pullRefreshState, Modifier.align(Alignment.TopCenter) ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DatesViewActions.SwipeRefresh) + } + ) + } } } ) @@ -372,6 +395,7 @@ private fun DatesScreenPreview() { DatesScreen( uiState = DatesUIState(isLoading = false), uiMessage = null, + hasInternetConnection = true, onAction = {} ) } 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 3e88e97c1..6296f65fe 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 @@ -10,12 +10,14 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.openedx.core.system.connection.NetworkConnection import org.openedx.dates.presentation.DatesRouter import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.UIMessage class DatesViewModel( private val datesRouter: DatesRouter, + private val networkConnection: NetworkConnection, ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -26,6 +28,9 @@ class DatesViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + init { fetchDates() } From f03bec6b05366949d98b66fc55cdea455f932d8b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 13 Mar 2025 20:06:37 +0200 Subject: [PATCH 05/41] feat: added dates request --- .../java/org/openedx/app/di/ScreenModule.kt | 17 +++- .../org/openedx/core/data/api/CourseApi.kt | 10 ++- .../core/data/model/CourseDatesResponse.kt | 53 ++++++++++++ .../core/domain/model/CourseDatesResponse.kt | 19 +++++ .../dates/data/repository/DatesRepository.kt | 15 ++++ .../domain/interactor/DatesInteractor.kt | 11 +++ .../dates/presentation/dates/DatesFragment.kt | 23 +++-- .../dates/presentation/dates/DatesUIState.kt | 4 +- .../presentation/dates/DatesViewModel.kt | 83 +++++++++++++++---- 9 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt create mode 100644 dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt create mode 100644 dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 9694e90e2..a325df575 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,8 @@ 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 @@ -584,10 +586,23 @@ val screenModule = module { ) } + factory { + DatesRepository( + api = get(), + preferencesManager = get() + ) + } + factory { + DatesInteractor( + repository = get() + ) + } viewModel { DatesViewModel( datesRouter = get(), - networkConnection = get() + networkConnection = get(), + resourceManager = get(), + datesInteractor = get() ) } } 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..bc2fdc643 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,6 +113,12 @@ 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 = 1 + ): CourseDatesResponse + @GET("/api/course_home/progress/{course_id}") suspend fun getCourseProgress( @Path("course_id") courseId: String, 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..6064970f8 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -0,0 +1,53 @@ +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("assignment_block_id") + val assignmentBlockId: String, + @SerializedName("due_date") + val dueDate: String?, + @SerializedName("assignment_title") + val assignmentTitle: String?, + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean?, + @SerializedName("course_name") + val courseName: String? +) { + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + assignmentBlockId = assignmentBlockId, + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + courseName = courseName ?: "" + ) + } +} + +data class CourseDatesResponse( + @SerializedName("count") + val count: Int, + @SerializedName("next") + val next: Int?, + @SerializedName("previous") + val previous: Int?, + @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/domain/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt new file mode 100644 index 000000000..a6bb9e8a1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -0,0 +1,19 @@ +package org.openedx.core.domain.model + +import java.util.Date + +data class CourseDatesResponse( + val count: Int, + val next: Int?, + val previous: Int?, + val results: List +) + +data class CourseDate( + val courseId: String, + val assignmentBlockId: String, + val dueDate: Date, + val assignmentTitle: String, + val learnerHasAccess: Boolean, + val courseName: String +) 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..0d9dfcd58 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -0,0 +1,15 @@ +package org.openedx.dates.data.repository + +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDatesResponse + +class DatesRepository( + private val api: CourseApi, + private val preferencesManager: CorePreferences +) { + suspend fun getUserDates(): CourseDatesResponse { + val username = preferencesManager.user?.username ?: "" + return api.getUserDates(username).mapToDomain() + } +} 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..68139ad01 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -0,0 +1,11 @@ +package org.openedx.dates.domain.interactor + +import org.openedx.dates.data.repository.DatesRepository + +class DatesInteractor( + private val repository: DatesRepository +) { + + suspend fun getUserDates() = repository.getUserDates() + +} 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 index b3d9fc5ee..3aa7deb96 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -22,7 +22,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -47,6 +49,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -57,6 +60,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.domain.model.CourseDate import org.openedx.core.presentation.ListItemPosition import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle @@ -66,6 +70,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.core.utils.TimeUtils import org.openedx.dates.R import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize @@ -166,7 +171,8 @@ private fun DatesScreen( ) { if (uiState.isLoading) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) @@ -203,7 +209,7 @@ private fun DatesScreen( val itemPosition = ListItemPosition.detectPosition(index, dates) DateItem( - date = date, + courseDate = date, lineColor = dueDateCategory.color, itemPosition = itemPosition, onClick = { @@ -247,11 +253,12 @@ private fun DatesScreen( @Composable private fun DateItem( modifier: Modifier = Modifier, - date: String, + courseDate: CourseDate, lineColor: Color, itemPosition: ListItemPosition, onClick: () -> Unit, ) { + val context = LocalContext.current val boxCornerWidth = 8.dp val boxCornerRadius = boxCornerWidth / 2 val infoPadding = 8.dp @@ -313,7 +320,7 @@ private fun DateItem( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = date, + text = TimeUtils.formatToString(context, courseDate.dueDate, true), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textDark ) @@ -326,13 +333,13 @@ private fun DateItem( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = date, + text = courseDate.assignmentTitle, style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark ) } Text( - text = date, + text = courseDate.courseName, style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -352,7 +359,9 @@ private fun EmptyState( modifier: Modifier = Modifier ) { Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), contentAlignment = Alignment.Center ) { Column( 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 index 24fa97736..543d9f9fe 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -1,7 +1,9 @@ package org.openedx.dates.presentation.dates +import org.openedx.core.domain.model.CourseDate + data class DatesUIState( val isLoading: Boolean = true, val isRefreshing: Boolean = false, - val dates: Map> = emptyMap() + 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 index 6296f65fe..8b5875eea 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 @@ -10,14 +10,26 @@ 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.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.isToday +import org.openedx.core.utils.toCalendar +import org.openedx.dates.domain.interactor.DatesInteractor 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 ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -32,38 +44,73 @@ class DatesViewModel( get() = networkConnection.isOnline() init { - fetchDates() + fetchDates(false) } - private fun fetchDates() { + private fun fetchDates(refresh: Boolean) { viewModelScope.launch { - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false, - dates = mapOf( - DueDateCategory.PAST_DUE to listOf("Date1", "Date2", "Date3"), - DueDateCategory.TODAY to listOf("Date1"), - DueDateCategory.THIS_WEEK to listOf("Date1", "Date2"), - DueDateCategory.UPCOMING to listOf("Date1", "Date2", "Date3", "Date4"), + try { + _uiState.update { state -> + state.copy( + isLoading = !refresh, + isRefreshing = refresh, ) - ) + } + val courseDatesResponse = datesInteractor.getUserDates() + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false, + dates = groupCourseDates(courseDatesResponse) + ) + } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + ) + } else { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + ) + } } } } fun refreshData() { - _uiState.update { state -> - state.copy( - isRefreshing = true, - ) - } - fetchDates() + fetchDates(true) } fun onSettingsClick(fragmentManager: FragmentManager) { datesRouter.navigateToSettings(fragmentManager) } + + private fun groupCourseDates(response: CourseDatesResponse): Map> { + val now = Date() + val calNow = Calendar.getInstance().apply { time = now } + val grouped = response.results.groupBy { courseDate -> + val dueDate = courseDate.dueDate + if (dueDate.before(now)) { + DueDateCategory.PAST_DUE + } else if (dueDate.isToday()) { + DueDateCategory.TODAY + } else { + val calDue = dueDate.toCalendar() + val weekNow = calNow.get(Calendar.WEEK_OF_YEAR) + val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) + val yearNow = calNow.get(Calendar.YEAR) + val yearDue = calDue.get(Calendar.YEAR) + if (weekNow == weekDue && yearNow == yearDue) { + DueDateCategory.THIS_WEEK + } else { + DueDateCategory.UPCOMING + } + } + } + + return grouped + } } interface DatesViewActions { From 5ed6582f50bf9a86d34e76aea445e96b4e410479 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Mar 2025 20:42:36 +0200 Subject: [PATCH 06/41] feat: paging and caching --- .../main/java/org/openedx/app/di/AppModule.kt | 5 ++ .../java/org/openedx/app/di/ScreenModule.kt | 3 +- .../java/org/openedx/app/room/AppDatabase.kt | 4 ++ .../org/openedx/core/data/api/CourseApi.kt | 2 +- .../dates/data/repository/DatesRepository.kt | 14 +++- .../dates/data/storage/CourseDateEntity.kt | 53 +++++++++++++++ .../openedx/dates/data/storage/DatesDao.kt | 19 ++++++ .../domain/interactor/DatesInteractor.kt | 4 +- .../dates/presentation/dates/DatesFragment.kt | 40 +++++++++-- .../dates/presentation/dates/DatesUIState.kt | 1 + .../presentation/dates/DatesViewModel.kt | 68 ++++++++++++++++--- 11 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt create mode 100644 dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index f680b33e5..7d46f43b4 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -179,6 +179,11 @@ val appModule = module { room.calendarDao() } + single { + val room = get() + room.datesDao() + } + single { FileDownloader() } 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 a325df575..fef2e9acc 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -589,7 +589,8 @@ val screenModule = module { factory { DatesRepository( api = get(), - preferencesManager = get() + dao = get(), + preferencesManager = get(), ) } factory { 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..2ee6f7eec 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -19,6 +19,8 @@ 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.CourseDateEntity +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 @@ -38,6 +40,7 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarStateEntity::class, DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class, + CourseDateEntity::class, VideoProgressEntity::class, CourseProgressEntity::class, ], @@ -55,5 +58,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/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index bc2fdc643..8c075ecff 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 @@ -116,7 +116,7 @@ interface CourseApi { @GET("/api/mobile/v1/course_dates/{username}/") suspend fun getUserDates( @Path("username") username: String, - @Query("page") page: Int = 1 + @Query("page") page: Int ): CourseDatesResponse @GET("/api/course_home/progress/{course_id}") 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 index 0d9dfcd58..1e3c2aebf 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -2,14 +2,24 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi 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.CourseDateEntity +import org.openedx.dates.data.storage.DatesDao class DatesRepository( private val api: CourseApi, + private val dao: DatesDao, private val preferencesManager: CorePreferences ) { - suspend fun getUserDates(): CourseDatesResponse { + suspend fun getUserDates(page: Int): CourseDatesResponse { val username = preferencesManager.user?.username ?: "" - return api.getUserDates(username).mapToDomain() + val response = api.getUserDates(username, page) + dao.insertCourseDateEntities(response.results.map { CourseDateEntity.createFrom(it) }) + return response.mapToDomain() + } + + suspend fun getUserDatesFromCache(): List { + return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } } } diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt new file mode 100644 index 000000000..558da6870 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt @@ -0,0 +1,53 @@ +package org.openedx.dates.data.storage + +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_date_table") +data class CourseDateEntity( + @PrimaryKey + @ColumnInfo("assignmentBlockId") + val assignmentBlockId: String, + @ColumnInfo("courseId") + val courseId: String, + @ColumnInfo("dueDate") + val dueDate: String?, + @ColumnInfo("assignmentTitle") + val assignmentTitle: String?, + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean?, + @ColumnInfo("courseName") + val courseName: String?, +) { + + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + assignmentBlockId = assignmentBlockId, + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + courseName = courseName ?: "" + ) + } + + companion object { + fun createFrom(courseDate: CourseDate): CourseDateEntity { + with(courseDate) { + return CourseDateEntity( + courseId = courseId, + assignmentBlockId = assignmentBlockId, + dueDate = dueDate, + assignmentTitle = assignmentTitle, + learnerHasAccess = learnerHasAccess, + courseName = courseName + ) + } + } + } +} 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..50b570112 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -0,0 +1,19 @@ +package org.openedx.dates.data.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface DatesDao { + + @Query("SELECT * FROM course_date_table") + suspend fun getCourseDateEntities(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseDateEntities(courseDate: List) + + @Query("DELETE FROM course_date_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 index 68139ad01..0fd1d2b77 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -6,6 +6,8 @@ class DatesInteractor( private val repository: DatesRepository ) { - suspend fun getUserDates() = repository.getUserDates() + suspend fun getUserDates(page: Int) = repository.getUserDates(page) + + suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() } 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 index 3aa7deb96..d2a4cf93a 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -40,6 +41,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -66,12 +68,14 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.shouldLoadMore 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.core.utils.TimeUtils import org.openedx.dates.R +import org.openedx.dates.presentation.dates.DatesFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue @@ -109,8 +113,15 @@ class DatesFragment : Fragment() { viewModel.refreshData() } - is DatesViewActions.OpenEvent -> { + DatesViewActions.LoadMore -> { + viewModel.fetchMore() + } + is DatesViewActions.OpenEvent -> { + viewModel.navigateToCourseOutline( + requireActivity().supportFragmentManager, + action.date + ) } } } @@ -119,6 +130,10 @@ class DatesFragment : Fragment() { } } + companion object { + const val LOAD_MORE_THRESHOLD = 4 + } + } @OptIn(ExperimentalMaterialApi::class) @@ -146,6 +161,10 @@ private fun DatesScreen( var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) } + val scrollState = rememberLazyListState() + val firstVisibleIndex = remember { + mutableIntStateOf(scrollState.firstVisibleItemIndex) + } Scaffold( scaffoldState = scaffoldState, @@ -169,7 +188,7 @@ private fun DatesScreen( .fillMaxSize() .pullRefresh(pullRefreshState) ) { - if (uiState.isLoading) { + if (uiState.isLoading && uiState.dates.isEmpty()) { Box( modifier = Modifier .fillMaxSize(), @@ -189,7 +208,8 @@ private fun DatesScreen( contentAlignment = Alignment.TopCenter ) { LazyColumn( - modifier = contentWidth, + modifier = contentWidth.fillMaxSize(), + state = scrollState, contentPadding = PaddingValues(bottom = 20.dp) ) { uiState.dates.keys.forEach { dueDateCategory -> @@ -213,12 +233,24 @@ private fun DatesScreen( lineColor = dueDateCategory.color, itemPosition = itemPosition, onClick = { - onAction(DatesViewActions.OpenEvent()) + onAction(DatesViewActions.OpenEvent(date)) } ) } } } + if (uiState.isLoading) { + item { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center), + color = MaterialTheme.appColors.primary + ) + } + } + } + if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + onAction(DatesViewActions.LoadMore) } } } 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 index 543d9f9fe..ba1dfed39 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -5,5 +5,6 @@ import org.openedx.core.domain.model.CourseDate data class DatesUIState( val isLoading: Boolean = true, 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 index 8b5875eea..6691717ad 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 @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.domain.model.CourseDate -import org.openedx.core.domain.model.CourseDatesResponse +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 @@ -43,6 +43,8 @@ class DatesViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + private var page = 1 + init { fetchDates(false) } @@ -56,13 +58,36 @@ class DatesViewModel( isRefreshing = refresh, ) } - val courseDatesResponse = datesInteractor.getUserDates() - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false, - dates = groupCourseDates(courseDatesResponse) - ) + if (refresh) { + page = 1 + } + val response = if (networkConnection.isOnline() || page > 1) { + datesInteractor.getUserDates(page) + } else { + null + } + if (response != null) { + if (response.next.isNotNull() && page != response.count) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + } + _uiState.update { state -> + state.copy( + dates = state.dates + groupCourseDates(response.results) + ) + } + } else { + val cachedList = datesInteractor.getUserDatesFromCache() + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList) + ) + } } } catch (e: Exception) { if (e.isInternetError()) { @@ -74,10 +99,23 @@ class DatesViewModel( UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) ) } + } finally { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false, + ) + } } } } + fun fetchMore() { + if (!_uiState.value.isLoading && page != -1) { + fetchDates(false) + } + } + fun refreshData() { fetchDates(true) } @@ -86,10 +124,17 @@ class DatesViewModel( datesRouter.navigateToSettings(fragmentManager) } - private fun groupCourseDates(response: CourseDatesResponse): Map> { + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + courseDate: CourseDate, + ) { + + } + + private fun groupCourseDates(dates: List): Map> { val now = Date() val calNow = Calendar.getInstance().apply { time = now } - val grouped = response.results.groupBy { courseDate -> + val grouped = dates.groupBy { courseDate -> val dueDate = courseDate.dueDate if (dueDate.before(now)) { DueDateCategory.PAST_DUE @@ -115,6 +160,7 @@ class DatesViewModel( interface DatesViewActions { object OpenSettings : DatesViewActions - class OpenEvent() : DatesViewActions + class OpenEvent(val date: CourseDate) : DatesViewActions + object LoadMore : DatesViewActions object SwipeRefresh : DatesViewActions } From 0260237e46fd33b41ee5f460eac00ec7d971a142 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 18 Mar 2025 13:09:46 +0200 Subject: [PATCH 07/41] feat: navigating to block --- .../org/openedx/app/deeplink/DeepLinkRouter.kt | 13 +++++++++---- .../presentation/AllEnrolledCoursesViewModel.kt | 4 +++- .../presentation/DashboardListFragment.kt | 2 ++ .../dashboard/presentation/DashboardRouter.kt | 4 ++-- .../openedx/dates/presentation/DatesRouter.kt | 8 ++++++++ .../dates/presentation/dates/DatesFragment.kt | 16 ++++++++++------ .../dates/presentation/dates/DatesViewModel.kt | 8 +++++++- .../dates/presentation/dates/DueDateCategory.kt | 8 ++++---- 8 files changed, 45 insertions(+), 18 deletions(-) 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/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/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt index 78f7472ba..01e06ed38 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt @@ -5,4 +5,12 @@ 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 index d2a4cf93a..1036ac96a 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -239,13 +239,17 @@ private fun DatesScreen( } } } - if (uiState.isLoading) { + if (uiState.canLoadMore) { item { - CircularProgressIndicator( - modifier = Modifier - .align(Alignment.Center), - color = MaterialTheme.appColors.primary - ) + Box( + Modifier + .fillMaxWidth() + .height(42.dp) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } } } } 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 6691717ad..701eafe62 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 @@ -128,7 +128,13 @@ class DatesViewModel( fragmentManager: FragmentManager, courseDate: CourseDate, ) { - + datesRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = courseDate.courseId, + courseTitle = courseDate.courseName, + openTab = "", + resumeBlockId = courseDate.assignmentBlockId + ) } private fun groupCourseDates(dates: List): Map> { diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt index 78ebda298..4cd305a56 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt @@ -11,11 +11,11 @@ enum class DueDateCategory( @StringRes val label: Int, ) { - PAST_DUE(R.string.dates_category_past_due), - TODAY(R.string.dates_category_today), - THIS_WEEK(R.string.dates_category_this_week), + UPCOMING(R.string.dates_category_upcoming), NEXT_WEEK(R.string.dates_category_next_week), - UPCOMING(R.string.dates_category_upcoming); + THIS_WEEK(R.string.dates_category_this_week), + TODAY(R.string.dates_category_today), + PAST_DUE(R.string.dates_category_past_due); val color: Color @Composable From 56ef429c43e41376bb3fd004ac4ffc8c046efe96 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 17:23:05 +0200 Subject: [PATCH 08/41] feat: reuse dates UI from CourseDatesScreen --- .../java/org/openedx/app/di/ScreenModule.kt | 3 +- .../openedx/core/domain/model/DatesSection.kt | 20 +- .../core/presentation/dates/DatesUI.kt | 321 ++++++++++++++++++ .../presentation/dates/CourseDatesScreen.kt | 8 +- .../dates/presentation/dates/DatesFragment.kt | 158 +-------- .../dates/presentation/dates/DatesUIState.kt | 3 +- .../presentation/dates/DatesViewModel.kt | 17 +- .../presentation/dates/DueDateCategory.kt | 31 -- dates/src/main/res/values/strings.xml | 5 - default_config/dev/config.yaml | 2 +- default_config/prod/config.yaml | 2 +- default_config/stage/config.yaml | 2 +- 12 files changed, 375 insertions(+), 197 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt delete mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index fef2e9acc..c0d27283e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -603,7 +603,8 @@ val screenModule = module { datesRouter = get(), networkConnection = get(), resourceManager = get(), - datesInteractor = get() + datesInteractor = get(), + corePreferences = get() ) } } 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/dates/DatesUI.kt b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt new file mode 100644 index 000000000..1c7b10df9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt @@ -0,0 +1,321 @@ +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 + +// --- Generic composables for reusability --- + +@Composable +private fun CourseDateBlockSectionGeneric( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + 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, useRelativeDates = useRelativeDates) { + 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, useRelativeDates = useRelativeDates) { + 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)) + } + 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.assignmentBlockId.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.assignmentBlockId.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, + style = MaterialTheme.appTypography.labelMedium, + ) + } +} 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..d98dad502 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 @@ -15,11 +15,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -27,7 +25,6 @@ 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.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -35,7 +32,6 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable @@ -51,10 +47,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -64,6 +58,8 @@ 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.course.CourseViewMode +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/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt index 1036ac96a..d85d835b0 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -3,28 +3,19 @@ package org.openedx.dates.presentation.dates import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.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.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi @@ -32,8 +23,6 @@ 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.automirrored.filled.ArrowForwardIos import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -48,10 +37,7 @@ 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.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -62,8 +48,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.domain.model.CourseDate -import org.openedx.core.presentation.ListItemPosition +import org.openedx.core.presentation.dates.CourseDateBlockSection import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog @@ -73,9 +58,9 @@ 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.core.utils.TimeUtils 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 @@ -103,6 +88,7 @@ class DatesFragment : Fragment() { uiState = uiState, uiMessage = uiMessage, hasInternetConnection = viewModel.hasInternetConnection, + useRelativeDates = viewModel.useRelativeDates, onAction = { action -> when (action) { DatesViewActions.OpenSettings -> { @@ -142,6 +128,7 @@ private fun DatesScreen( uiState: DatesUIState, uiMessage: UIMessage?, hasInternetConnection: Boolean, + useRelativeDates: Boolean, onAction: (DatesViewActions) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -212,29 +199,17 @@ private fun DatesScreen( state = scrollState, contentPadding = PaddingValues(bottom = 20.dp) ) { - uiState.dates.keys.forEach { dueDateCategory -> - val dates = uiState.dates[dueDateCategory] ?: emptyList() - if (dates.isNotEmpty()) { + uiState.dates.keys.forEach { sectionKey -> + val dates = uiState.dates[sectionKey] ?: emptyList() + dates.isNotEmptyThenLet { sectionDates -> item { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp, top = 20.dp), - text = stringResource(id = dueDateCategory.label), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.titleMedium, - ) - } - itemsIndexed(dates) { index, date -> - val itemPosition = - ListItemPosition.detectPosition(index, dates) - DateItem( - courseDate = date, - lineColor = dueDateCategory.color, - itemPosition = itemPosition, - onClick = { - onAction(DatesViewActions.OpenEvent(date)) - } + CourseDateBlockSection( + sectionKey = sectionKey, + sectionDates = sectionDates, + onItemClick = { + onAction(DatesViewActions.OpenEvent(it)) + }, + useRelativeDates = useRelativeDates ) } } @@ -286,110 +261,6 @@ private fun DatesScreen( ) } -@Composable -private fun DateItem( - modifier: Modifier = Modifier, - courseDate: CourseDate, - lineColor: Color, - itemPosition: ListItemPosition, - onClick: () -> Unit, -) { - val context = LocalContext.current - val boxCornerWidth = 8.dp - val boxCornerRadius = boxCornerWidth / 2 - val infoPadding = 8.dp - - val boxCornerShape = remember(itemPosition) { - when (itemPosition) { - ListItemPosition.SINGLE -> RoundedCornerShape(boxCornerRadius) - ListItemPosition.MIDDLE -> RectangleShape - ListItemPosition.FIRST -> RoundedCornerShape( - topStart = boxCornerRadius, - topEnd = boxCornerRadius - ) - - ListItemPosition.LAST -> RoundedCornerShape( - bottomStart = boxCornerRadius, - bottomEnd = boxCornerRadius - ) - } - } - - val infoPaddingModifier = remember(itemPosition) { - when (itemPosition) { - ListItemPosition.SINGLE -> Modifier - ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) - ListItemPosition.LAST -> Modifier.padding(top = infoPadding) - ListItemPosition.MIDDLE -> Modifier.padding(vertical = infoPadding) - } - } - - val arrowOffset = remember(itemPosition) { - when (itemPosition) { - ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) - ListItemPosition.LAST -> Modifier.padding(top = infoPadding) - else -> Modifier - } - } - - Row( - modifier = modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable { - onClick() - }, - verticalAlignment = Alignment.CenterVertically - ) { - // Colored line box - Box( - modifier = Modifier - .width(boxCornerWidth) - .fillMaxHeight() - .background(color = lineColor, shape = boxCornerShape) - ) - Spacer(modifier = Modifier.width(12.dp)) - Column( - modifier = Modifier - .weight(1f) - .then(infoPaddingModifier), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = TimeUtils.formatToString(context, courseDate.dueDate, true), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_assignment), - contentDescription = null, - tint = MaterialTheme.appColors.textDark, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = courseDate.assignmentTitle, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark - ) - } - Text( - text = courseDate.courseName, - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } - - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, - contentDescription = null, - tint = MaterialTheme.appColors.textDark, - modifier = arrowOffset.size(16.dp) - ) - } -} - @Composable private fun EmptyState( modifier: Modifier = Modifier @@ -441,6 +312,7 @@ private fun DatesScreenPreview() { uiState = DatesUIState(isLoading = false), uiMessage = null, hasInternetConnection = true, + useRelativeDates = true, onAction = {} ) } 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 index ba1dfed39..4b5febddb 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -1,10 +1,11 @@ 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 isRefreshing: Boolean = false, val canLoadMore: Boolean = false, - val dates: Map> = emptyMap() + 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 index 701eafe62..e26465ef0 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 @@ -11,7 +11,9 @@ 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.DatesSection import org.openedx.core.extension.isNotNull import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.utils.isToday @@ -29,7 +31,8 @@ class DatesViewModel( private val datesRouter: DatesRouter, private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, - private val datesInteractor: DatesInteractor + private val datesInteractor: DatesInteractor, + private val corePreferences: CorePreferences, ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -43,6 +46,8 @@ class DatesViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + var useRelativeDates = corePreferences.isRelativeDatesEnabled + private var page = 1 init { @@ -137,15 +142,15 @@ class DatesViewModel( ) } - private fun groupCourseDates(dates: List): Map> { + private fun groupCourseDates(dates: List): Map> { val now = Date() val calNow = Calendar.getInstance().apply { time = now } val grouped = dates.groupBy { courseDate -> val dueDate = courseDate.dueDate if (dueDate.before(now)) { - DueDateCategory.PAST_DUE + DatesSection.PAST_DUE } else if (dueDate.isToday()) { - DueDateCategory.TODAY + DatesSection.TODAY } else { val calDue = dueDate.toCalendar() val weekNow = calNow.get(Calendar.WEEK_OF_YEAR) @@ -153,9 +158,9 @@ class DatesViewModel( val yearNow = calNow.get(Calendar.YEAR) val yearDue = calDue.get(Calendar.YEAR) if (weekNow == weekDue && yearNow == yearDue) { - DueDateCategory.THIS_WEEK + DatesSection.THIS_WEEK } else { - DueDateCategory.UPCOMING + DatesSection.UPCOMING } } } diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt deleted file mode 100644 index 4cd305a56..000000000 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.openedx.dates.presentation.dates - -import androidx.annotation.StringRes -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import org.openedx.core.ui.theme.appColors -import org.openedx.dates.R - -enum class DueDateCategory( - @StringRes - val label: Int, -) { - UPCOMING(R.string.dates_category_upcoming), - NEXT_WEEK(R.string.dates_category_next_week), - THIS_WEEK(R.string.dates_category_this_week), - TODAY(R.string.dates_category_today), - PAST_DUE(R.string.dates_category_past_due); - - val color: Color - @Composable - get() { - return when (this) { - PAST_DUE -> MaterialTheme.appColors.warning - TODAY -> MaterialTheme.appColors.info - THIS_WEEK -> MaterialTheme.appColors.textPrimaryVariant - NEXT_WEEK -> MaterialTheme.appColors.textFieldBorder - UPCOMING -> MaterialTheme.appColors.divider - } - } -} diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml index 3187e2b97..87df86589 100644 --- a/dates/src/main/res/values/strings.xml +++ b/dates/src/main/res/values/strings.xml @@ -1,11 +1,6 @@ Dates - Past Due - Today - This Week - Next Week - Upcoming No Dates You currently have no active courses with scheduled events. Enroll in a course to view important dates and deadlines. \ No newline at end of file diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index e6ab8bce2..ac06ef7ba 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DATES: +APP_LEVEL_DATES: ENABLED: true FIREBASE: diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index c013c2a99..54b5e3e2a 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DATES: +APP_LEVEL_DATES: ENABLED: true FIREBASE: diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index c013c2a99..54b5e3e2a 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DATES: +APP_LEVEL_DATES: ENABLED: true FIREBASE: From 4c50c5240acb0e51a8430b9a0c1c2c95e5fc07c5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 17:54:38 +0200 Subject: [PATCH 09/41] feat: shift due date card --- .../core/data/model/CourseDatesResponse.kt | 5 +- .../core/domain/model/CourseDatesResponse.kt | 1 + .../dates/data/storage/CourseDateEntity.kt | 4 + .../dates/presentation/dates/DatesFragment.kt | 73 ++++++++++++++++++- .../presentation/dates/DatesViewModel.kt | 7 +- dates/src/main/res/values/strings.xml | 3 + 6 files changed, 89 insertions(+), 4 deletions(-) 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 index 6064970f8..b39028bd5 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -16,6 +16,8 @@ data class CourseDate( val assignmentTitle: String?, @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, + @SerializedName("relative") + val relative: Boolean?, @SerializedName("course_name") val courseName: String? ) { @@ -27,7 +29,8 @@ data class CourseDate( dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, - courseName = courseName ?: "" + courseName = courseName ?: "", + relative = relative ?: false ) } } 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 index a6bb9e8a1..2248e3a21 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -15,5 +15,6 @@ data class CourseDate( val dueDate: Date, val assignmentTitle: String, val learnerHasAccess: Boolean, + val relative: Boolean, val courseName: String ) diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt index 558da6870..ec751d1ee 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt @@ -20,6 +20,8 @@ data class CourseDateEntity( val assignmentTitle: String?, @ColumnInfo("learnerHasAccess") val learnerHasAccess: Boolean?, + @ColumnInfo("relative") + val relative: Boolean?, @ColumnInfo("courseName") val courseName: String?, ) { @@ -32,6 +34,7 @@ data class CourseDateEntity( dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, + relative = relative ?: false, courseName = courseName ?: "" ) } @@ -45,6 +48,7 @@ data class CourseDateEntity( dueDate = dueDate, assignmentTitle = assignmentTitle, learnerHasAccess = learnerHasAccess, + relative = relative, courseName = courseName ) } 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 index d85d835b0..15be6680a 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -3,6 +3,7 @@ package org.openedx.dates.presentation.dates import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -17,6 +18,7 @@ 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 @@ -48,15 +50,18 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel +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.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.shouldLoadMore 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 @@ -103,6 +108,10 @@ class DatesFragment : Fragment() { viewModel.fetchMore() } + DatesViewActions.ShiftDueDate -> { + viewModel.shiftDueDate() + } + is DatesViewActions.OpenEvent -> { viewModel.navigateToCourseOutline( requireActivity().supportFragmentManager, @@ -197,11 +206,23 @@ private fun DatesScreen( LazyColumn( modifier = contentWidth.fillMaxSize(), state = scrollState, - contentPadding = PaddingValues(bottom = 20.dp) + contentPadding = PaddingValues(bottom = 48.dp) ) { uiState.dates.keys.forEach { sectionKey -> - val dates = uiState.dates[sectionKey] ?: emptyList() + val dates = uiState.dates[sectionKey].orEmpty() dates.isNotEmptyThenLet { sectionDates -> + val isHavePastRelatedDates = + sectionKey == DatesSection.PAST_DUE && dates.any { it.relative } + if (isHavePastRelatedDates) { + item { + ShiftDueDatesCard( + modifier = Modifier.padding(top = 12.dp), + onClick = { + onAction(DatesViewActions.ShiftDueDate) + } + ) + } + } item { CourseDateBlockSection( sectionKey = sectionKey, @@ -261,6 +282,44 @@ private fun DatesScreen( ) } +@Composable +private fun ShiftDueDatesCard( + modifier: Modifier = Modifier, + 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), + onClick = onClick + ) + } + } +} + @Composable private fun EmptyState( modifier: Modifier = Modifier @@ -317,3 +376,13 @@ private fun DatesScreenPreview() { ) } } + +@Preview +@Composable +private fun ShiftDueDatesCardPreview() { + OpenEdXTheme { + ShiftDueDatesCard( + onClick = {} + ) + } +} 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 e26465ef0..14f566945 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 @@ -32,7 +32,7 @@ class DatesViewModel( private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val datesInteractor: DatesInteractor, - private val corePreferences: CorePreferences, + corePreferences: CorePreferences, ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -115,6 +115,10 @@ class DatesViewModel( } } + fun shiftDueDate() { +//TODO + } + fun fetchMore() { if (!_uiState.value.isLoading && page != -1) { fetchDates(false) @@ -174,4 +178,5 @@ interface 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 index 87df86589..1a2c6f989 100644 --- a/dates/src/main/res/values/strings.xml +++ b/dates/src/main/res/values/strings.xml @@ -3,4 +3,7 @@ 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 past due assignments without losing any progress. + Shift Due Dates \ No newline at end of file From 582e2c60af3249dffdd2246ddd7111b8699411a5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 18:11:51 +0200 Subject: [PATCH 10/41] feat: shift due date request --- .../org/openedx/core/data/api/CourseApi.kt | 6 ++++ .../core/data/model/ShiftDueDatesBody.kt | 7 ++++ .../dates/data/repository/DatesRepository.kt | 4 +++ .../domain/interactor/DatesInteractor.kt | 2 ++ .../dates/presentation/dates/DatesFragment.kt | 4 +++ .../dates/presentation/dates/DatesUIState.kt | 1 + .../presentation/dates/DatesViewModel.kt | 32 ++++++++++++++++++- 7 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 8c075ecff..c701c13e1 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 @@ -15,6 +15,7 @@ import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates +import org.openedx.core.data.model.ShiftDueDatesBody import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -123,4 +124,9 @@ interface CourseApi { suspend fun getCourseProgress( @Path("course_id") courseId: String, ): CourseProgressResponse + + @POST("/api/course_experience/v1/reset_multiple_course_deadlines/") + suspend fun shiftDueDate( + @Body shiftDueDatesBody: ShiftDueDatesBody + ) } diff --git a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt new file mode 100644 index 000000000..df6749f24 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt @@ -0,0 +1,7 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class ShiftDueDatesBody( + @SerializedName("course_keys") val courseKeys: List +) \ 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 index 1e3c2aebf..fcfad5460 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,6 +1,7 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.ShiftDueDatesBody import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse @@ -22,4 +23,7 @@ class DatesRepository( suspend fun getUserDatesFromCache(): List { return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } } + + suspend fun shiftDueDate(courseIds: List) = + api.shiftDueDate(ShiftDueDatesBody(courseIds)) } 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 index 0fd1d2b77..736819c0b 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -10,4 +10,6 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() + suspend fun shiftDueDate(courseIds: List) = repository.shiftDueDate(courseIds) + } 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 index 15be6680a..761b697c1 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -217,6 +217,7 @@ private fun DatesScreen( item { ShiftDueDatesCard( modifier = Modifier.padding(top = 12.dp), + isButtonEnabled = !uiState.isShiftDueDatesPressed, onClick = { onAction(DatesViewActions.ShiftDueDate) } @@ -285,6 +286,7 @@ private fun DatesScreen( @Composable private fun ShiftDueDatesCard( modifier: Modifier = Modifier, + isButtonEnabled: Boolean, onClick: () -> Unit ) { Card( @@ -314,6 +316,7 @@ private fun ShiftDueDatesCard( ) OpenEdXButton( text = stringResource(id = R.string.dates_shift_due_date), + enabled = isButtonEnabled, onClick = onClick ) } @@ -382,6 +385,7 @@ private fun DatesScreenPreview() { 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 index 4b5febddb..0dd6464b2 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -5,6 +5,7 @@ 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 index 14f566945..43bb9565c 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 @@ -116,7 +116,37 @@ class DatesViewModel( } fun shiftDueDate() { -//TODO + viewModelScope.launch { + try { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = true, + ) + } + val pastDueDates = _uiState.value.dates[DatesSection.PAST_DUE] ?: emptyList() + val courseIds = pastDueDates + .filter { it.relative } + .map { it.courseId } + datesInteractor.shiftDueDate(courseIds) + refreshData() + } 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)) + ) + } + } finally { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = false, + ) + } + } + } } fun fetchMore() { From cce080d96ba4884bb283464c741cd559e9797751 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 19:52:45 +0200 Subject: [PATCH 11/41] feat: junit tests --- .../org/openedx/dates/DatesViewModelTest.kt | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt 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..b477a5074 --- /dev/null +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -0,0 +1,354 @@ +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.dates.domain.interactor.DatesInteractor +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 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 + } + + @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 = 2, + previous = 1, + 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, + 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, + corePreferences + ) + advanceUntilIdle() + + // When offline, getUserDates is not called. + coVerify(exactly = 0) { datesInteractor.getUserDates(any()) } + coVerify(exactly = 1) { datesInteractor.getUserDatesFromCache() } + assertFalse(viewModel.uiState.value.isLoading) + // Expect no further pages to load. + 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, + 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, + 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 `shiftDueDate 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, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftDueDate() + advanceUntilIdle() + + coVerify { datesInteractor.shiftDueDate(listOf("course-123")) } + // isShiftDueDatesPressed should be reset to false after processing. + assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) + } + + @Test + fun `shiftDueDate 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.shiftDueDate(any()) } throws Exception() + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftDueDate() + 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, + 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, + corePreferences + ) + val fragmentManager = mockk(relaxed = true) + val courseDate: CourseDate = mockk(relaxed = true) { + every { courseId } returns "course-123" + every { courseName } returns "Test Course" + every { assignmentBlockId } 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 = 2, + previous = 1, + 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, + 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, + 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) + } +} \ No newline at end of file From 62a96ff6da74b4299f0bbfb6c775169e3a861fe7 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 20:12:43 +0200 Subject: [PATCH 12/41] feat: junit tests and analytics --- .../java/org/openedx/app/AnalyticsManager.kt | 4 +++- .../main/java/org/openedx/app/di/AppModule.kt | 2 ++ .../java/org/openedx/app/di/ScreenModule.kt | 3 ++- .../dates/presentation/DatesAnalytics.kt | 20 +++++++++++++++++++ .../presentation/dates/DatesViewModel.kt | 19 ++++++++++++++++++ .../org/openedx/dates/DatesViewModelTest.kt | 15 +++++++++++++- 6 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt 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/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 7d46f43b4..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,7 @@ 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 @@ -216,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 c0d27283e..9f096a7a3 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -604,7 +604,8 @@ val screenModule = module { networkConnection = get(), resourceManager = get(), datesInteractor = get(), - corePreferences = get() + corePreferences = get(), + analytics = get() ) } } 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/dates/DatesViewModel.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt index 43bb9565c..70052e6d8 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 @@ -19,6 +19,9 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.utils.isToday import org.openedx.core.utils.toCalendar 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 @@ -32,6 +35,7 @@ class DatesViewModel( private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val datesInteractor: DatesInteractor, + private val analytics: DatesAnalytics, corePreferences: CorePreferences, ) : BaseViewModel() { @@ -116,6 +120,7 @@ class DatesViewModel( } fun shiftDueDate() { + logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) viewModelScope.launch { try { _uiState.update { state -> @@ -167,6 +172,7 @@ class DatesViewModel( fragmentManager: FragmentManager, courseDate: CourseDate, ) { + logEvent(DatesAnalyticsEvent.ASSIGNMENT_CLICK) datesRouter.navigateToCourseOutline( fm = fragmentManager, courseId = courseDate.courseId, @@ -201,6 +207,19 @@ class DatesViewModel( return grouped } + + 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 { diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index b477a5074..d01794e2e 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -31,6 +31,7 @@ import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse import org.openedx.core.system.connection.NetworkConnection 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 @@ -51,6 +52,7 @@ class DatesViewModelTest { private val resourceManager = mockk() private val datesInteractor = mockk() private val corePreferences = mockk() + private val analytics = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -63,6 +65,7 @@ class DatesViewModelTest { // 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 } @After @@ -88,7 +91,8 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, - corePreferences + analytics, + corePreferences, ) advanceUntilIdle() @@ -109,6 +113,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -131,6 +136,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val message = async { @@ -155,6 +161,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val message = async { @@ -193,6 +200,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -228,6 +236,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -251,6 +260,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -266,6 +276,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -308,6 +319,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -339,6 +351,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() From 3e9bb00ed52f286a8352f80b25d8acbc7db90968 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 21 Mar 2025 12:42:48 +0200 Subject: [PATCH 13/41] fix: changes according detekt warnings --- .../java/org/openedx/core/data/model/ShiftDueDatesBody.kt | 2 +- .../java/org/openedx/core/presentation/dates/DatesUI.kt | 7 ++----- .../org/openedx/dates/domain/interactor/DatesInteractor.kt | 1 - .../org/openedx/dates/presentation/dates/DatesFragment.kt | 1 - .../org/openedx/dates/presentation/dates/DatesViewModel.kt | 4 +--- .../src/test/java/org/openedx/dates/DatesViewModelTest.kt | 2 +- 6 files changed, 5 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt index df6749f24..63e66363d 100644 --- a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt +++ b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt @@ -4,4 +4,4 @@ import com.google.gson.annotations.SerializedName data class ShiftDueDatesBody( @SerializedName("course_keys") val courseKeys: List -) \ No newline at end of file +) 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 1c7b10df9..0b4cb0be8 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,12 +36,9 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime -// --- Generic composables for reusability --- - @Composable private fun CourseDateBlockSectionGeneric( sectionKey: DatesSection = DatesSection.NONE, - useRelativeDates: Boolean, content: @Composable () -> Unit ) { Column(modifier = Modifier.padding(start = 8.dp)) { @@ -87,7 +84,7 @@ fun CourseDateBlockSection( sectionDates: List, onItemClick: (CourseDateBlock) -> Unit, ) { - CourseDateBlockSectionGeneric(sectionKey = sectionKey, useRelativeDates = useRelativeDates) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { DateBlock( dateBlocks = sectionDates, onItemClick = onItemClick, @@ -104,7 +101,7 @@ fun CourseDateBlockSection( sectionDates: List, onItemClick: (CourseDate) -> Unit, ) { - CourseDateBlockSectionGeneric(sectionKey = sectionKey, useRelativeDates = useRelativeDates) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { DateBlock( dateBlocks = sectionDates, onItemClick = onItemClick, 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 index 736819c0b..3db176580 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -11,5 +11,4 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() suspend fun shiftDueDate(courseIds: List) = repository.shiftDueDate(courseIds) - } 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 index 761b697c1..00e083de7 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -128,7 +128,6 @@ class DatesFragment : Fragment() { companion object { const val LOAD_MORE_THRESHOLD = 4 } - } @OptIn(ExperimentalMaterialApi::class) 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 70052e6d8..f3f2f1891 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 @@ -67,9 +67,7 @@ class DatesViewModel( isRefreshing = refresh, ) } - if (refresh) { - page = 1 - } + if (refresh) page = 1 val response = if (networkConnection.isOnline() || page > 1) { datesInteractor.getUserDates(page) } else { diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index d01794e2e..dbe9527b4 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -364,4 +364,4 @@ class DatesViewModelTest { // After refresh, isRefreshing should be false. assertFalse(viewModel.uiState.value.isRefreshing) } -} \ No newline at end of file +} From 3a904c22db6d8dde460dd12fa918035a24a1e8bd Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 25 Mar 2025 13:40:28 +0200 Subject: [PATCH 14/41] feat: pagination --- .../core/data/model/CourseDatesResponse.kt | 10 +- .../core/domain/model/CourseDatesResponse.kt | 6 +- .../core/presentation/dates/DatesUI.kt | 4 +- .../dates/data/storage/CourseDateEntity.kt | 25 +-- .../dates/presentation/dates/DatesFragment.kt | 16 +- .../presentation/dates/DatesViewModel.kt | 182 +++++++++++------- .../org/openedx/dates/DatesViewModelTest.kt | 10 +- 7 files changed, 146 insertions(+), 107 deletions(-) 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 index b39028bd5..c86500671 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -8,8 +8,8 @@ import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesRes data class CourseDate( @SerializedName("course_id") val courseId: String, - @SerializedName("assignment_block_id") - val assignmentBlockId: String, + @SerializedName("first_component_block_id") + val firstComponentBlockId: String?, @SerializedName("due_date") val dueDate: String?, @SerializedName("assignment_title") @@ -25,7 +25,7 @@ data class CourseDate( val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") return DomainCourseDate( courseId = courseId, - assignmentBlockId = assignmentBlockId, + firstComponentBlockId = firstComponentBlockId ?: "", dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, @@ -39,9 +39,9 @@ data class CourseDatesResponse( @SerializedName("count") val count: Int, @SerializedName("next") - val next: Int?, + val next: String?, @SerializedName("previous") - val previous: Int?, + val previous: String?, @SerializedName("results") val results: List ) { 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 index 2248e3a21..5a317b69c 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -4,14 +4,14 @@ import java.util.Date data class CourseDatesResponse( val count: Int, - val next: Int?, - val previous: Int?, + val next: String?, + val previous: String?, val results: List ) data class CourseDate( val courseId: String, - val assignmentBlockId: String, + val firstComponentBlockId: String, val dueDate: Date, val assignmentTitle: String, val learnerHasAccess: Boolean, 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 0b4cb0be8..f8f45edc3 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 @@ -273,7 +273,7 @@ private fun CourseDateItem( .fillMaxWidth() .padding(end = 4.dp) .clickable( - enabled = dateBlock.assignmentBlockId.isNotEmpty() && dateBlock.learnerHasAccess, + enabled = dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess, onClick = { onItemClick(dateBlock) } ) ) { @@ -296,7 +296,7 @@ private fun CourseDateItem( overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.width(7.dp)) - if (dateBlock.assignmentBlockId.isNotEmpty() && dateBlock.learnerHasAccess) { + if (dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.textDark, diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt index ec751d1ee..de7705d54 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt @@ -9,20 +9,22 @@ import org.openedx.core.domain.model.CourseDate as DomainCourseDate @Entity(tableName = "course_date_table") data class CourseDateEntity( - @PrimaryKey - @ColumnInfo("assignmentBlockId") - val assignmentBlockId: String, - @ColumnInfo("courseId") + @PrimaryKey(autoGenerate = true) + @ColumnInfo("course_date_id") + val id: Int, + @ColumnInfo("course_date_first_component_block_id") + val firstComponentBlockId: String?, + @ColumnInfo("course_date_courseId") val courseId: String, - @ColumnInfo("dueDate") + @ColumnInfo("course_date_dueDate") val dueDate: String?, - @ColumnInfo("assignmentTitle") + @ColumnInfo("course_date_assignmentTitle") val assignmentTitle: String?, - @ColumnInfo("learnerHasAccess") + @ColumnInfo("course_date_learnerHasAccess") val learnerHasAccess: Boolean?, - @ColumnInfo("relative") + @ColumnInfo("course_date_relative") val relative: Boolean?, - @ColumnInfo("courseName") + @ColumnInfo("course_date_courseName") val courseName: String?, ) { @@ -30,7 +32,7 @@ data class CourseDateEntity( val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") return DomainCourseDate( courseId = courseId, - assignmentBlockId = assignmentBlockId, + firstComponentBlockId = firstComponentBlockId ?: "", dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, @@ -43,8 +45,9 @@ data class CourseDateEntity( fun createFrom(courseDate: CourseDate): CourseDateEntity { with(courseDate) { return CourseDateEntity( + id = 0, courseId = courseId, - assignmentBlockId = assignmentBlockId, + firstComponentBlockId = firstComponentBlockId, dueDate = dueDate, assignmentTitle = assignmentTitle, learnerHasAccess = learnerHasAccess, 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 index 00e083de7..22974ee32 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -31,8 +31,8 @@ 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.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -57,7 +57,6 @@ import org.openedx.core.ui.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.shouldLoadMore import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -126,7 +125,7 @@ class DatesFragment : Fragment() { } companion object { - const val LOAD_MORE_THRESHOLD = 4 + const val LOAD_MORE_THRESHOLD = 0.8f } } @@ -157,9 +156,7 @@ private fun DatesScreen( mutableStateOf(false) } val scrollState = rememberLazyListState() - val firstVisibleIndex = remember { - mutableIntStateOf(scrollState.firstVisibleItemIndex) - } + val layoutInfo by remember { derivedStateOf { scrollState.layoutInfo } } Scaffold( scaffoldState = scaffoldState, @@ -249,7 +246,12 @@ private fun DatesScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + val lastVisibleItemIndex = + layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItemsCount = layoutInfo.totalItemsCount + if (totalItemsCount > 0 && + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + ) { onAction(DatesViewActions.LoadMore) } } 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 f3f2f1891..f62f48a60 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 @@ -13,6 +13,7 @@ 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 @@ -61,62 +62,93 @@ class DatesViewModel( private fun fetchDates(refresh: Boolean) { viewModelScope.launch { try { - _uiState.update { state -> - state.copy( - isLoading = !refresh, - isRefreshing = refresh, - ) - } - if (refresh) page = 1 - val response = if (networkConnection.isOnline() || page > 1) { - datesInteractor.getUserDates(page) - } else { - null - } + updateLoadingState(refresh) + val response = getUserDates(refresh) if (response != null) { - if (response.next.isNotNull() && page != response.count) { - _uiState.update { state -> state.copy(canLoadMore = true) } - page++ - } else { - _uiState.update { state -> state.copy(canLoadMore = false) } - page = -1 - } - _uiState.update { state -> - state.copy( - dates = state.dates + groupCourseDates(response.results) - ) - } + updateUIWithResponse(response, refresh) } else { - val cachedList = datesInteractor.getUserDatesFromCache() - _uiState.update { state -> state.copy(canLoadMore = false) } - page = -1 - _uiState.update { state -> - state.copy( - dates = groupCourseDates(cachedList) - ) - } + updateUIWithCachedResponse() } } 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)) - ) - } + handleFetchException(e) } finally { - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false, - ) - } + clearLoadingState() } } } + private fun updateLoadingState(refresh: Boolean) { + _uiState.update { state -> + state.copy( + isLoading = !refresh, + isRefreshing = refresh + ) + } + } + + private suspend fun getUserDates(refresh: Boolean) = if (refresh) { + page = 1 + datesInteractor.getUserDates(page) + } else { + if (networkConnection.isOnline() || page > 1) { + datesInteractor.getUserDates(page) + } else { + null + } + } + + private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { + if (response.next.isNotNull() && page != response.count) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + } + _uiState.update { state -> + if (refresh) { + state.copy( + dates = groupCourseDates(response.results) + ) + } else { + val newDates = groupCourseDates(response.results) + state.copy(dates = mergeDates(state.dates, newDates)) + } + } + } + + private suspend fun updateUIWithCachedResponse() { + val cachedList = datesInteractor.getUserDatesFromCache() + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList) + ) + } + } + + private suspend fun handleFetchException(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)) + ) + } + } + + private fun clearLoadingState() { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false + ) + } + } + fun shiftDueDate() { logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) viewModelScope.launch { @@ -130,18 +162,11 @@ class DatesViewModel( val courseIds = pastDueDates .filter { it.relative } .map { it.courseId } + .distinct() datesInteractor.shiftDueDate(courseIds) refreshData() } 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)) - ) - } + handleFetchException(e) } finally { _uiState.update { state -> state.copy( @@ -176,34 +201,43 @@ class DatesViewModel( courseId = courseDate.courseId, courseTitle = courseDate.courseName, openTab = "", - resumeBlockId = courseDate.assignmentBlockId + resumeBlockId = courseDate.firstComponentBlockId ) } private fun groupCourseDates(dates: List): Map> { val now = Date() val calNow = Calendar.getInstance().apply { time = now } - val grouped = dates.groupBy { courseDate -> - val dueDate = courseDate.dueDate - if (dueDate.before(now)) { - DatesSection.PAST_DUE - } else if (dueDate.isToday()) { - DatesSection.TODAY - } else { - val calDue = dueDate.toCalendar() - val weekNow = calNow.get(Calendar.WEEK_OF_YEAR) - val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) - val yearNow = calNow.get(Calendar.YEAR) - val yearDue = calDue.get(Calendar.YEAR) - if (weekNow == weekDue && yearNow == yearDue) { - DatesSection.THIS_WEEK - } else { - DatesSection.UPCOMING + 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 = calNow.get(Calendar.WEEK_OF_YEAR) + val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) + val yearNow = calNow.get(Calendar.YEAR) + val yearDue = calDue.get(Calendar.YEAR) + if (weekNow == weekDue && yearNow == yearDue) { + DatesSection.THIS_WEEK + } else { + DatesSection.UPCOMING + } } } } + } - return grouped + 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( diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index dbe9527b4..16f0a30ac 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -79,8 +79,8 @@ class DatesViewModelTest { val courseDate: CourseDate = mockk(relaxed = true) val courseDatesResponse = CourseDatesResponse( count = 10, - next = 2, - previous = 1, + next = "", + previous = "", results = listOf(courseDate) ) coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse @@ -283,7 +283,7 @@ class DatesViewModelTest { val courseDate: CourseDate = mockk(relaxed = true) { every { courseId } returns "course-123" every { courseName } returns "Test Course" - every { assignmentBlockId } returns "block-1" + every { firstComponentBlockId } returns "block-1" } viewModel.navigateToCourseOutline(fragmentManager, courseDate) @@ -304,8 +304,8 @@ class DatesViewModelTest { val courseDate: CourseDate = mockk(relaxed = true) val courseDatesResponse = CourseDatesResponse( count = 10, - next = 2, - previous = 1, + next = "", + previous = "", results = listOf(courseDate) ) From 7ecaa334d283f981ad2d7613d5e2c8a124c04c26 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 27 Mar 2025 18:12:09 +0200 Subject: [PATCH 15/41] fix: pagination bugs --- .../org/openedx/core/data/api/CourseApi.kt | 7 ++---- .../core/data/model/ShiftDueDatesBody.kt | 7 ------ .../core/presentation/dates/DatesUI.kt | 1 + .../dates/data/repository/DatesRepository.kt | 7 +++--- .../domain/interactor/DatesInteractor.kt | 2 +- .../dates/presentation/dates/DatesFragment.kt | 5 +++- .../presentation/dates/DatesViewModel.kt | 23 ++++++++----------- .../org/openedx/dates/DatesViewModelTest.kt | 4 ++-- 8 files changed, 24 insertions(+), 32 deletions(-) delete mode 100644 core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index c701c13e1..99b0e4e34 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 @@ -15,7 +15,6 @@ import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates -import org.openedx.core.data.model.ShiftDueDatesBody import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -125,8 +124,6 @@ interface CourseApi { @Path("course_id") courseId: String, ): CourseProgressResponse - @POST("/api/course_experience/v1/reset_multiple_course_deadlines/") - suspend fun shiftDueDate( - @Body shiftDueDatesBody: ShiftDueDatesBody - ) + @POST("/api/course_experience/v1/reset_all_relative_course_deadlines/") + suspend fun shiftDueDate() } diff --git a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt deleted file mode 100644 index 63e66363d..000000000 --- a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.openedx.core.data.model - -import com.google.gson.annotations.SerializedName - -data class ShiftDueDatesBody( - @SerializedName("course_keys") val courseKeys: List -) 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 f8f45edc3..499d101e8 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 @@ -312,6 +312,7 @@ private fun CourseDateItem( .fillMaxWidth() .padding(top = 4.dp), text = dateBlock.courseName, + maxLines = 1, style = MaterialTheme.appTypography.labelMedium, ) } 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 index fcfad5460..69396450a 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,7 +1,6 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.ShiftDueDatesBody import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse @@ -16,6 +15,9 @@ class DatesRepository( suspend fun getUserDates(page: Int): CourseDatesResponse { val username = preferencesManager.user?.username ?: "" val response = api.getUserDates(username, page) + if (page == 1) { + dao.clearCachedData() + } dao.insertCourseDateEntities(response.results.map { CourseDateEntity.createFrom(it) }) return response.mapToDomain() } @@ -24,6 +26,5 @@ class DatesRepository( return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } } - suspend fun shiftDueDate(courseIds: List) = - api.shiftDueDate(ShiftDueDatesBody(courseIds)) + suspend fun shiftDueDate() = api.shiftDueDate() } 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 index 3db176580..e72b9dae0 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -10,5 +10,5 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() - suspend fun shiftDueDate(courseIds: List) = repository.shiftDueDate(courseIds) + suspend fun shiftDueDate() = repository.shiftDueDate() } 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 index 22974ee32..ef53a6924 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -250,7 +250,10 @@ private fun DatesScreen( layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalItemsCount = layoutInfo.totalItemsCount if (totalItemsCount > 0 && - lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() && + !uiState.isLoading && + !uiState.isRefreshing && + uiState.canLoadMore ) { onAction(DatesViewActions.LoadMore) } 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 f62f48a60..9397e033b 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 @@ -2,6 +2,7 @@ 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 @@ -54,13 +55,14 @@ class DatesViewModel( var useRelativeDates = corePreferences.isRelativeDatesEnabled private var page = 1 + private var fetchDataJob: Job? = null init { fetchDates(false) } private fun fetchDates(refresh: Boolean) { - viewModelScope.launch { + fetchDataJob = viewModelScope.launch { try { updateLoadingState(refresh) val response = getUserDates(refresh) @@ -70,6 +72,7 @@ class DatesViewModel( updateUIWithCachedResponse() } } catch (e: Exception) { + page = -1 handleFetchException(e) } finally { clearLoadingState() @@ -86,11 +89,9 @@ class DatesViewModel( } } - private suspend fun getUserDates(refresh: Boolean) = if (refresh) { - page = 1 - datesInteractor.getUserDates(page) - } else { - if (networkConnection.isOnline() || page > 1) { + private suspend fun getUserDates(refresh: Boolean): CourseDatesResponse? { + if (refresh) page = 1 + return if (networkConnection.isOnline() || page > 1) { datesInteractor.getUserDates(page) } else { null @@ -98,7 +99,7 @@ class DatesViewModel( } private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { - if (response.next.isNotNull() && page != response.count) { + if (response.next.isNotNull()) { _uiState.update { state -> state.copy(canLoadMore = true) } page++ } else { @@ -158,12 +159,7 @@ class DatesViewModel( isShiftDueDatesPressed = true, ) } - val pastDueDates = _uiState.value.dates[DatesSection.PAST_DUE] ?: emptyList() - val courseIds = pastDueDates - .filter { it.relative } - .map { it.courseId } - .distinct() - datesInteractor.shiftDueDate(courseIds) + datesInteractor.shiftDueDate() refreshData() } catch (e: Exception) { handleFetchException(e) @@ -184,6 +180,7 @@ class DatesViewModel( } fun refreshData() { + fetchDataJob?.cancel() fetchDates(true) } diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index 16f0a30ac..ffa7198a4 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -208,7 +208,7 @@ class DatesViewModelTest { viewModel.shiftDueDate() advanceUntilIdle() - coVerify { datesInteractor.shiftDueDate(listOf("course-123")) } + coVerify { datesInteractor.shiftDueDate() } // isShiftDueDatesPressed should be reset to false after processing. assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) } @@ -229,7 +229,7 @@ class DatesViewModelTest { results = listOf(courseDate) ) coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse - coEvery { datesInteractor.shiftDueDate(any()) } throws Exception() + coEvery { datesInteractor.shiftDueDate() } throws Exception() val viewModel = DatesViewModel( datesRouter, From 894a20694c3461d58896129c02080f3fee9c733c Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 31 Mar 2025 16:53:15 +0300 Subject: [PATCH 16/41] feat: cache-first logic --- .../main/java/org/openedx/app/MainFragment.kt | 9 +- .../java/org/openedx/app/di/ScreenModule.kt | 4 +- .../java/org/openedx/app/room/AppDatabase.kt | 3 +- .../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/menu/bottom_view_menu.xml | 0 app/src/main/res/values/main_manu_tab_ids.xml | 1 + ...{DatesConfig.kt => AppLevelDatesConfig.kt} | 2 +- .../core/config/ExperimentalFeaturesConfig.kt | 2 + .../core/data/model/CourseDatesResponse.kt | 4 +- .../model/room/CourseDatesResponseEntity.kt | 51 ++- .../java/org/openedx/core/ui/ComposeCommon.kt | 4 +- .../course/data/storage/CourseConverter.kt | 13 + .../learn/presentation/LearnFragment.kt | 6 +- .../dates/data/repository/DatesRepository.kt | 16 +- .../openedx/dates/data/storage/DatesDao.kt | 9 +- .../domain/interactor/DatesInteractor.kt | 2 + .../dates/presentation/dates/DatesFragment.kt | 324 ----------------- .../dates/presentation/dates/DatesScreen.kt | 325 ++++++++++++++++++ .../presentation/dates/DatesViewModel.kt | 67 ++-- .../org/openedx/dates/DatesViewModelTest.kt | 17 +- default_config/dev/config.yaml | 3 - default_config/prod/config.yaml | 5 +- default_config/stage/config.yaml | 5 +- .../presentation/download/DownloadsScreen.kt | 4 +- 26 files changed, 508 insertions(+), 391 deletions(-) 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 delete mode 100644 app/src/main/res/menu/bottom_view_menu.xml rename core/src/main/java/org/openedx/core/config/{DatesConfig.kt => AppLevelDatesConfig.kt} (82%) rename dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt => core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt (54%) create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 82092e439..80ecd842a 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -26,6 +26,7 @@ import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment 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.dates.presentation.dates.DatesFragment import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.downloads.presentation.download.DownloadsFragment @@ -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 -> @@ -173,7 +180,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { } else { 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/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 9f096a7a3..f2d531918 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -240,6 +240,7 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -605,7 +606,8 @@ val screenModule = module { resourceManager = get(), datesInteractor = get(), corePreferences = get(), - analytics = 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 2ee6f7eec..06181c510 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.CourseDatesResponseEntity 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,7 +20,6 @@ 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.CourseDateEntity import org.openedx.dates.data.storage.DatesDao import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity @@ -42,6 +42,7 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEnrollmentDetailsEntity::class, CourseDateEntity::class, VideoProgressEntity::class, + CourseDatesResponseEntity::class, CourseProgressEntity::class, ], autoMigrations = [ 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..b803c4937 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml deleted file mode 100644 index e69de29bb..000000000 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/core/src/main/java/org/openedx/core/config/DatesConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt similarity index 82% rename from core/src/main/java/org/openedx/core/config/DatesConfig.kt rename to core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt index 0e48a5ed5..73392bf72 100644 --- a/core/src/main/java/org/openedx/core/config/DatesConfig.kt +++ b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt @@ -2,7 +2,7 @@ package org.openedx.core.config import com.google.gson.annotations.SerializedName -data class DatesConfig( +data class AppLevelDatesConfig( @SerializedName("ENABLED") val isEnabled: Boolean = true, ) 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/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt index c86500671..28a1b28dd 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -50,7 +50,9 @@ data class CourseDatesResponse( count = count, next = next, previous = previous, - results = results.mapNotNull { it.mapToDomain() } + results = results + .mapNotNull { it.mapToDomain() } + .sortedBy { it.dueDate } ) } } diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt similarity index 54% rename from dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt rename to core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt index de7705d54..5231a5604 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt @@ -1,17 +1,55 @@ -package org.openedx.dates.data.storage +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.data.model.CourseDatesResponse import org.openedx.core.utils.TimeUtils import org.openedx.core.domain.model.CourseDate as DomainCourseDate +import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesResponse -@Entity(tableName = "course_date_table") -data class CourseDateEntity( +@Entity(tableName = "course_dates_response_table") +data class CourseDatesResponseEntity( @PrimaryKey(autoGenerate = true) - @ColumnInfo("course_date_id") + @ColumnInfo("course_date_response_id") val id: Int, + @ColumnInfo("course_date_response_count") + val count: Int, + @ColumnInfo("course_date_response_next") + val next: String?, + @ColumnInfo("course_date_response_previous") + val previous: String?, + @ColumnInfo("course_date_response_results") + val results: List +) { + fun mapToDomain(): DomainCourseDatesResponse { + return DomainCourseDatesResponse( + count = count, + next = next, + previous = previous, + results = results + .mapNotNull { it.mapToDomain() } + .sortedBy { it.dueDate } + ) + } + + companion object { + fun createFrom(courseDatesResponse: CourseDatesResponse): CourseDatesResponseEntity { + with(courseDatesResponse) { + return CourseDatesResponseEntity( + id = 0, + count = count, + next = next, + previous = previous, + results = results.map { CourseDateDB.createFrom(it) } + ) + } + } + } +} + +data class CourseDateDB( @ColumnInfo("course_date_first_component_block_id") val firstComponentBlockId: String?, @ColumnInfo("course_date_courseId") @@ -42,10 +80,9 @@ data class CourseDateEntity( } companion object { - fun createFrom(courseDate: CourseDate): CourseDateEntity { + fun createFrom(courseDate: CourseDate): CourseDateDB { with(courseDate) { - return CourseDateEntity( - id = 0, + return CourseDateDB( courseId = courseId, firstComponentBlockId = firstComponentBlockId, dueDate = dueDate, 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 6243dae74..4230980be 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1277,7 +1277,7 @@ private fun RoundTab( } @Composable -fun MainScreenTitle( +fun MainScreenToolbar( modifier: Modifier = Modifier, label: String, onSettingsClick: () -> Unit, @@ -1314,7 +1314,7 @@ fun MainScreenTitle( @Composable private fun MainScreenTitlePreview() { OpenEdXTheme { - MainScreenTitle( + MainScreenToolbar( label = "Title", onSettingsClick = {} ) diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index b49a806e6..68829efd2 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -4,6 +4,7 @@ import androidx.room.TypeConverter import com.google.common.reflect.TypeToken import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb +import org.openedx.core.data.model.room.CourseDateDB import org.openedx.core.data.model.room.GradingPolicyDb import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb @@ -83,4 +84,16 @@ class CourseConverter { @TypeConverter fun toGradeRangeMap(value: String): Map = Gson().fromJson(value, object : TypeToken>() {}.type) + + @TypeConverter + fun fromListOfCourseDateDB(value: List): String { + val json = Gson().toJson(value) + return json.toString() + } + + @TypeConverter + fun toListOfCourseDateDB(value: String): List { + val type = genericType>() + return Gson().fromJson(value, type) + } } 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 54e4402ee..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.MainScreenTitle +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 ) { - MainScreenTitle( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -240,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - MainScreenTitle( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = {} ) 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 index 69396450a..3ce6b463a 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,10 +1,10 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.CourseDatesResponseEntity 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.CourseDateEntity import org.openedx.dates.data.storage.DatesDao class DatesRepository( @@ -18,12 +18,22 @@ class DatesRepository( if (page == 1) { dao.clearCachedData() } - dao.insertCourseDateEntities(response.results.map { CourseDateEntity.createFrom(it) }) + dao.insertCourseDateResponses(CourseDatesResponseEntity.createFrom(response)) return response.mapToDomain() } suspend fun getUserDatesFromCache(): List { - return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } + return dao.getCourseDateResponses() + .map { it.mapToDomain() } + .map { it.results } + .flatten() + .sortedBy { it.dueDate } + } + + suspend fun preloadFirstPageCachedDates(): CourseDatesResponse? { + return dao.getCourseDateResponses() + .find { it.previous == null } + ?.mapToDomain() } suspend fun shiftDueDate() = api.shiftDueDate() 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 index 50b570112..1c46ad77d 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -4,16 +4,17 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import org.openedx.core.data.model.room.CourseDatesResponseEntity @Dao interface DatesDao { - @Query("SELECT * FROM course_date_table") - suspend fun getCourseDateEntities(): List + @Query("SELECT * FROM course_dates_response_table") + suspend fun getCourseDateResponses(): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertCourseDateEntities(courseDate: List) + suspend fun insertCourseDateResponses(courseDates: CourseDatesResponseEntity) - @Query("DELETE FROM course_date_table") + @Query("DELETE FROM course_dates_response_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 index e72b9dae0..96f7cf8ba 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -10,5 +10,7 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() + suspend fun preloadFirstPageCachedDates() = repository.preloadFirstPageCachedDates() + suspend fun shiftDueDate() = repository.shiftDueDate() } 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 index ef53a6924..219cadbc3 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -3,71 +3,13 @@ package org.openedx.dates.presentation.dates import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -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.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.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.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.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -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 androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -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.MainScreenTitle -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 class DatesFragment : Fragment() { @@ -128,269 +70,3 @@ class DatesFragment : Fragment() { const val LOAD_MORE_THRESHOLD = 0.8f } } - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private 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 = { - MainScreenTitle( - modifier = Modifier - .statusBarsInset() - .displayCutoutForLandscape(), - label = stringResource(id = R.string.dates), - 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) - ) { - 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( - modifier = Modifier.padding(top = 12.dp), - 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 - if (totalItemsCount > 0 && - lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() && - !uiState.isLoading && - !uiState.isRefreshing && - uiState.canLoadMore - ) { - 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( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), - 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/DatesScreen.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt new file mode 100644 index 000000000..8c62f576c --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -0,0 +1,325 @@ +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.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.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.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.painterResource +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), + 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) + ) { + 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( + modifier = Modifier.padding(top = 12.dp), + 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 + if (totalItemsCount > 0 && + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + ) { + 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( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), + 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/DatesViewModel.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt index 9397e033b..2da6ac783 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 @@ -20,6 +20,7 @@ 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 @@ -38,6 +39,7 @@ class DatesViewModel( private val resourceManager: ResourceManager, private val datesInteractor: DatesInteractor, private val analytics: DatesAnalytics, + private val calendarSyncScheduler: CalendarSyncScheduler, corePreferences: CorePreferences, ) : BaseViewModel() { @@ -58,21 +60,23 @@ class DatesViewModel( private var fetchDataJob: Job? = null 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 = getUserDates(refresh) - if (response != null) { - updateUIWithResponse(response, refresh) - } else { - updateUIWithCachedResponse() - } + val response = datesInteractor.getUserDates(page) + updateUIWithResponse(response, refresh) } catch (e: Exception) { page = -1 + updateUIWithCachedResponse() handleFetchException(e) } finally { clearLoadingState() @@ -89,39 +93,26 @@ class DatesViewModel( } } - private suspend fun getUserDates(refresh: Boolean): CourseDatesResponse? { - if (refresh) page = 1 - return if (networkConnection.isOnline() || page > 1) { - datesInteractor.getUserDates(page) - } else { - null - } - } - private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { - if (response.next.isNotNull()) { - _uiState.update { state -> state.copy(canLoadMore = true) } - page++ - } else { - _uiState.update { state -> state.copy(canLoadMore = false) } - page = -1 - } _uiState.update { state -> - if (refresh) { - state.copy( - dates = groupCourseDates(response.results) - ) + 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(canLoadMore = false) } - page = -1 _uiState.update { state -> state.copy( dates = groupCourseDates(cachedList) @@ -129,7 +120,19 @@ class DatesViewModel( } } - private suspend fun handleFetchException(e: Exception) { + private fun preloadFirstPageCachedDates() { + viewModelScope.launch { + val cachedList = datesInteractor.preloadFirstPageCachedDates()?.results ?: emptyList() + _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)) @@ -161,6 +164,7 @@ class DatesViewModel( } datesInteractor.shiftDueDate() refreshData() + calendarSyncScheduler.requestImmediateSync() } catch (e: Exception) { handleFetchException(e) } finally { @@ -174,7 +178,10 @@ class DatesViewModel( } fun fetchMore() { - if (!_uiState.value.isLoading && page != -1) { + if (!_uiState.value.isLoading && + !_uiState.value.isRefreshing && + _uiState.value.canLoadMore + ) { fetchDates(false) } } @@ -217,6 +224,8 @@ class DatesViewModel( 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 } diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index ffa7198a4..1a2e556c2 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -30,6 +30,7 @@ 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 @@ -52,6 +53,7 @@ class DatesViewModelTest { 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" @@ -66,6 +68,8 @@ class DatesViewModelTest { every { networkConnection.isOnline() } returns true every { corePreferences.isRelativeDatesEnabled } returns true every { analytics.logEvent(any(), any()) } returns Unit + coEvery { datesInteractor.preloadFirstPageCachedDates() } returns null + coEvery { datesInteractor.getUserDatesFromCache() } returns emptyList() } @After @@ -92,6 +96,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences, ) advanceUntilIdle() @@ -114,15 +119,13 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() - // When offline, getUserDates is not called. - coVerify(exactly = 0) { datesInteractor.getUserDates(any()) } coVerify(exactly = 1) { datesInteractor.getUserDatesFromCache() } assertFalse(viewModel.uiState.value.isLoading) - // Expect no further pages to load. assertFalse(viewModel.uiState.value.canLoadMore) } @@ -137,6 +140,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val message = async { @@ -162,6 +166,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val message = async { @@ -201,6 +206,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() @@ -237,6 +243,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() @@ -261,6 +268,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -277,6 +285,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -320,6 +329,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() @@ -352,6 +362,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index ac06ef7ba..952e041de 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,9 +31,6 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -APP_LEVEL_DATES: - ENABLED: true - FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 54b5e3e2a..d30d38719 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 @@ -70,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: true #Platform names PLATFORM_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 54b5e3e2a..d30d38719 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 @@ -70,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: true #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(), From abaa25aca29b6558fdf21187cab997808c73e2ce Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 2 Apr 2025 12:52:13 +0300 Subject: [PATCH 17/41] fix: changes according code review --- .../main/java/org/openedx/app/MainFragment.kt | 3 +- .../app/data/networking/HeadersInterceptor.kt | 2 +- .../java/org/openedx/app/room/AppDatabase.kt | 2 +- .../res/drawable/app_ic_dates_selector.xml | 2 +- .../org/openedx/core/data/api/CourseApi.kt | 2 +- .../core/data/model/CourseDatesResponse.kt | 4 +- .../core/data/model/room/CourseDateEntity.kt | 60 ++++++++++++ .../model/room/CourseDatesResponseEntity.kt | 97 ------------------- .../core/presentation/dates/DatesUI.kt | 17 ++-- dates/proguard-rules.pro | 28 ++---- .../dates/data/repository/DatesRepository.kt | 22 ++--- .../openedx/dates/data/storage/DatesDao.kt | 13 ++- .../domain/interactor/DatesInteractor.kt | 2 +- .../dates/presentation/dates/DatesFragment.kt | 2 +- .../dates/presentation/dates/DatesScreen.kt | 2 +- .../presentation/dates/DatesViewModel.kt | 16 +-- dates/src/main/res/layout/fragment_dates.xml | 6 -- dates/src/main/res/values/strings.xml | 4 +- .../org/openedx/dates/DatesViewModelTest.kt | 14 +-- 19 files changed, 122 insertions(+), 176 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt delete mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt delete mode 100644 dates/src/main/res/layout/fragment_dates.xml diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 80ecd842a..fdea40e77 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -26,8 +26,8 @@ import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment 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.dates.presentation.dates.DatesFragment 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 @@ -180,6 +180,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { } else { 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/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/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index 06181c510..fd0461d8e 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -6,7 +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.CourseDatesResponseEntity +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 diff --git a/app/src/main/res/drawable/app_ic_dates_selector.xml b/app/src/main/res/drawable/app_ic_dates_selector.xml index b803c4937..9e20819bf 100644 --- a/app/src/main/res/drawable/app_ic_dates_selector.xml +++ b/app/src/main/res/drawable/app_ic_dates_selector.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + 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 99b0e4e34..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 @@ -125,5 +125,5 @@ interface CourseApi { ): CourseProgressResponse @POST("/api/course_experience/v1/reset_all_relative_course_deadlines/") - suspend fun shiftDueDate() + 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 index 28a1b28dd..c86500671 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -50,9 +50,7 @@ data class CourseDatesResponse( count = count, next = next, previous = previous, - results = results - .mapNotNull { it.mapToDomain() } - .sortedBy { it.dueDate } + results = results.mapNotNull { it.mapToDomain() } ) } } 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/data/model/room/CourseDatesResponseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt deleted file mode 100644 index 5231a5604..000000000 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt +++ /dev/null @@ -1,97 +0,0 @@ -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.data.model.CourseDatesResponse -import org.openedx.core.utils.TimeUtils -import org.openedx.core.domain.model.CourseDate as DomainCourseDate -import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesResponse - -@Entity(tableName = "course_dates_response_table") -data class CourseDatesResponseEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo("course_date_response_id") - val id: Int, - @ColumnInfo("course_date_response_count") - val count: Int, - @ColumnInfo("course_date_response_next") - val next: String?, - @ColumnInfo("course_date_response_previous") - val previous: String?, - @ColumnInfo("course_date_response_results") - val results: List -) { - fun mapToDomain(): DomainCourseDatesResponse { - return DomainCourseDatesResponse( - count = count, - next = next, - previous = previous, - results = results - .mapNotNull { it.mapToDomain() } - .sortedBy { it.dueDate } - ) - } - - companion object { - fun createFrom(courseDatesResponse: CourseDatesResponse): CourseDatesResponseEntity { - with(courseDatesResponse) { - return CourseDatesResponseEntity( - id = 0, - count = count, - next = next, - previous = previous, - results = results.map { CourseDateDB.createFrom(it) } - ) - } - } - } -} - -data class CourseDateDB( - @ColumnInfo("course_date_first_component_block_id") - val firstComponentBlockId: String?, - @ColumnInfo("course_date_courseId") - val courseId: String, - @ColumnInfo("course_date_dueDate") - val dueDate: String?, - @ColumnInfo("course_date_assignmentTitle") - val assignmentTitle: String?, - @ColumnInfo("course_date_learnerHasAccess") - val learnerHasAccess: Boolean?, - @ColumnInfo("course_date_relative") - val relative: Boolean?, - @ColumnInfo("course_date_courseName") - 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): CourseDateDB { - with(courseDate) { - return CourseDateDB( - courseId = courseId, - firstComponentBlockId = firstComponentBlockId, - dueDate = dueDate, - assignmentTitle = assignmentTitle, - learnerHasAccess = learnerHasAccess, - relative = relative, - courseName = courseName - ) - } - } - } -} 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 499d101e8..c57874865 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 @@ -35,6 +35,7 @@ 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( @@ -261,13 +262,15 @@ private fun CourseDateItem( if (isMiddleChild) { Spacer(modifier = Modifier.height(20.dp)) } - val timeTitle = formatToString(context, dateBlock.dueDate, useRelativeDates) - Text( - text = timeTitle, - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark, - maxLines = 1, - ) + 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() diff --git a/dates/proguard-rules.pro b/dates/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/dates/proguard-rules.pro +++ b/dates/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# 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/java/org/openedx/dates/data/repository/DatesRepository.kt b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt index 3ce6b463a..f261d312d 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,7 +1,7 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.room.CourseDatesResponseEntity +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 @@ -18,23 +18,21 @@ class DatesRepository( if (page == 1) { dao.clearCachedData() } - dao.insertCourseDateResponses(CourseDatesResponseEntity.createFrom(response)) + dao.insertCourseDates(response.results.map { CourseDateEntity.createFrom(it) }) return response.mapToDomain() } suspend fun getUserDatesFromCache(): List { - return dao.getCourseDateResponses() - .map { it.mapToDomain() } - .map { it.results } - .flatten() - .sortedBy { it.dueDate } + return dao.getCourseDates().mapNotNull { it.mapToDomain() } } - suspend fun preloadFirstPageCachedDates(): CourseDatesResponse? { - return dao.getCourseDateResponses() - .find { it.previous == null } - ?.mapToDomain() + suspend fun preloadFirstPageCachedDates(): List { + return dao.getCourseDates(PAGE_SIZE).mapNotNull { it.mapToDomain() } } - suspend fun shiftDueDate() = api.shiftDueDate() + 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 index 1c46ad77d..e8df66ad2 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -4,17 +4,20 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.openedx.core.data.model.room.CourseDatesResponseEntity +import org.openedx.core.data.model.room.CourseDateEntity @Dao interface DatesDao { - @Query("SELECT * FROM course_dates_response_table") - suspend fun getCourseDateResponses(): List + @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 insertCourseDateResponses(courseDates: CourseDatesResponseEntity) + suspend fun insertCourseDates(courseDates: List) - @Query("DELETE FROM course_dates_response_table") + @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 index 96f7cf8ba..5bcb8abf1 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -12,5 +12,5 @@ class DatesInteractor( suspend fun preloadFirstPageCachedDates() = repository.preloadFirstPageCachedDates() - suspend fun shiftDueDate() = repository.shiftDueDate() + suspend fun shiftAllDueDates() = repository.shiftAllDueDates() } 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 index 219cadbc3..2d28bb389 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -50,7 +50,7 @@ class DatesFragment : Fragment() { } DatesViewActions.ShiftDueDate -> { - viewModel.shiftDueDate() + viewModel.shiftAllDueDates() } is DatesViewActions.OpenEvent -> { 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 index 8c62f576c..010f2b895 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -100,7 +100,7 @@ fun DatesScreen( modifier = Modifier .statusBarsInset() .displayCutoutForLandscape(), - label = stringResource(id = R.string.dates), + label = stringResource(id = R.string.dates_title), onSettingsClick = { onAction(DatesViewActions.OpenSettings) } 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 2da6ac783..3fa4f3d02 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 @@ -112,17 +112,17 @@ class DatesViewModel( private suspend fun updateUIWithCachedResponse() { val cachedList = datesInteractor.getUserDatesFromCache() - _uiState.update { state -> state.copy(canLoadMore = false) } _uiState.update { state -> state.copy( - dates = groupCourseDates(cachedList) + dates = groupCourseDates(cachedList), + canLoadMore = false ) } } private fun preloadFirstPageCachedDates() { viewModelScope.launch { - val cachedList = datesInteractor.preloadFirstPageCachedDates()?.results ?: emptyList() + val cachedList = datesInteractor.preloadFirstPageCachedDates() _uiState.update { state -> state.copy( dates = groupCourseDates(cachedList), @@ -153,7 +153,7 @@ class DatesViewModel( } } - fun shiftDueDate() { + fun shiftAllDueDates() { logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) viewModelScope.launch { try { @@ -162,7 +162,7 @@ class DatesViewModel( isShiftDueDatesPressed = true, ) } - datesInteractor.shiftDueDate() + datesInteractor.shiftAllDueDates() refreshData() calendarSyncScheduler.requestImmediateSync() } catch (e: Exception) { @@ -211,16 +211,16 @@ class DatesViewModel( private fun groupCourseDates(dates: List): Map> { val now = Date() - val calNow = Calendar.getInstance().apply { time = now } + 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 = calNow.get(Calendar.WEEK_OF_YEAR) + val weekNow = calendar.get(Calendar.WEEK_OF_YEAR) val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) - val yearNow = calNow.get(Calendar.YEAR) + val yearNow = calendar.get(Calendar.YEAR) val yearDue = calDue.get(Calendar.YEAR) if (weekNow == weekDue && yearNow == yearDue) { DatesSection.THIS_WEEK diff --git a/dates/src/main/res/layout/fragment_dates.xml b/dates/src/main/res/layout/fragment_dates.xml deleted file mode 100644 index 77d9ef65f..000000000 --- a/dates/src/main/res/layout/fragment_dates.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml index 1a2c6f989..9aa26728d 100644 --- a/dates/src/main/res/values/strings.xml +++ b/dates/src/main/res/values/strings.xml @@ -1,9 +1,9 @@ - Dates + 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 past due assignments without losing any progress. Shift Due Dates - \ No newline at end of file + diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index 1a2e556c2..4bb903753 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -68,7 +68,7 @@ class DatesViewModelTest { every { networkConnection.isOnline() } returns true every { corePreferences.isRelativeDatesEnabled } returns true every { analytics.logEvent(any(), any()) } returns Unit - coEvery { datesInteractor.preloadFirstPageCachedDates() } returns null + coEvery { datesInteractor.preloadFirstPageCachedDates() } returns emptyList() coEvery { datesInteractor.getUserDatesFromCache() } returns emptyList() } @@ -181,7 +181,7 @@ class DatesViewModelTest { } @Test - fun `shiftDueDate success`() = runTest { + 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) { @@ -211,16 +211,16 @@ class DatesViewModelTest { ) advanceUntilIdle() - viewModel.shiftDueDate() + viewModel.shiftAllDueDates() advanceUntilIdle() - coVerify { datesInteractor.shiftDueDate() } + coVerify { datesInteractor.shiftAllDueDates() } // isShiftDueDatesPressed should be reset to false after processing. assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) } @Test - fun `shiftDueDate error emits error message and resets flag`() = + fun `shiftAllDueDates error emits error message and resets flag`() = runTest(UnconfinedTestDispatcher()) { every { networkConnection.isOnline() } returns true val courseDate: CourseDate = mockk(relaxed = true) { @@ -235,7 +235,7 @@ class DatesViewModelTest { results = listOf(courseDate) ) coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse - coEvery { datesInteractor.shiftDueDate() } throws Exception() + coEvery { datesInteractor.shiftAllDueDates() } throws Exception() val viewModel = DatesViewModel( datesRouter, @@ -248,7 +248,7 @@ class DatesViewModelTest { ) advanceUntilIdle() - viewModel.shiftDueDate() + viewModel.shiftAllDueDates() val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage From b881a1dee80288cbea3a8ba449d7bd841f7d1546 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 16 Apr 2025 11:14:08 +0300 Subject: [PATCH 18/41] feat: according designer feedback --- app/src/main/java/org/openedx/app/MainFragment.kt | 2 +- .../main/java/org/openedx/app/room/AppDatabase.kt | 6 +++--- .../openedx/course/data/storage/CourseConverter.kt | 13 ------------- .../course/presentation/dates/CourseDatesScreen.kt | 7 ++++++- .../openedx/dates/presentation/dates/DatesScreen.kt | 4 ++-- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index fdea40e77..397216b74 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -106,7 +106,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { add(R.id.fragmentDownloads to { DownloadsFragment() }) } if (viewModel.isDatesFragmentEnabled) { - add(R.id.fragmentDates to DatesFragment()) + add(R.id.fragmentDates to { DatesFragment() }) } add(R.id.fragmentProfile to { ProfileFragment() }) } 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 fd0461d8e..3a3316bd0 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -25,7 +25,7 @@ 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") @@ -42,14 +42,14 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEnrollmentDetailsEntity::class, CourseDateEntity::class, VideoProgressEntity::class, - CourseDatesResponseEntity::class, CourseProgressEntity::class, ], autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), AutoMigration(3, 4), - AutoMigration(4, DATABASE_VERSION), + AutoMigration(4, 5), + AutoMigration(5, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 68829efd2..b49a806e6 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -4,7 +4,6 @@ import androidx.room.TypeConverter import com.google.common.reflect.TypeToken import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb -import org.openedx.core.data.model.room.CourseDateDB import org.openedx.core.data.model.room.GradingPolicyDb import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb @@ -84,16 +83,4 @@ class CourseConverter { @TypeConverter fun toGradeRangeMap(value: String): Map = Gson().fromJson(value, object : TypeToken>() {}.type) - - @TypeConverter - fun fromListOfCourseDateDB(value: List): String { - val json = Gson().toJson(value) - return json.toString() - } - - @TypeConverter - fun toListOfCourseDateDB(value: String): List { - val type = genericType>() - return Gson().fromJson(value, type) - } } 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 d98dad502..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 @@ -15,9 +15,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,6 +27,7 @@ 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.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -32,6 +35,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable @@ -47,8 +51,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -58,7 +64,6 @@ 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.course.CourseViewMode import org.openedx.core.presentation.dates.CourseDateBlockSection import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState 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 index 010f2b895..e5e9e2444 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -134,7 +134,8 @@ fun DatesScreen( LazyColumn( modifier = contentWidth.fillMaxSize(), state = scrollState, - contentPadding = PaddingValues(bottom = 48.dp) + contentPadding = PaddingValues(bottom = 48.dp, top = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { uiState.dates.keys.forEach { sectionKey -> val dates = uiState.dates[sectionKey].orEmpty() @@ -144,7 +145,6 @@ fun DatesScreen( if (isHavePastRelatedDates) { item { ShiftDueDatesCard( - modifier = Modifier.padding(top = 12.dp), isButtonEnabled = !uiState.isShiftDueDatesPressed, onClick = { onAction(DatesViewActions.ShiftDueDate) From a0d9ea5dfbf859390941127bd20cfad8893b76aa Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Dec 2025 16:44:05 +0200 Subject: [PATCH 19/41] fix: assignment default color fix --- .../core/data/model/CourseProgressResponse.kt | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) 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..5b8540e36 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,25 @@ 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 +119,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( From 058b8477fdf33c1fbd300321c7c8bdf548f0b2d2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Dec 2025 16:59:26 +0200 Subject: [PATCH 20/41] fix: empty state icon --- .../org.openedx.app.room.AppDatabase/6.json | 1206 +++++++++++++++++ .../core/data/model/CourseProgressResponse.kt | 1 - .../dates/presentation/dates/DatesScreen.kt | 7 +- 3 files changed, 1211 insertions(+), 3 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/6.json 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/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index 5b8540e36..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 @@ -107,7 +107,6 @@ data class CourseProgressResponse( ) } - fun mapToRoomEntity() = GradingPolicyDb( assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), gradeRange = gradeRange ?: emptyMap(), 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 index e5e9e2444..89f38f13f 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -9,6 +9,7 @@ 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 @@ -22,6 +23,8 @@ 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 @@ -36,7 +39,6 @@ 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.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -271,7 +273,8 @@ private fun EmptyState( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), + modifier = Modifier.size(100.dp), + imageVector = Icons.Outlined.CalendarMonth, tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) From 4d0483e8ea2fa62829b222909b0a367ad43caa24 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Feb 2025 20:20:17 +0200 Subject: [PATCH 21/41] feat: dates tab UI --- app/build.gradle | 1 + .../main/java/org/openedx/app/AppAnalytics.kt | 4 + .../main/java/org/openedx/app/AppRouter.kt | 4 +- .../java/org/openedx/app/MainViewModel.kt | 4 + .../java/org/openedx/app/deeplink/HomeTab.kt | 1 + .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 8 +- app/src/main/res/menu/bottom_view_menu.xml | 0 app/src/main/res/values/strings.xml | 1 + .../core/presentation/ListItemPosition.kt | 16 + .../java/org/openedx/core/ui/ComposeCommon.kt | 79 ++-- .../learn/presentation/LearnFragment.kt | 6 +- dates/.gitignore | 1 + dates/build.gradle | 64 ++++ dates/consumer-rules.pro | 0 dates/proguard-rules.pro | 21 ++ dates/src/main/AndroidManifest.xml | 4 + .../openedx/dates/presentation/DatesRouter.kt | 8 + .../dates/presentation/dates/DatesFragment.kt | 355 ++++++++++++++++++ .../dates/presentation/dates/DatesUIState.kt | 6 + .../presentation/dates/DatesViewModel.kt | 58 +++ .../presentation/dates/DueDateCategory.kt | 31 ++ dates/src/main/res/layout/fragment_dates.xml | 6 + dates/src/main/res/values/strings.xml | 11 + settings.gradle | 1 + 25 files changed, 653 insertions(+), 39 deletions(-) create mode 100644 app/src/main/res/menu/bottom_view_menu.xml create mode 100644 core/src/main/java/org/openedx/core/presentation/ListItemPosition.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/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/DatesUIState.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt create mode 100644 dates/src/main/res/layout/fragment_dates.xml create mode 100644 dates/src/main/res/values/strings.xml 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/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/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 8723d6dbe..5ca4ec153 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -65,6 +65,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/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..f680b33e5 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,7 @@ 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.DatesRouter import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -131,6 +132,7 @@ val appModule = module { single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } single { get() } + single { get() } single { NetworkConnection(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..cf9026767 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,7 @@ 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.presentation.dates.DatesViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -237,7 +238,6 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } - single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -583,4 +583,10 @@ val screenModule = module { analytics = get() ) } + + viewModel { + DatesViewModel( + datesRouter = get(), + ) + } } diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml new file mode 100644 index 000000000..e69de29bb 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/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/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index eed214567..6243dae74 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 MainScreenTitle( + 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 { + MainScreenTitle( + label = "Title", + onSettingsClick = {} + ) + } +} + @Composable fun OpenEdXDropdownMenuItem( modifier: Modifier = Modifier, 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..54e4402ee 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.MainScreenTitle 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( + MainScreenTitle( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -240,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - MainToolbar( + MainScreenTitle( 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..481bb4348 --- /dev/null +++ b/dates/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file 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/presentation/DatesRouter.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt new file mode 100644 index 000000000..78f7472ba --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt @@ -0,0 +1,8 @@ +package org.openedx.dates.presentation + +import androidx.fragment.app.FragmentManager + +interface DatesRouter { + + fun navigateToSettings(fm: FragmentManager) +} 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..8cd818edf --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -0,0 +1,355 @@ +package org.openedx.dates.presentation.dates + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.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.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.CircularProgressIndicator +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.automirrored.filled.ArrowForwardIos +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +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 androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.presentation.ListItemPosition +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.MainScreenTitle +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.appTypography +import org.openedx.dates.R +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +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, + onAction = { action -> + when (action) { + DatesViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DatesViewActions.ReloadData -> { + + } + + is DatesViewActions.OpenEvent -> { + + } + } + } + ) + } + } + } + +} + +@Composable +private fun DatesScreen( + uiState: DatesUIState, + uiMessage: UIMessage?, + 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(), + ) + ) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainScreenTitle( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.dates), + onSettingsClick = { + onAction(DatesViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + if (uiState.isLoading) { + 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, + contentPadding = PaddingValues(bottom = 20.dp) + ) { + uiState.dates.keys.forEach { dueDateCategory -> + val dates = uiState.dates[dueDateCategory] ?: emptyList() + if (dates.isNotEmpty()) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, top = 20.dp), + text = stringResource(id = dueDateCategory.label), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + } + itemsIndexed(dates) { index, date -> + val itemPosition = ListItemPosition.detectPosition(index, dates) + DateItem( + date = date, + lineColor = dueDateCategory.color, + itemPosition = itemPosition, + onClick = { + onAction(DatesViewActions.OpenEvent()) + } + ) + } + } + } + } + } + } + } + ) +} + +@Composable +private fun DateItem( + modifier: Modifier = Modifier, + date: String, + lineColor: Color, + itemPosition: ListItemPosition, + onClick: () -> Unit, +) { + val boxCornerWidth = 8.dp + val boxCornerRadius = boxCornerWidth / 2 + val infoPadding = 8.dp + + val boxCornerShape = remember(itemPosition) { + when (itemPosition) { + ListItemPosition.SINGLE -> RoundedCornerShape(boxCornerRadius) + ListItemPosition.MIDDLE -> RectangleShape + ListItemPosition.FIRST -> RoundedCornerShape( + topStart = boxCornerRadius, + topEnd = boxCornerRadius + ) + + ListItemPosition.LAST -> RoundedCornerShape( + bottomStart = boxCornerRadius, + bottomEnd = boxCornerRadius + ) + } + } + + val infoPaddingModifier = remember(itemPosition) { + when (itemPosition) { + ListItemPosition.SINGLE -> Modifier + ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) + ListItemPosition.LAST -> Modifier.padding(top = infoPadding) + ListItemPosition.MIDDLE -> Modifier.padding(vertical = infoPadding) + } + } + + val arrowOffset = remember(itemPosition) { + when (itemPosition) { + ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) + ListItemPosition.LAST -> Modifier.padding(top = infoPadding) + else -> Modifier + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .clickable { + onClick() + }, + verticalAlignment = Alignment.CenterVertically + ) { + // Colored line box + Box( + modifier = Modifier + .width(boxCornerWidth) + .fillMaxHeight() + .background(color = lineColor, shape = boxCornerShape) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier + .weight(1f) + .then(infoPaddingModifier), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = date, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_assignment), + contentDescription = null, + tint = MaterialTheme.appColors.textDark, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = date, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark + ) + } + Text( + text = date, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = null, + tint = MaterialTheme.appColors.textDark, + modifier = arrowOffset.size(16.dp) + ) + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), + 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, + onAction = {} + ) + } +} 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..205c34978 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -0,0 +1,6 @@ +package org.openedx.dates.presentation.dates + +data class DatesUIState( + val isLoading: Boolean = true, + 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..0295cf015 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt @@ -0,0 +1,58 @@ +package org.openedx.dates.presentation.dates + +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.flow.update +import kotlinx.coroutines.launch +import org.openedx.dates.presentation.DatesRouter +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage + +class DatesViewModel( + private val datesRouter: DatesRouter, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(DatesUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + init { + fetchDates() + } + + private fun fetchDates() { + viewModelScope.launch { + _uiState.update { state -> + state.copy( + isLoading = false, + dates = mapOf( + DueDateCategory.PAST_DUE to listOf("Date1", "Date2", "Date3"), + DueDateCategory.TODAY to listOf("Date1"), + DueDateCategory.THIS_WEEK to listOf("Date1", "Date2"), + DueDateCategory.UPCOMING to listOf("Date1", "Date2", "Date3", "Date4"), + ) + ) + } + } + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + datesRouter.navigateToSettings(fragmentManager) + } +} + +interface DatesViewActions { + object OpenSettings : DatesViewActions + class OpenEvent() : DatesViewActions + object ReloadData : DatesViewActions +} diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt new file mode 100644 index 000000000..78ebda298 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt @@ -0,0 +1,31 @@ +package org.openedx.dates.presentation.dates + +import androidx.annotation.StringRes +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import org.openedx.core.ui.theme.appColors +import org.openedx.dates.R + +enum class DueDateCategory( + @StringRes + val label: Int, +) { + PAST_DUE(R.string.dates_category_past_due), + TODAY(R.string.dates_category_today), + THIS_WEEK(R.string.dates_category_this_week), + NEXT_WEEK(R.string.dates_category_next_week), + UPCOMING(R.string.dates_category_upcoming); + + val color: Color + @Composable + get() { + return when (this) { + PAST_DUE -> MaterialTheme.appColors.warning + TODAY -> MaterialTheme.appColors.info + THIS_WEEK -> MaterialTheme.appColors.textPrimaryVariant + NEXT_WEEK -> MaterialTheme.appColors.textFieldBorder + UPCOMING -> MaterialTheme.appColors.divider + } + } +} diff --git a/dates/src/main/res/layout/fragment_dates.xml b/dates/src/main/res/layout/fragment_dates.xml new file mode 100644 index 000000000..77d9ef65f --- /dev/null +++ b/dates/src/main/res/layout/fragment_dates.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml new file mode 100644 index 000000000..3187e2b97 --- /dev/null +++ b/dates/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + Dates + Past Due + Today + This Week + Next Week + Upcoming + No Dates + You currently have no active courses with scheduled events. Enroll in a course to view important dates and deadlines. + \ No newline at end of file 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 94908957ab37b31851a5b78ee42f4bbbaf8c2336 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 12:27:53 +0200 Subject: [PATCH 22/41] feat: added config flag for enabling/disabling dates screen --- app/src/main/java/org/openedx/app/MainViewModel.kt | 1 + core/src/main/java/org/openedx/core/config/Config.kt | 4 ++++ core/src/main/java/org/openedx/core/config/DatesConfig.kt | 8 ++++++++ default_config/dev/config.yaml | 3 +++ default_config/prod/config.yaml | 3 +++ default_config/stage/config.yaml | 3 +++ 6 files changed, 22 insertions(+) create mode 100644 core/src/main/java/org/openedx/core/config/DatesConfig.kt diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 5ca4ec153..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) { 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/DatesConfig.kt b/core/src/main/java/org/openedx/core/config/DatesConfig.kt new file mode 100644 index 000000000..0e48a5ed5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DatesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DatesConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 952e041de..e6ab8bce2 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +DATES: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index a7f265a45..c013c2a99 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +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 a7f265a45..c013c2a99 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +DATES: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false From 51bb67d874b8c3826867550c9698c20e6e135b6f Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 13:24:28 +0200 Subject: [PATCH 23/41] feat: pull to refresh --- .../dates/presentation/dates/DatesFragment.kt | 121 +++++++++++------- .../dates/presentation/dates/DatesUIState.kt | 1 + .../presentation/dates/DatesViewModel.kt | 12 +- 3 files changed, 84 insertions(+), 50 deletions(-) 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 index 8cd818edf..44ae32834 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -24,12 +24,16 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape 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.automirrored.filled.ArrowForwardIos +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -92,8 +96,8 @@ class DatesFragment : Fragment() { viewModel.onSettingsClick(requireActivity().supportFragmentManager) } - DatesViewActions.ReloadData -> { - + DatesViewActions.SwipeRefresh -> { + viewModel.refreshData() } is DatesViewActions.OpenEvent -> { @@ -108,6 +112,7 @@ class DatesFragment : Fragment() { } +@OptIn(ExperimentalMaterialApi::class) @Composable private fun DatesScreen( uiState: DatesUIState, @@ -124,10 +129,15 @@ private fun DatesScreen( ) ) } + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { onAction(DatesViewActions.SwipeRefresh) } + ) Scaffold( scaffoldState = scaffoldState, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), backgroundColor = MaterialTheme.appColors.background, topBar = { MainScreenTitle( @@ -141,58 +151,71 @@ private fun DatesScreen( ) }, content = { paddingValues -> - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - if (uiState.isLoading) { - 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, - contentPadding = PaddingValues(bottom = 20.dp) + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - uiState.dates.keys.forEach { dueDateCategory -> - val dates = uiState.dates[dueDateCategory] ?: emptyList() - if (dates.isNotEmpty()) { - item { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp, top = 20.dp), - text = stringResource(id = dueDateCategory.label), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.titleMedium, - ) - } - itemsIndexed(dates) { index, date -> - val itemPosition = ListItemPosition.detectPosition(index, dates) - DateItem( - date = date, - lineColor = dueDateCategory.color, - itemPosition = itemPosition, - onClick = { - onAction(DatesViewActions.OpenEvent()) - } - ) + 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, + contentPadding = PaddingValues(bottom = 20.dp) + ) { + uiState.dates.keys.forEach { dueDateCategory -> + val dates = uiState.dates[dueDateCategory] ?: emptyList() + if (dates.isNotEmpty()) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp, top = 20.dp), + text = stringResource(id = dueDateCategory.label), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + } + itemsIndexed(dates) { index, date -> + val itemPosition = + ListItemPosition.detectPosition(index, dates) + DateItem( + date = date, + lineColor = dueDateCategory.color, + itemPosition = itemPosition, + onClick = { + onAction(DatesViewActions.OpenEvent()) + } + ) + } } } } } } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + PullRefreshIndicator( + uiState.isRefreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) } } ) 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 index 205c34978..24fa97736 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -2,5 +2,6 @@ package org.openedx.dates.presentation.dates data class DatesUIState( val isLoading: Boolean = true, + val isRefreshing: 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 index 0295cf015..3e88e97c1 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 @@ -35,6 +35,7 @@ class DatesViewModel( _uiState.update { state -> state.copy( isLoading = false, + isRefreshing = false, dates = mapOf( DueDateCategory.PAST_DUE to listOf("Date1", "Date2", "Date3"), DueDateCategory.TODAY to listOf("Date1"), @@ -46,6 +47,15 @@ class DatesViewModel( } } + fun refreshData() { + _uiState.update { state -> + state.copy( + isRefreshing = true, + ) + } + fetchDates() + } + fun onSettingsClick(fragmentManager: FragmentManager) { datesRouter.navigateToSettings(fragmentManager) } @@ -54,5 +64,5 @@ class DatesViewModel( interface DatesViewActions { object OpenSettings : DatesViewActions class OpenEvent() : DatesViewActions - object ReloadData : DatesViewActions + object SwipeRefresh : DatesViewActions } From d9fc9bf44976c4dc287f905554fee03465cd541d Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 13:29:27 +0200 Subject: [PATCH 24/41] feat: offline mode dialog --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../dates/presentation/dates/DatesFragment.kt | 24 +++++++++++++++++++ .../presentation/dates/DatesViewModel.kt | 5 ++++ 3 files changed, 30 insertions(+) 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 cf9026767..9694e90e2 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -587,6 +587,7 @@ val screenModule = module { viewModel { DatesViewModel( datesRouter = get(), + networkConnection = get() ) } } 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 index 44ae32834..b3d9fc5ee 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -40,6 +40,8 @@ import androidx.compose.runtime.collectAsState 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.graphics.Color @@ -58,6 +60,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.presentation.ListItemPosition import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle +import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -90,6 +93,7 @@ class DatesFragment : Fragment() { DatesScreen( uiState = uiState, uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, onAction = { action -> when (action) { DatesViewActions.OpenSettings -> { @@ -117,6 +121,7 @@ class DatesFragment : Fragment() { private fun DatesScreen( uiState: DatesUIState, uiMessage: UIMessage?, + hasInternetConnection: Boolean, onAction: (DatesViewActions) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -133,6 +138,9 @@ private fun DatesScreen( refreshing = uiState.isRefreshing, onRefresh = { onAction(DatesViewActions.SwipeRefresh) } ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } Scaffold( scaffoldState = scaffoldState, @@ -216,6 +224,21 @@ private fun DatesScreen( pullRefreshState, Modifier.align(Alignment.TopCenter) ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DatesViewActions.SwipeRefresh) + } + ) + } } } ) @@ -372,6 +395,7 @@ private fun DatesScreenPreview() { DatesScreen( uiState = DatesUIState(isLoading = false), uiMessage = null, + hasInternetConnection = true, onAction = {} ) } 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 3e88e97c1..6296f65fe 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 @@ -10,12 +10,14 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.openedx.core.system.connection.NetworkConnection import org.openedx.dates.presentation.DatesRouter import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.UIMessage class DatesViewModel( private val datesRouter: DatesRouter, + private val networkConnection: NetworkConnection, ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -26,6 +28,9 @@ class DatesViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + init { fetchDates() } From ae40ff1859c3dbb66c7646ccc43fc1c8ba5bba4e Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 13 Mar 2025 20:06:37 +0200 Subject: [PATCH 25/41] feat: added dates request --- .../java/org/openedx/app/di/ScreenModule.kt | 17 +++- .../org/openedx/core/data/api/CourseApi.kt | 10 ++- .../core/data/model/CourseDatesResponse.kt | 53 ++++++++++++ .../core/domain/model/CourseDatesResponse.kt | 19 +++++ .../dates/data/repository/DatesRepository.kt | 15 ++++ .../domain/interactor/DatesInteractor.kt | 11 +++ .../dates/presentation/dates/DatesFragment.kt | 23 +++-- .../dates/presentation/dates/DatesUIState.kt | 4 +- .../presentation/dates/DatesViewModel.kt | 83 +++++++++++++++---- 9 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt create mode 100644 dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt create mode 100644 dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 9694e90e2..a325df575 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,8 @@ 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 @@ -584,10 +586,23 @@ val screenModule = module { ) } + factory { + DatesRepository( + api = get(), + preferencesManager = get() + ) + } + factory { + DatesInteractor( + repository = get() + ) + } viewModel { DatesViewModel( datesRouter = get(), - networkConnection = get() + networkConnection = get(), + resourceManager = get(), + datesInteractor = get() ) } } 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..bc2fdc643 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,6 +113,12 @@ 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 = 1 + ): CourseDatesResponse + @GET("/api/course_home/progress/{course_id}") suspend fun getCourseProgress( @Path("course_id") courseId: String, 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..6064970f8 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -0,0 +1,53 @@ +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("assignment_block_id") + val assignmentBlockId: String, + @SerializedName("due_date") + val dueDate: String?, + @SerializedName("assignment_title") + val assignmentTitle: String?, + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean?, + @SerializedName("course_name") + val courseName: String? +) { + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + assignmentBlockId = assignmentBlockId, + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + courseName = courseName ?: "" + ) + } +} + +data class CourseDatesResponse( + @SerializedName("count") + val count: Int, + @SerializedName("next") + val next: Int?, + @SerializedName("previous") + val previous: Int?, + @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/domain/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt new file mode 100644 index 000000000..a6bb9e8a1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -0,0 +1,19 @@ +package org.openedx.core.domain.model + +import java.util.Date + +data class CourseDatesResponse( + val count: Int, + val next: Int?, + val previous: Int?, + val results: List +) + +data class CourseDate( + val courseId: String, + val assignmentBlockId: String, + val dueDate: Date, + val assignmentTitle: String, + val learnerHasAccess: Boolean, + val courseName: String +) 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..0d9dfcd58 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -0,0 +1,15 @@ +package org.openedx.dates.data.repository + +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseDatesResponse + +class DatesRepository( + private val api: CourseApi, + private val preferencesManager: CorePreferences +) { + suspend fun getUserDates(): CourseDatesResponse { + val username = preferencesManager.user?.username ?: "" + return api.getUserDates(username).mapToDomain() + } +} 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..68139ad01 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -0,0 +1,11 @@ +package org.openedx.dates.domain.interactor + +import org.openedx.dates.data.repository.DatesRepository + +class DatesInteractor( + private val repository: DatesRepository +) { + + suspend fun getUserDates() = repository.getUserDates() + +} 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 index b3d9fc5ee..3aa7deb96 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -22,7 +22,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon @@ -47,6 +49,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -57,6 +60,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.domain.model.CourseDate import org.openedx.core.presentation.ListItemPosition import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle @@ -66,6 +70,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.core.utils.TimeUtils import org.openedx.dates.R import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize @@ -166,7 +171,8 @@ private fun DatesScreen( ) { if (uiState.isLoading) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize(), contentAlignment = Alignment.Center ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) @@ -203,7 +209,7 @@ private fun DatesScreen( val itemPosition = ListItemPosition.detectPosition(index, dates) DateItem( - date = date, + courseDate = date, lineColor = dueDateCategory.color, itemPosition = itemPosition, onClick = { @@ -247,11 +253,12 @@ private fun DatesScreen( @Composable private fun DateItem( modifier: Modifier = Modifier, - date: String, + courseDate: CourseDate, lineColor: Color, itemPosition: ListItemPosition, onClick: () -> Unit, ) { + val context = LocalContext.current val boxCornerWidth = 8.dp val boxCornerRadius = boxCornerWidth / 2 val infoPadding = 8.dp @@ -313,7 +320,7 @@ private fun DateItem( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = date, + text = TimeUtils.formatToString(context, courseDate.dueDate, true), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textDark ) @@ -326,13 +333,13 @@ private fun DateItem( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = date, + text = courseDate.assignmentTitle, style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark ) } Text( - text = date, + text = courseDate.courseName, style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textPrimaryVariant ) @@ -352,7 +359,9 @@ private fun EmptyState( modifier: Modifier = Modifier ) { Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), contentAlignment = Alignment.Center ) { Column( 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 index 24fa97736..543d9f9fe 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -1,7 +1,9 @@ package org.openedx.dates.presentation.dates +import org.openedx.core.domain.model.CourseDate + data class DatesUIState( val isLoading: Boolean = true, val isRefreshing: Boolean = false, - val dates: Map> = emptyMap() + 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 index 6296f65fe..8b5875eea 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 @@ -10,14 +10,26 @@ 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.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDatesResponse import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.isToday +import org.openedx.core.utils.toCalendar +import org.openedx.dates.domain.interactor.DatesInteractor 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 ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -32,38 +44,73 @@ class DatesViewModel( get() = networkConnection.isOnline() init { - fetchDates() + fetchDates(false) } - private fun fetchDates() { + private fun fetchDates(refresh: Boolean) { viewModelScope.launch { - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false, - dates = mapOf( - DueDateCategory.PAST_DUE to listOf("Date1", "Date2", "Date3"), - DueDateCategory.TODAY to listOf("Date1"), - DueDateCategory.THIS_WEEK to listOf("Date1", "Date2"), - DueDateCategory.UPCOMING to listOf("Date1", "Date2", "Date3", "Date4"), + try { + _uiState.update { state -> + state.copy( + isLoading = !refresh, + isRefreshing = refresh, ) - ) + } + val courseDatesResponse = datesInteractor.getUserDates() + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false, + dates = groupCourseDates(courseDatesResponse) + ) + } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection)) + ) + } else { + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + ) + } } } } fun refreshData() { - _uiState.update { state -> - state.copy( - isRefreshing = true, - ) - } - fetchDates() + fetchDates(true) } fun onSettingsClick(fragmentManager: FragmentManager) { datesRouter.navigateToSettings(fragmentManager) } + + private fun groupCourseDates(response: CourseDatesResponse): Map> { + val now = Date() + val calNow = Calendar.getInstance().apply { time = now } + val grouped = response.results.groupBy { courseDate -> + val dueDate = courseDate.dueDate + if (dueDate.before(now)) { + DueDateCategory.PAST_DUE + } else if (dueDate.isToday()) { + DueDateCategory.TODAY + } else { + val calDue = dueDate.toCalendar() + val weekNow = calNow.get(Calendar.WEEK_OF_YEAR) + val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) + val yearNow = calNow.get(Calendar.YEAR) + val yearDue = calDue.get(Calendar.YEAR) + if (weekNow == weekDue && yearNow == yearDue) { + DueDateCategory.THIS_WEEK + } else { + DueDateCategory.UPCOMING + } + } + } + + return grouped + } } interface DatesViewActions { From ef9de7eb85d2bdc7b25e9175d31771a0ae2b334b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Mar 2025 20:42:36 +0200 Subject: [PATCH 26/41] feat: paging and caching --- .../main/java/org/openedx/app/di/AppModule.kt | 5 ++ .../java/org/openedx/app/di/ScreenModule.kt | 3 +- .../java/org/openedx/app/room/AppDatabase.kt | 4 ++ .../org/openedx/core/data/api/CourseApi.kt | 2 +- .../dates/data/repository/DatesRepository.kt | 14 +++- .../dates/data/storage/CourseDateEntity.kt | 53 +++++++++++++++ .../openedx/dates/data/storage/DatesDao.kt | 19 ++++++ .../domain/interactor/DatesInteractor.kt | 4 +- .../dates/presentation/dates/DatesFragment.kt | 40 +++++++++-- .../dates/presentation/dates/DatesUIState.kt | 1 + .../presentation/dates/DatesViewModel.kt | 68 ++++++++++++++++--- 11 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt create mode 100644 dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index f680b33e5..7d46f43b4 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -179,6 +179,11 @@ val appModule = module { room.calendarDao() } + single { + val room = get() + room.datesDao() + } + single { FileDownloader() } 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 a325df575..fef2e9acc 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -589,7 +589,8 @@ val screenModule = module { factory { DatesRepository( api = get(), - preferencesManager = get() + dao = get(), + preferencesManager = get(), ) } factory { 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..2ee6f7eec 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -19,6 +19,8 @@ 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.CourseDateEntity +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 @@ -38,6 +40,7 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarStateEntity::class, DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class, + CourseDateEntity::class, VideoProgressEntity::class, CourseProgressEntity::class, ], @@ -55,5 +58,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/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index bc2fdc643..8c075ecff 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 @@ -116,7 +116,7 @@ interface CourseApi { @GET("/api/mobile/v1/course_dates/{username}/") suspend fun getUserDates( @Path("username") username: String, - @Query("page") page: Int = 1 + @Query("page") page: Int ): CourseDatesResponse @GET("/api/course_home/progress/{course_id}") 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 index 0d9dfcd58..1e3c2aebf 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -2,14 +2,24 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi 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.CourseDateEntity +import org.openedx.dates.data.storage.DatesDao class DatesRepository( private val api: CourseApi, + private val dao: DatesDao, private val preferencesManager: CorePreferences ) { - suspend fun getUserDates(): CourseDatesResponse { + suspend fun getUserDates(page: Int): CourseDatesResponse { val username = preferencesManager.user?.username ?: "" - return api.getUserDates(username).mapToDomain() + val response = api.getUserDates(username, page) + dao.insertCourseDateEntities(response.results.map { CourseDateEntity.createFrom(it) }) + return response.mapToDomain() + } + + suspend fun getUserDatesFromCache(): List { + return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } } } diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt new file mode 100644 index 000000000..558da6870 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt @@ -0,0 +1,53 @@ +package org.openedx.dates.data.storage + +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_date_table") +data class CourseDateEntity( + @PrimaryKey + @ColumnInfo("assignmentBlockId") + val assignmentBlockId: String, + @ColumnInfo("courseId") + val courseId: String, + @ColumnInfo("dueDate") + val dueDate: String?, + @ColumnInfo("assignmentTitle") + val assignmentTitle: String?, + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean?, + @ColumnInfo("courseName") + val courseName: String?, +) { + + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + assignmentBlockId = assignmentBlockId, + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + courseName = courseName ?: "" + ) + } + + companion object { + fun createFrom(courseDate: CourseDate): CourseDateEntity { + with(courseDate) { + return CourseDateEntity( + courseId = courseId, + assignmentBlockId = assignmentBlockId, + dueDate = dueDate, + assignmentTitle = assignmentTitle, + learnerHasAccess = learnerHasAccess, + courseName = courseName + ) + } + } + } +} 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..50b570112 --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -0,0 +1,19 @@ +package org.openedx.dates.data.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface DatesDao { + + @Query("SELECT * FROM course_date_table") + suspend fun getCourseDateEntities(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseDateEntities(courseDate: List) + + @Query("DELETE FROM course_date_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 index 68139ad01..0fd1d2b77 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -6,6 +6,8 @@ class DatesInteractor( private val repository: DatesRepository ) { - suspend fun getUserDates() = repository.getUserDates() + suspend fun getUserDates(page: Int) = repository.getUserDates(page) + + suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() } 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 index 3aa7deb96..d2a4cf93a 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -40,6 +41,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -66,12 +68,14 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.shouldLoadMore 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.core.utils.TimeUtils import org.openedx.dates.R +import org.openedx.dates.presentation.dates.DatesFragment.Companion.LOAD_MORE_THRESHOLD import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue @@ -109,8 +113,15 @@ class DatesFragment : Fragment() { viewModel.refreshData() } - is DatesViewActions.OpenEvent -> { + DatesViewActions.LoadMore -> { + viewModel.fetchMore() + } + is DatesViewActions.OpenEvent -> { + viewModel.navigateToCourseOutline( + requireActivity().supportFragmentManager, + action.date + ) } } } @@ -119,6 +130,10 @@ class DatesFragment : Fragment() { } } + companion object { + const val LOAD_MORE_THRESHOLD = 4 + } + } @OptIn(ExperimentalMaterialApi::class) @@ -146,6 +161,10 @@ private fun DatesScreen( var isInternetConnectionShown by rememberSaveable { mutableStateOf(false) } + val scrollState = rememberLazyListState() + val firstVisibleIndex = remember { + mutableIntStateOf(scrollState.firstVisibleItemIndex) + } Scaffold( scaffoldState = scaffoldState, @@ -169,7 +188,7 @@ private fun DatesScreen( .fillMaxSize() .pullRefresh(pullRefreshState) ) { - if (uiState.isLoading) { + if (uiState.isLoading && uiState.dates.isEmpty()) { Box( modifier = Modifier .fillMaxSize(), @@ -189,7 +208,8 @@ private fun DatesScreen( contentAlignment = Alignment.TopCenter ) { LazyColumn( - modifier = contentWidth, + modifier = contentWidth.fillMaxSize(), + state = scrollState, contentPadding = PaddingValues(bottom = 20.dp) ) { uiState.dates.keys.forEach { dueDateCategory -> @@ -213,12 +233,24 @@ private fun DatesScreen( lineColor = dueDateCategory.color, itemPosition = itemPosition, onClick = { - onAction(DatesViewActions.OpenEvent()) + onAction(DatesViewActions.OpenEvent(date)) } ) } } } + if (uiState.isLoading) { + item { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center), + color = MaterialTheme.appColors.primary + ) + } + } + } + if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + onAction(DatesViewActions.LoadMore) } } } 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 index 543d9f9fe..ba1dfed39 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -5,5 +5,6 @@ import org.openedx.core.domain.model.CourseDate data class DatesUIState( val isLoading: Boolean = true, 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 index 8b5875eea..6691717ad 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 @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.domain.model.CourseDate -import org.openedx.core.domain.model.CourseDatesResponse +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 @@ -43,6 +43,8 @@ class DatesViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + private var page = 1 + init { fetchDates(false) } @@ -56,13 +58,36 @@ class DatesViewModel( isRefreshing = refresh, ) } - val courseDatesResponse = datesInteractor.getUserDates() - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false, - dates = groupCourseDates(courseDatesResponse) - ) + if (refresh) { + page = 1 + } + val response = if (networkConnection.isOnline() || page > 1) { + datesInteractor.getUserDates(page) + } else { + null + } + if (response != null) { + if (response.next.isNotNull() && page != response.count) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + } + _uiState.update { state -> + state.copy( + dates = state.dates + groupCourseDates(response.results) + ) + } + } else { + val cachedList = datesInteractor.getUserDatesFromCache() + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList) + ) + } } } catch (e: Exception) { if (e.isInternetError()) { @@ -74,10 +99,23 @@ class DatesViewModel( UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) ) } + } finally { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false, + ) + } } } } + fun fetchMore() { + if (!_uiState.value.isLoading && page != -1) { + fetchDates(false) + } + } + fun refreshData() { fetchDates(true) } @@ -86,10 +124,17 @@ class DatesViewModel( datesRouter.navigateToSettings(fragmentManager) } - private fun groupCourseDates(response: CourseDatesResponse): Map> { + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + courseDate: CourseDate, + ) { + + } + + private fun groupCourseDates(dates: List): Map> { val now = Date() val calNow = Calendar.getInstance().apply { time = now } - val grouped = response.results.groupBy { courseDate -> + val grouped = dates.groupBy { courseDate -> val dueDate = courseDate.dueDate if (dueDate.before(now)) { DueDateCategory.PAST_DUE @@ -115,6 +160,7 @@ class DatesViewModel( interface DatesViewActions { object OpenSettings : DatesViewActions - class OpenEvent() : DatesViewActions + class OpenEvent(val date: CourseDate) : DatesViewActions + object LoadMore : DatesViewActions object SwipeRefresh : DatesViewActions } From 65a666752ff2ea85d21d171c2052021b89ffa5b4 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 18 Mar 2025 13:09:46 +0200 Subject: [PATCH 27/41] feat: navigating to block --- .../org/openedx/app/deeplink/DeepLinkRouter.kt | 13 +++++++++---- .../presentation/AllEnrolledCoursesViewModel.kt | 4 +++- .../presentation/DashboardListFragment.kt | 2 ++ .../dashboard/presentation/DashboardRouter.kt | 4 ++-- .../openedx/dates/presentation/DatesRouter.kt | 8 ++++++++ .../dates/presentation/dates/DatesFragment.kt | 16 ++++++++++------ .../dates/presentation/dates/DatesViewModel.kt | 8 +++++++- .../dates/presentation/dates/DueDateCategory.kt | 8 ++++---- 8 files changed, 45 insertions(+), 18 deletions(-) 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/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/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt index 78f7472ba..01e06ed38 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/DatesRouter.kt @@ -5,4 +5,12 @@ 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 index d2a4cf93a..1036ac96a 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -239,13 +239,17 @@ private fun DatesScreen( } } } - if (uiState.isLoading) { + if (uiState.canLoadMore) { item { - CircularProgressIndicator( - modifier = Modifier - .align(Alignment.Center), - color = MaterialTheme.appColors.primary - ) + Box( + Modifier + .fillMaxWidth() + .height(42.dp) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } } } } 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 6691717ad..701eafe62 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 @@ -128,7 +128,13 @@ class DatesViewModel( fragmentManager: FragmentManager, courseDate: CourseDate, ) { - + datesRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = courseDate.courseId, + courseTitle = courseDate.courseName, + openTab = "", + resumeBlockId = courseDate.assignmentBlockId + ) } private fun groupCourseDates(dates: List): Map> { diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt index 78ebda298..4cd305a56 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt @@ -11,11 +11,11 @@ enum class DueDateCategory( @StringRes val label: Int, ) { - PAST_DUE(R.string.dates_category_past_due), - TODAY(R.string.dates_category_today), - THIS_WEEK(R.string.dates_category_this_week), + UPCOMING(R.string.dates_category_upcoming), NEXT_WEEK(R.string.dates_category_next_week), - UPCOMING(R.string.dates_category_upcoming); + THIS_WEEK(R.string.dates_category_this_week), + TODAY(R.string.dates_category_today), + PAST_DUE(R.string.dates_category_past_due); val color: Color @Composable From 2a61eb97830bfae5c3b82b65b5338c540b60fbd0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 17:23:05 +0200 Subject: [PATCH 28/41] feat: reuse dates UI from CourseDatesScreen --- .../java/org/openedx/app/di/ScreenModule.kt | 3 +- .../openedx/core/domain/model/DatesSection.kt | 20 +- .../core/presentation/dates/DatesUI.kt | 321 ++++++++++++++++++ .../presentation/dates/CourseDatesScreen.kt | 8 +- .../dates/presentation/dates/DatesFragment.kt | 158 +-------- .../dates/presentation/dates/DatesUIState.kt | 3 +- .../presentation/dates/DatesViewModel.kt | 17 +- .../presentation/dates/DueDateCategory.kt | 31 -- dates/src/main/res/values/strings.xml | 5 - default_config/dev/config.yaml | 2 +- default_config/prod/config.yaml | 2 +- default_config/stage/config.yaml | 2 +- 12 files changed, 375 insertions(+), 197 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt delete mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index fef2e9acc..c0d27283e 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -603,7 +603,8 @@ val screenModule = module { datesRouter = get(), networkConnection = get(), resourceManager = get(), - datesInteractor = get() + datesInteractor = get(), + corePreferences = get() ) } } 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/dates/DatesUI.kt b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt new file mode 100644 index 000000000..1c7b10df9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt @@ -0,0 +1,321 @@ +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 + +// --- Generic composables for reusability --- + +@Composable +private fun CourseDateBlockSectionGeneric( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + 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, useRelativeDates = useRelativeDates) { + 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, useRelativeDates = useRelativeDates) { + 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)) + } + 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.assignmentBlockId.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.assignmentBlockId.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, + style = MaterialTheme.appTypography.labelMedium, + ) + } +} 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..d98dad502 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 @@ -15,11 +15,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -27,7 +25,6 @@ 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.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -35,7 +32,6 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable @@ -51,10 +47,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -64,6 +58,8 @@ 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.course.CourseViewMode +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/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt index 1036ac96a..d85d835b0 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -3,28 +3,19 @@ package org.openedx.dates.presentation.dates import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.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.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ExperimentalMaterialApi @@ -32,8 +23,6 @@ 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.automirrored.filled.ArrowForwardIos import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -48,10 +37,7 @@ 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.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -62,8 +48,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.domain.model.CourseDate -import org.openedx.core.presentation.ListItemPosition +import org.openedx.core.presentation.dates.CourseDateBlockSection import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog @@ -73,9 +58,9 @@ 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.core.utils.TimeUtils 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 @@ -103,6 +88,7 @@ class DatesFragment : Fragment() { uiState = uiState, uiMessage = uiMessage, hasInternetConnection = viewModel.hasInternetConnection, + useRelativeDates = viewModel.useRelativeDates, onAction = { action -> when (action) { DatesViewActions.OpenSettings -> { @@ -142,6 +128,7 @@ private fun DatesScreen( uiState: DatesUIState, uiMessage: UIMessage?, hasInternetConnection: Boolean, + useRelativeDates: Boolean, onAction: (DatesViewActions) -> Unit, ) { val scaffoldState = rememberScaffoldState() @@ -212,29 +199,17 @@ private fun DatesScreen( state = scrollState, contentPadding = PaddingValues(bottom = 20.dp) ) { - uiState.dates.keys.forEach { dueDateCategory -> - val dates = uiState.dates[dueDateCategory] ?: emptyList() - if (dates.isNotEmpty()) { + uiState.dates.keys.forEach { sectionKey -> + val dates = uiState.dates[sectionKey] ?: emptyList() + dates.isNotEmptyThenLet { sectionDates -> item { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp, top = 20.dp), - text = stringResource(id = dueDateCategory.label), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.titleMedium, - ) - } - itemsIndexed(dates) { index, date -> - val itemPosition = - ListItemPosition.detectPosition(index, dates) - DateItem( - courseDate = date, - lineColor = dueDateCategory.color, - itemPosition = itemPosition, - onClick = { - onAction(DatesViewActions.OpenEvent(date)) - } + CourseDateBlockSection( + sectionKey = sectionKey, + sectionDates = sectionDates, + onItemClick = { + onAction(DatesViewActions.OpenEvent(it)) + }, + useRelativeDates = useRelativeDates ) } } @@ -286,110 +261,6 @@ private fun DatesScreen( ) } -@Composable -private fun DateItem( - modifier: Modifier = Modifier, - courseDate: CourseDate, - lineColor: Color, - itemPosition: ListItemPosition, - onClick: () -> Unit, -) { - val context = LocalContext.current - val boxCornerWidth = 8.dp - val boxCornerRadius = boxCornerWidth / 2 - val infoPadding = 8.dp - - val boxCornerShape = remember(itemPosition) { - when (itemPosition) { - ListItemPosition.SINGLE -> RoundedCornerShape(boxCornerRadius) - ListItemPosition.MIDDLE -> RectangleShape - ListItemPosition.FIRST -> RoundedCornerShape( - topStart = boxCornerRadius, - topEnd = boxCornerRadius - ) - - ListItemPosition.LAST -> RoundedCornerShape( - bottomStart = boxCornerRadius, - bottomEnd = boxCornerRadius - ) - } - } - - val infoPaddingModifier = remember(itemPosition) { - when (itemPosition) { - ListItemPosition.SINGLE -> Modifier - ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) - ListItemPosition.LAST -> Modifier.padding(top = infoPadding) - ListItemPosition.MIDDLE -> Modifier.padding(vertical = infoPadding) - } - } - - val arrowOffset = remember(itemPosition) { - when (itemPosition) { - ListItemPosition.FIRST -> Modifier.padding(bottom = infoPadding) - ListItemPosition.LAST -> Modifier.padding(top = infoPadding) - else -> Modifier - } - } - - Row( - modifier = modifier - .fillMaxWidth() - .height(IntrinsicSize.Min) - .clickable { - onClick() - }, - verticalAlignment = Alignment.CenterVertically - ) { - // Colored line box - Box( - modifier = Modifier - .width(boxCornerWidth) - .fillMaxHeight() - .background(color = lineColor, shape = boxCornerShape) - ) - Spacer(modifier = Modifier.width(12.dp)) - Column( - modifier = Modifier - .weight(1f) - .then(infoPaddingModifier), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = TimeUtils.formatToString(context, courseDate.dueDate, true), - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_assignment), - contentDescription = null, - tint = MaterialTheme.appColors.textDark, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = courseDate.assignmentTitle, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textDark - ) - } - Text( - text = courseDate.courseName, - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } - - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, - contentDescription = null, - tint = MaterialTheme.appColors.textDark, - modifier = arrowOffset.size(16.dp) - ) - } -} - @Composable private fun EmptyState( modifier: Modifier = Modifier @@ -441,6 +312,7 @@ private fun DatesScreenPreview() { uiState = DatesUIState(isLoading = false), uiMessage = null, hasInternetConnection = true, + useRelativeDates = true, onAction = {} ) } 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 index ba1dfed39..4b5febddb 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -1,10 +1,11 @@ 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 isRefreshing: Boolean = false, val canLoadMore: Boolean = false, - val dates: Map> = emptyMap() + 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 index 701eafe62..e26465ef0 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 @@ -11,7 +11,9 @@ 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.DatesSection import org.openedx.core.extension.isNotNull import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.utils.isToday @@ -29,7 +31,8 @@ class DatesViewModel( private val datesRouter: DatesRouter, private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, - private val datesInteractor: DatesInteractor + private val datesInteractor: DatesInteractor, + private val corePreferences: CorePreferences, ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -43,6 +46,8 @@ class DatesViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + var useRelativeDates = corePreferences.isRelativeDatesEnabled + private var page = 1 init { @@ -137,15 +142,15 @@ class DatesViewModel( ) } - private fun groupCourseDates(dates: List): Map> { + private fun groupCourseDates(dates: List): Map> { val now = Date() val calNow = Calendar.getInstance().apply { time = now } val grouped = dates.groupBy { courseDate -> val dueDate = courseDate.dueDate if (dueDate.before(now)) { - DueDateCategory.PAST_DUE + DatesSection.PAST_DUE } else if (dueDate.isToday()) { - DueDateCategory.TODAY + DatesSection.TODAY } else { val calDue = dueDate.toCalendar() val weekNow = calNow.get(Calendar.WEEK_OF_YEAR) @@ -153,9 +158,9 @@ class DatesViewModel( val yearNow = calNow.get(Calendar.YEAR) val yearDue = calDue.get(Calendar.YEAR) if (weekNow == weekDue && yearNow == yearDue) { - DueDateCategory.THIS_WEEK + DatesSection.THIS_WEEK } else { - DueDateCategory.UPCOMING + DatesSection.UPCOMING } } } diff --git a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt deleted file mode 100644 index 4cd305a56..000000000 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DueDateCategory.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.openedx.dates.presentation.dates - -import androidx.annotation.StringRes -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import org.openedx.core.ui.theme.appColors -import org.openedx.dates.R - -enum class DueDateCategory( - @StringRes - val label: Int, -) { - UPCOMING(R.string.dates_category_upcoming), - NEXT_WEEK(R.string.dates_category_next_week), - THIS_WEEK(R.string.dates_category_this_week), - TODAY(R.string.dates_category_today), - PAST_DUE(R.string.dates_category_past_due); - - val color: Color - @Composable - get() { - return when (this) { - PAST_DUE -> MaterialTheme.appColors.warning - TODAY -> MaterialTheme.appColors.info - THIS_WEEK -> MaterialTheme.appColors.textPrimaryVariant - NEXT_WEEK -> MaterialTheme.appColors.textFieldBorder - UPCOMING -> MaterialTheme.appColors.divider - } - } -} diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml index 3187e2b97..87df86589 100644 --- a/dates/src/main/res/values/strings.xml +++ b/dates/src/main/res/values/strings.xml @@ -1,11 +1,6 @@ Dates - Past Due - Today - This Week - Next Week - Upcoming No Dates You currently have no active courses with scheduled events. Enroll in a course to view important dates and deadlines. \ No newline at end of file diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index e6ab8bce2..ac06ef7ba 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DATES: +APP_LEVEL_DATES: ENABLED: true FIREBASE: diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index c013c2a99..54b5e3e2a 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DATES: +APP_LEVEL_DATES: ENABLED: true FIREBASE: diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index c013c2a99..54b5e3e2a 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DATES: +APP_LEVEL_DATES: ENABLED: true FIREBASE: From e692b980063a085f125bd7bd7b4add67f7daa04d Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 17:54:38 +0200 Subject: [PATCH 29/41] feat: shift due date card --- .../core/data/model/CourseDatesResponse.kt | 5 +- .../core/domain/model/CourseDatesResponse.kt | 1 + .../dates/data/storage/CourseDateEntity.kt | 4 + .../dates/presentation/dates/DatesFragment.kt | 73 ++++++++++++++++++- .../presentation/dates/DatesViewModel.kt | 7 +- dates/src/main/res/values/strings.xml | 3 + 6 files changed, 89 insertions(+), 4 deletions(-) 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 index 6064970f8..b39028bd5 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -16,6 +16,8 @@ data class CourseDate( val assignmentTitle: String?, @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, + @SerializedName("relative") + val relative: Boolean?, @SerializedName("course_name") val courseName: String? ) { @@ -27,7 +29,8 @@ data class CourseDate( dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, - courseName = courseName ?: "" + courseName = courseName ?: "", + relative = relative ?: false ) } } 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 index a6bb9e8a1..2248e3a21 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -15,5 +15,6 @@ data class CourseDate( val dueDate: Date, val assignmentTitle: String, val learnerHasAccess: Boolean, + val relative: Boolean, val courseName: String ) diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt index 558da6870..ec751d1ee 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt @@ -20,6 +20,8 @@ data class CourseDateEntity( val assignmentTitle: String?, @ColumnInfo("learnerHasAccess") val learnerHasAccess: Boolean?, + @ColumnInfo("relative") + val relative: Boolean?, @ColumnInfo("courseName") val courseName: String?, ) { @@ -32,6 +34,7 @@ data class CourseDateEntity( dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, + relative = relative ?: false, courseName = courseName ?: "" ) } @@ -45,6 +48,7 @@ data class CourseDateEntity( dueDate = dueDate, assignmentTitle = assignmentTitle, learnerHasAccess = learnerHasAccess, + relative = relative, courseName = courseName ) } 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 index d85d835b0..15be6680a 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -3,6 +3,7 @@ package org.openedx.dates.presentation.dates import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -17,6 +18,7 @@ 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 @@ -48,15 +50,18 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel +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.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.shouldLoadMore 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 @@ -103,6 +108,10 @@ class DatesFragment : Fragment() { viewModel.fetchMore() } + DatesViewActions.ShiftDueDate -> { + viewModel.shiftDueDate() + } + is DatesViewActions.OpenEvent -> { viewModel.navigateToCourseOutline( requireActivity().supportFragmentManager, @@ -197,11 +206,23 @@ private fun DatesScreen( LazyColumn( modifier = contentWidth.fillMaxSize(), state = scrollState, - contentPadding = PaddingValues(bottom = 20.dp) + contentPadding = PaddingValues(bottom = 48.dp) ) { uiState.dates.keys.forEach { sectionKey -> - val dates = uiState.dates[sectionKey] ?: emptyList() + val dates = uiState.dates[sectionKey].orEmpty() dates.isNotEmptyThenLet { sectionDates -> + val isHavePastRelatedDates = + sectionKey == DatesSection.PAST_DUE && dates.any { it.relative } + if (isHavePastRelatedDates) { + item { + ShiftDueDatesCard( + modifier = Modifier.padding(top = 12.dp), + onClick = { + onAction(DatesViewActions.ShiftDueDate) + } + ) + } + } item { CourseDateBlockSection( sectionKey = sectionKey, @@ -261,6 +282,44 @@ private fun DatesScreen( ) } +@Composable +private fun ShiftDueDatesCard( + modifier: Modifier = Modifier, + 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), + onClick = onClick + ) + } + } +} + @Composable private fun EmptyState( modifier: Modifier = Modifier @@ -317,3 +376,13 @@ private fun DatesScreenPreview() { ) } } + +@Preview +@Composable +private fun ShiftDueDatesCardPreview() { + OpenEdXTheme { + ShiftDueDatesCard( + onClick = {} + ) + } +} 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 e26465ef0..14f566945 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 @@ -32,7 +32,7 @@ class DatesViewModel( private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val datesInteractor: DatesInteractor, - private val corePreferences: CorePreferences, + corePreferences: CorePreferences, ) : BaseViewModel() { private val _uiState = MutableStateFlow(DatesUIState()) @@ -115,6 +115,10 @@ class DatesViewModel( } } + fun shiftDueDate() { +//TODO + } + fun fetchMore() { if (!_uiState.value.isLoading && page != -1) { fetchDates(false) @@ -174,4 +178,5 @@ interface 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 index 87df86589..1a2c6f989 100644 --- a/dates/src/main/res/values/strings.xml +++ b/dates/src/main/res/values/strings.xml @@ -3,4 +3,7 @@ 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 past due assignments without losing any progress. + Shift Due Dates \ No newline at end of file From 1d315699f3715fd16cfef8e15abc010164756193 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 18:11:51 +0200 Subject: [PATCH 30/41] feat: shift due date request --- .../org/openedx/core/data/api/CourseApi.kt | 6 ++++ .../core/data/model/ShiftDueDatesBody.kt | 7 ++++ .../dates/data/repository/DatesRepository.kt | 4 +++ .../domain/interactor/DatesInteractor.kt | 2 ++ .../dates/presentation/dates/DatesFragment.kt | 4 +++ .../dates/presentation/dates/DatesUIState.kt | 1 + .../presentation/dates/DatesViewModel.kt | 32 ++++++++++++++++++- 7 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 8c075ecff..c701c13e1 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 @@ -15,6 +15,7 @@ import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates +import org.openedx.core.data.model.ShiftDueDatesBody import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -123,4 +124,9 @@ interface CourseApi { suspend fun getCourseProgress( @Path("course_id") courseId: String, ): CourseProgressResponse + + @POST("/api/course_experience/v1/reset_multiple_course_deadlines/") + suspend fun shiftDueDate( + @Body shiftDueDatesBody: ShiftDueDatesBody + ) } diff --git a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt new file mode 100644 index 000000000..df6749f24 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt @@ -0,0 +1,7 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName + +data class ShiftDueDatesBody( + @SerializedName("course_keys") val courseKeys: List +) \ 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 index 1e3c2aebf..fcfad5460 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,6 +1,7 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.ShiftDueDatesBody import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse @@ -22,4 +23,7 @@ class DatesRepository( suspend fun getUserDatesFromCache(): List { return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } } + + suspend fun shiftDueDate(courseIds: List) = + api.shiftDueDate(ShiftDueDatesBody(courseIds)) } 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 index 0fd1d2b77..736819c0b 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -10,4 +10,6 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() + suspend fun shiftDueDate(courseIds: List) = repository.shiftDueDate(courseIds) + } 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 index 15be6680a..761b697c1 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -217,6 +217,7 @@ private fun DatesScreen( item { ShiftDueDatesCard( modifier = Modifier.padding(top = 12.dp), + isButtonEnabled = !uiState.isShiftDueDatesPressed, onClick = { onAction(DatesViewActions.ShiftDueDate) } @@ -285,6 +286,7 @@ private fun DatesScreen( @Composable private fun ShiftDueDatesCard( modifier: Modifier = Modifier, + isButtonEnabled: Boolean, onClick: () -> Unit ) { Card( @@ -314,6 +316,7 @@ private fun ShiftDueDatesCard( ) OpenEdXButton( text = stringResource(id = R.string.dates_shift_due_date), + enabled = isButtonEnabled, onClick = onClick ) } @@ -382,6 +385,7 @@ private fun DatesScreenPreview() { 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 index 4b5febddb..0dd6464b2 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesUIState.kt @@ -5,6 +5,7 @@ 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 index 14f566945..43bb9565c 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 @@ -116,7 +116,37 @@ class DatesViewModel( } fun shiftDueDate() { -//TODO + viewModelScope.launch { + try { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = true, + ) + } + val pastDueDates = _uiState.value.dates[DatesSection.PAST_DUE] ?: emptyList() + val courseIds = pastDueDates + .filter { it.relative } + .map { it.courseId } + datesInteractor.shiftDueDate(courseIds) + refreshData() + } 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)) + ) + } + } finally { + _uiState.update { state -> + state.copy( + isShiftDueDatesPressed = false, + ) + } + } + } } fun fetchMore() { From 60809a126ce4dcd54789281240348e9ba865db3a Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 19:52:45 +0200 Subject: [PATCH 31/41] feat: junit tests --- .../org/openedx/dates/DatesViewModelTest.kt | 354 ++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt 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..b477a5074 --- /dev/null +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -0,0 +1,354 @@ +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.dates.domain.interactor.DatesInteractor +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 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 + } + + @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 = 2, + previous = 1, + 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, + 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, + corePreferences + ) + advanceUntilIdle() + + // When offline, getUserDates is not called. + coVerify(exactly = 0) { datesInteractor.getUserDates(any()) } + coVerify(exactly = 1) { datesInteractor.getUserDatesFromCache() } + assertFalse(viewModel.uiState.value.isLoading) + // Expect no further pages to load. + 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, + 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, + 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 `shiftDueDate 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, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftDueDate() + advanceUntilIdle() + + coVerify { datesInteractor.shiftDueDate(listOf("course-123")) } + // isShiftDueDatesPressed should be reset to false after processing. + assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) + } + + @Test + fun `shiftDueDate 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.shiftDueDate(any()) } throws Exception() + + val viewModel = DatesViewModel( + datesRouter, + networkConnection, + resourceManager, + datesInteractor, + corePreferences + ) + advanceUntilIdle() + + viewModel.shiftDueDate() + 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, + 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, + corePreferences + ) + val fragmentManager = mockk(relaxed = true) + val courseDate: CourseDate = mockk(relaxed = true) { + every { courseId } returns "course-123" + every { courseName } returns "Test Course" + every { assignmentBlockId } 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 = 2, + previous = 1, + 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, + 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, + 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) + } +} \ No newline at end of file From 55e168ee0ea1cb0449a49e3bb612794d278eb6a2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 20:12:43 +0200 Subject: [PATCH 32/41] feat: junit tests and analytics --- .../java/org/openedx/app/AnalyticsManager.kt | 4 +++- .../main/java/org/openedx/app/di/AppModule.kt | 2 ++ .../java/org/openedx/app/di/ScreenModule.kt | 3 ++- .../dates/presentation/DatesAnalytics.kt | 20 +++++++++++++++++++ .../presentation/dates/DatesViewModel.kt | 19 ++++++++++++++++++ .../org/openedx/dates/DatesViewModelTest.kt | 15 +++++++++++++- 6 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 dates/src/main/java/org/openedx/dates/presentation/DatesAnalytics.kt 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/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 7d46f43b4..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,7 @@ 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 @@ -216,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 c0d27283e..9f096a7a3 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -604,7 +604,8 @@ val screenModule = module { networkConnection = get(), resourceManager = get(), datesInteractor = get(), - corePreferences = get() + corePreferences = get(), + analytics = get() ) } } 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/dates/DatesViewModel.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt index 43bb9565c..70052e6d8 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 @@ -19,6 +19,9 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.utils.isToday import org.openedx.core.utils.toCalendar 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 @@ -32,6 +35,7 @@ class DatesViewModel( private val networkConnection: NetworkConnection, private val resourceManager: ResourceManager, private val datesInteractor: DatesInteractor, + private val analytics: DatesAnalytics, corePreferences: CorePreferences, ) : BaseViewModel() { @@ -116,6 +120,7 @@ class DatesViewModel( } fun shiftDueDate() { + logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) viewModelScope.launch { try { _uiState.update { state -> @@ -167,6 +172,7 @@ class DatesViewModel( fragmentManager: FragmentManager, courseDate: CourseDate, ) { + logEvent(DatesAnalyticsEvent.ASSIGNMENT_CLICK) datesRouter.navigateToCourseOutline( fm = fragmentManager, courseId = courseDate.courseId, @@ -201,6 +207,19 @@ class DatesViewModel( return grouped } + + 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 { diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index b477a5074..d01794e2e 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -31,6 +31,7 @@ import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse import org.openedx.core.system.connection.NetworkConnection 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 @@ -51,6 +52,7 @@ class DatesViewModelTest { private val resourceManager = mockk() private val datesInteractor = mockk() private val corePreferences = mockk() + private val analytics = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -63,6 +65,7 @@ class DatesViewModelTest { // 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 } @After @@ -88,7 +91,8 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, - corePreferences + analytics, + corePreferences, ) advanceUntilIdle() @@ -109,6 +113,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -131,6 +136,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val message = async { @@ -155,6 +161,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val message = async { @@ -193,6 +200,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -228,6 +236,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -251,6 +260,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -266,6 +276,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -308,6 +319,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() @@ -339,6 +351,7 @@ class DatesViewModelTest { networkConnection, resourceManager, datesInteractor, + analytics, corePreferences ) advanceUntilIdle() From cd61dcd0a0d585bce6ee1e8b15eface954be5f5e Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 21 Mar 2025 12:42:48 +0200 Subject: [PATCH 33/41] fix: changes according detekt warnings --- .../java/org/openedx/core/data/model/ShiftDueDatesBody.kt | 2 +- .../java/org/openedx/core/presentation/dates/DatesUI.kt | 7 ++----- .../org/openedx/dates/domain/interactor/DatesInteractor.kt | 1 - .../org/openedx/dates/presentation/dates/DatesFragment.kt | 1 - .../org/openedx/dates/presentation/dates/DatesViewModel.kt | 4 +--- .../src/test/java/org/openedx/dates/DatesViewModelTest.kt | 2 +- 6 files changed, 5 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt index df6749f24..63e66363d 100644 --- a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt +++ b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt @@ -4,4 +4,4 @@ import com.google.gson.annotations.SerializedName data class ShiftDueDatesBody( @SerializedName("course_keys") val courseKeys: List -) \ No newline at end of file +) 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 1c7b10df9..0b4cb0be8 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,12 +36,9 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.core.utils.TimeUtils.formatToString import org.openedx.core.utils.clearTime -// --- Generic composables for reusability --- - @Composable private fun CourseDateBlockSectionGeneric( sectionKey: DatesSection = DatesSection.NONE, - useRelativeDates: Boolean, content: @Composable () -> Unit ) { Column(modifier = Modifier.padding(start = 8.dp)) { @@ -87,7 +84,7 @@ fun CourseDateBlockSection( sectionDates: List, onItemClick: (CourseDateBlock) -> Unit, ) { - CourseDateBlockSectionGeneric(sectionKey = sectionKey, useRelativeDates = useRelativeDates) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { DateBlock( dateBlocks = sectionDates, onItemClick = onItemClick, @@ -104,7 +101,7 @@ fun CourseDateBlockSection( sectionDates: List, onItemClick: (CourseDate) -> Unit, ) { - CourseDateBlockSectionGeneric(sectionKey = sectionKey, useRelativeDates = useRelativeDates) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { DateBlock( dateBlocks = sectionDates, onItemClick = onItemClick, 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 index 736819c0b..3db176580 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -11,5 +11,4 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() suspend fun shiftDueDate(courseIds: List) = repository.shiftDueDate(courseIds) - } 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 index 761b697c1..00e083de7 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -128,7 +128,6 @@ class DatesFragment : Fragment() { companion object { const val LOAD_MORE_THRESHOLD = 4 } - } @OptIn(ExperimentalMaterialApi::class) 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 70052e6d8..f3f2f1891 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 @@ -67,9 +67,7 @@ class DatesViewModel( isRefreshing = refresh, ) } - if (refresh) { - page = 1 - } + if (refresh) page = 1 val response = if (networkConnection.isOnline() || page > 1) { datesInteractor.getUserDates(page) } else { diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index d01794e2e..dbe9527b4 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -364,4 +364,4 @@ class DatesViewModelTest { // After refresh, isRefreshing should be false. assertFalse(viewModel.uiState.value.isRefreshing) } -} \ No newline at end of file +} From cf52207dd39b9ef0d97c5c54f1fb9ddc94bbec50 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 25 Mar 2025 13:40:28 +0200 Subject: [PATCH 34/41] feat: pagination --- .../core/data/model/CourseDatesResponse.kt | 10 +- .../core/domain/model/CourseDatesResponse.kt | 6 +- .../core/presentation/dates/DatesUI.kt | 4 +- .../dates/data/storage/CourseDateEntity.kt | 25 +-- .../dates/presentation/dates/DatesFragment.kt | 16 +- .../presentation/dates/DatesViewModel.kt | 182 +++++++++++------- .../org/openedx/dates/DatesViewModelTest.kt | 10 +- 7 files changed, 146 insertions(+), 107 deletions(-) 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 index b39028bd5..c86500671 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -8,8 +8,8 @@ import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesRes data class CourseDate( @SerializedName("course_id") val courseId: String, - @SerializedName("assignment_block_id") - val assignmentBlockId: String, + @SerializedName("first_component_block_id") + val firstComponentBlockId: String?, @SerializedName("due_date") val dueDate: String?, @SerializedName("assignment_title") @@ -25,7 +25,7 @@ data class CourseDate( val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") return DomainCourseDate( courseId = courseId, - assignmentBlockId = assignmentBlockId, + firstComponentBlockId = firstComponentBlockId ?: "", dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, @@ -39,9 +39,9 @@ data class CourseDatesResponse( @SerializedName("count") val count: Int, @SerializedName("next") - val next: Int?, + val next: String?, @SerializedName("previous") - val previous: Int?, + val previous: String?, @SerializedName("results") val results: List ) { 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 index 2248e3a21..5a317b69c 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -4,14 +4,14 @@ import java.util.Date data class CourseDatesResponse( val count: Int, - val next: Int?, - val previous: Int?, + val next: String?, + val previous: String?, val results: List ) data class CourseDate( val courseId: String, - val assignmentBlockId: String, + val firstComponentBlockId: String, val dueDate: Date, val assignmentTitle: String, val learnerHasAccess: Boolean, 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 0b4cb0be8..f8f45edc3 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 @@ -273,7 +273,7 @@ private fun CourseDateItem( .fillMaxWidth() .padding(end = 4.dp) .clickable( - enabled = dateBlock.assignmentBlockId.isNotEmpty() && dateBlock.learnerHasAccess, + enabled = dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess, onClick = { onItemClick(dateBlock) } ) ) { @@ -296,7 +296,7 @@ private fun CourseDateItem( overflow = TextOverflow.Ellipsis, ) Spacer(modifier = Modifier.width(7.dp)) - if (dateBlock.assignmentBlockId.isNotEmpty() && dateBlock.learnerHasAccess) { + if (dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess) { Icon( imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.textDark, diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt index ec751d1ee..de7705d54 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt @@ -9,20 +9,22 @@ import org.openedx.core.domain.model.CourseDate as DomainCourseDate @Entity(tableName = "course_date_table") data class CourseDateEntity( - @PrimaryKey - @ColumnInfo("assignmentBlockId") - val assignmentBlockId: String, - @ColumnInfo("courseId") + @PrimaryKey(autoGenerate = true) + @ColumnInfo("course_date_id") + val id: Int, + @ColumnInfo("course_date_first_component_block_id") + val firstComponentBlockId: String?, + @ColumnInfo("course_date_courseId") val courseId: String, - @ColumnInfo("dueDate") + @ColumnInfo("course_date_dueDate") val dueDate: String?, - @ColumnInfo("assignmentTitle") + @ColumnInfo("course_date_assignmentTitle") val assignmentTitle: String?, - @ColumnInfo("learnerHasAccess") + @ColumnInfo("course_date_learnerHasAccess") val learnerHasAccess: Boolean?, - @ColumnInfo("relative") + @ColumnInfo("course_date_relative") val relative: Boolean?, - @ColumnInfo("courseName") + @ColumnInfo("course_date_courseName") val courseName: String?, ) { @@ -30,7 +32,7 @@ data class CourseDateEntity( val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") return DomainCourseDate( courseId = courseId, - assignmentBlockId = assignmentBlockId, + firstComponentBlockId = firstComponentBlockId ?: "", dueDate = dueDate ?: return null, assignmentTitle = assignmentTitle ?: "", learnerHasAccess = learnerHasAccess ?: false, @@ -43,8 +45,9 @@ data class CourseDateEntity( fun createFrom(courseDate: CourseDate): CourseDateEntity { with(courseDate) { return CourseDateEntity( + id = 0, courseId = courseId, - assignmentBlockId = assignmentBlockId, + firstComponentBlockId = firstComponentBlockId, dueDate = dueDate, assignmentTitle = assignmentTitle, learnerHasAccess = learnerHasAccess, 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 index 00e083de7..22974ee32 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -31,8 +31,8 @@ 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.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -57,7 +57,6 @@ import org.openedx.core.ui.MainScreenTitle import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.shouldLoadMore import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -126,7 +125,7 @@ class DatesFragment : Fragment() { } companion object { - const val LOAD_MORE_THRESHOLD = 4 + const val LOAD_MORE_THRESHOLD = 0.8f } } @@ -157,9 +156,7 @@ private fun DatesScreen( mutableStateOf(false) } val scrollState = rememberLazyListState() - val firstVisibleIndex = remember { - mutableIntStateOf(scrollState.firstVisibleItemIndex) - } + val layoutInfo by remember { derivedStateOf { scrollState.layoutInfo } } Scaffold( scaffoldState = scaffoldState, @@ -249,7 +246,12 @@ private fun DatesScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + val lastVisibleItemIndex = + layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val totalItemsCount = layoutInfo.totalItemsCount + if (totalItemsCount > 0 && + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + ) { onAction(DatesViewActions.LoadMore) } } 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 f3f2f1891..f62f48a60 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 @@ -13,6 +13,7 @@ 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 @@ -61,62 +62,93 @@ class DatesViewModel( private fun fetchDates(refresh: Boolean) { viewModelScope.launch { try { - _uiState.update { state -> - state.copy( - isLoading = !refresh, - isRefreshing = refresh, - ) - } - if (refresh) page = 1 - val response = if (networkConnection.isOnline() || page > 1) { - datesInteractor.getUserDates(page) - } else { - null - } + updateLoadingState(refresh) + val response = getUserDates(refresh) if (response != null) { - if (response.next.isNotNull() && page != response.count) { - _uiState.update { state -> state.copy(canLoadMore = true) } - page++ - } else { - _uiState.update { state -> state.copy(canLoadMore = false) } - page = -1 - } - _uiState.update { state -> - state.copy( - dates = state.dates + groupCourseDates(response.results) - ) - } + updateUIWithResponse(response, refresh) } else { - val cachedList = datesInteractor.getUserDatesFromCache() - _uiState.update { state -> state.copy(canLoadMore = false) } - page = -1 - _uiState.update { state -> - state.copy( - dates = groupCourseDates(cachedList) - ) - } + updateUIWithCachedResponse() } } 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)) - ) - } + handleFetchException(e) } finally { - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false, - ) - } + clearLoadingState() } } } + private fun updateLoadingState(refresh: Boolean) { + _uiState.update { state -> + state.copy( + isLoading = !refresh, + isRefreshing = refresh + ) + } + } + + private suspend fun getUserDates(refresh: Boolean) = if (refresh) { + page = 1 + datesInteractor.getUserDates(page) + } else { + if (networkConnection.isOnline() || page > 1) { + datesInteractor.getUserDates(page) + } else { + null + } + } + + private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { + if (response.next.isNotNull() && page != response.count) { + _uiState.update { state -> state.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + } + _uiState.update { state -> + if (refresh) { + state.copy( + dates = groupCourseDates(response.results) + ) + } else { + val newDates = groupCourseDates(response.results) + state.copy(dates = mergeDates(state.dates, newDates)) + } + } + } + + private suspend fun updateUIWithCachedResponse() { + val cachedList = datesInteractor.getUserDatesFromCache() + _uiState.update { state -> state.copy(canLoadMore = false) } + page = -1 + _uiState.update { state -> + state.copy( + dates = groupCourseDates(cachedList) + ) + } + } + + private suspend fun handleFetchException(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)) + ) + } + } + + private fun clearLoadingState() { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false + ) + } + } + fun shiftDueDate() { logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) viewModelScope.launch { @@ -130,18 +162,11 @@ class DatesViewModel( val courseIds = pastDueDates .filter { it.relative } .map { it.courseId } + .distinct() datesInteractor.shiftDueDate(courseIds) refreshData() } 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)) - ) - } + handleFetchException(e) } finally { _uiState.update { state -> state.copy( @@ -176,34 +201,43 @@ class DatesViewModel( courseId = courseDate.courseId, courseTitle = courseDate.courseName, openTab = "", - resumeBlockId = courseDate.assignmentBlockId + resumeBlockId = courseDate.firstComponentBlockId ) } private fun groupCourseDates(dates: List): Map> { val now = Date() val calNow = Calendar.getInstance().apply { time = now } - val grouped = dates.groupBy { courseDate -> - val dueDate = courseDate.dueDate - if (dueDate.before(now)) { - DatesSection.PAST_DUE - } else if (dueDate.isToday()) { - DatesSection.TODAY - } else { - val calDue = dueDate.toCalendar() - val weekNow = calNow.get(Calendar.WEEK_OF_YEAR) - val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) - val yearNow = calNow.get(Calendar.YEAR) - val yearDue = calDue.get(Calendar.YEAR) - if (weekNow == weekDue && yearNow == yearDue) { - DatesSection.THIS_WEEK - } else { - DatesSection.UPCOMING + 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 = calNow.get(Calendar.WEEK_OF_YEAR) + val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) + val yearNow = calNow.get(Calendar.YEAR) + val yearDue = calDue.get(Calendar.YEAR) + if (weekNow == weekDue && yearNow == yearDue) { + DatesSection.THIS_WEEK + } else { + DatesSection.UPCOMING + } } } } + } - return grouped + 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( diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index dbe9527b4..16f0a30ac 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -79,8 +79,8 @@ class DatesViewModelTest { val courseDate: CourseDate = mockk(relaxed = true) val courseDatesResponse = CourseDatesResponse( count = 10, - next = 2, - previous = 1, + next = "", + previous = "", results = listOf(courseDate) ) coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse @@ -283,7 +283,7 @@ class DatesViewModelTest { val courseDate: CourseDate = mockk(relaxed = true) { every { courseId } returns "course-123" every { courseName } returns "Test Course" - every { assignmentBlockId } returns "block-1" + every { firstComponentBlockId } returns "block-1" } viewModel.navigateToCourseOutline(fragmentManager, courseDate) @@ -304,8 +304,8 @@ class DatesViewModelTest { val courseDate: CourseDate = mockk(relaxed = true) val courseDatesResponse = CourseDatesResponse( count = 10, - next = 2, - previous = 1, + next = "", + previous = "", results = listOf(courseDate) ) From 1e36b5fc95d5adccd9c194379c072989848bbc0a Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 27 Mar 2025 18:12:09 +0200 Subject: [PATCH 35/41] fix: pagination bugs --- .../org/openedx/core/data/api/CourseApi.kt | 7 ++---- .../core/data/model/ShiftDueDatesBody.kt | 7 ------ .../core/presentation/dates/DatesUI.kt | 1 + .../dates/data/repository/DatesRepository.kt | 7 +++--- .../domain/interactor/DatesInteractor.kt | 2 +- .../dates/presentation/dates/DatesFragment.kt | 5 +++- .../presentation/dates/DatesViewModel.kt | 23 ++++++++----------- .../org/openedx/dates/DatesViewModelTest.kt | 4 ++-- 8 files changed, 24 insertions(+), 32 deletions(-) delete mode 100644 core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index c701c13e1..99b0e4e34 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 @@ -15,7 +15,6 @@ import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates -import org.openedx.core.data.model.ShiftDueDatesBody import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Header @@ -125,8 +124,6 @@ interface CourseApi { @Path("course_id") courseId: String, ): CourseProgressResponse - @POST("/api/course_experience/v1/reset_multiple_course_deadlines/") - suspend fun shiftDueDate( - @Body shiftDueDatesBody: ShiftDueDatesBody - ) + @POST("/api/course_experience/v1/reset_all_relative_course_deadlines/") + suspend fun shiftDueDate() } diff --git a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt b/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt deleted file mode 100644 index 63e66363d..000000000 --- a/core/src/main/java/org/openedx/core/data/model/ShiftDueDatesBody.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.openedx.core.data.model - -import com.google.gson.annotations.SerializedName - -data class ShiftDueDatesBody( - @SerializedName("course_keys") val courseKeys: List -) 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 f8f45edc3..499d101e8 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 @@ -312,6 +312,7 @@ private fun CourseDateItem( .fillMaxWidth() .padding(top = 4.dp), text = dateBlock.courseName, + maxLines = 1, style = MaterialTheme.appTypography.labelMedium, ) } 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 index fcfad5460..69396450a 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,7 +1,6 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.ShiftDueDatesBody import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseDate import org.openedx.core.domain.model.CourseDatesResponse @@ -16,6 +15,9 @@ class DatesRepository( suspend fun getUserDates(page: Int): CourseDatesResponse { val username = preferencesManager.user?.username ?: "" val response = api.getUserDates(username, page) + if (page == 1) { + dao.clearCachedData() + } dao.insertCourseDateEntities(response.results.map { CourseDateEntity.createFrom(it) }) return response.mapToDomain() } @@ -24,6 +26,5 @@ class DatesRepository( return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } } - suspend fun shiftDueDate(courseIds: List) = - api.shiftDueDate(ShiftDueDatesBody(courseIds)) + suspend fun shiftDueDate() = api.shiftDueDate() } 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 index 3db176580..e72b9dae0 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -10,5 +10,5 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() - suspend fun shiftDueDate(courseIds: List) = repository.shiftDueDate(courseIds) + suspend fun shiftDueDate() = repository.shiftDueDate() } 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 index 22974ee32..ef53a6924 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -250,7 +250,10 @@ private fun DatesScreen( layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalItemsCount = layoutInfo.totalItemsCount if (totalItemsCount > 0 && - lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() && + !uiState.isLoading && + !uiState.isRefreshing && + uiState.canLoadMore ) { onAction(DatesViewActions.LoadMore) } 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 f62f48a60..9397e033b 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 @@ -2,6 +2,7 @@ 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 @@ -54,13 +55,14 @@ class DatesViewModel( var useRelativeDates = corePreferences.isRelativeDatesEnabled private var page = 1 + private var fetchDataJob: Job? = null init { fetchDates(false) } private fun fetchDates(refresh: Boolean) { - viewModelScope.launch { + fetchDataJob = viewModelScope.launch { try { updateLoadingState(refresh) val response = getUserDates(refresh) @@ -70,6 +72,7 @@ class DatesViewModel( updateUIWithCachedResponse() } } catch (e: Exception) { + page = -1 handleFetchException(e) } finally { clearLoadingState() @@ -86,11 +89,9 @@ class DatesViewModel( } } - private suspend fun getUserDates(refresh: Boolean) = if (refresh) { - page = 1 - datesInteractor.getUserDates(page) - } else { - if (networkConnection.isOnline() || page > 1) { + private suspend fun getUserDates(refresh: Boolean): CourseDatesResponse? { + if (refresh) page = 1 + return if (networkConnection.isOnline() || page > 1) { datesInteractor.getUserDates(page) } else { null @@ -98,7 +99,7 @@ class DatesViewModel( } private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { - if (response.next.isNotNull() && page != response.count) { + if (response.next.isNotNull()) { _uiState.update { state -> state.copy(canLoadMore = true) } page++ } else { @@ -158,12 +159,7 @@ class DatesViewModel( isShiftDueDatesPressed = true, ) } - val pastDueDates = _uiState.value.dates[DatesSection.PAST_DUE] ?: emptyList() - val courseIds = pastDueDates - .filter { it.relative } - .map { it.courseId } - .distinct() - datesInteractor.shiftDueDate(courseIds) + datesInteractor.shiftDueDate() refreshData() } catch (e: Exception) { handleFetchException(e) @@ -184,6 +180,7 @@ class DatesViewModel( } fun refreshData() { + fetchDataJob?.cancel() fetchDates(true) } diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index 16f0a30ac..ffa7198a4 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -208,7 +208,7 @@ class DatesViewModelTest { viewModel.shiftDueDate() advanceUntilIdle() - coVerify { datesInteractor.shiftDueDate(listOf("course-123")) } + coVerify { datesInteractor.shiftDueDate() } // isShiftDueDatesPressed should be reset to false after processing. assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) } @@ -229,7 +229,7 @@ class DatesViewModelTest { results = listOf(courseDate) ) coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse - coEvery { datesInteractor.shiftDueDate(any()) } throws Exception() + coEvery { datesInteractor.shiftDueDate() } throws Exception() val viewModel = DatesViewModel( datesRouter, From 76737e26cae8bd522230d9ab3ba14e55d8687898 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 31 Mar 2025 16:53:15 +0300 Subject: [PATCH 36/41] feat: cache-first logic --- .../main/java/org/openedx/app/MainFragment.kt | 9 +- .../java/org/openedx/app/di/ScreenModule.kt | 4 +- .../java/org/openedx/app/room/AppDatabase.kt | 3 +- .../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/menu/bottom_view_menu.xml | 0 app/src/main/res/values/main_manu_tab_ids.xml | 1 + ...{DatesConfig.kt => AppLevelDatesConfig.kt} | 2 +- .../core/config/ExperimentalFeaturesConfig.kt | 2 + .../core/data/model/CourseDatesResponse.kt | 4 +- .../model/room/CourseDatesResponseEntity.kt | 51 ++- .../java/org/openedx/core/ui/ComposeCommon.kt | 4 +- .../course/data/storage/CourseConverter.kt | 13 + .../learn/presentation/LearnFragment.kt | 6 +- .../dates/data/repository/DatesRepository.kt | 16 +- .../openedx/dates/data/storage/DatesDao.kt | 9 +- .../domain/interactor/DatesInteractor.kt | 2 + .../dates/presentation/dates/DatesFragment.kt | 324 ----------------- .../dates/presentation/dates/DatesScreen.kt | 325 ++++++++++++++++++ .../presentation/dates/DatesViewModel.kt | 67 ++-- .../org/openedx/dates/DatesViewModelTest.kt | 17 +- default_config/dev/config.yaml | 3 - default_config/prod/config.yaml | 5 +- default_config/stage/config.yaml | 5 +- .../presentation/download/DownloadsScreen.kt | 4 +- 26 files changed, 508 insertions(+), 391 deletions(-) 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 delete mode 100644 app/src/main/res/menu/bottom_view_menu.xml rename core/src/main/java/org/openedx/core/config/{DatesConfig.kt => AppLevelDatesConfig.kt} (82%) rename dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt => core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt (54%) create mode 100644 dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 82092e439..80ecd842a 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -26,6 +26,7 @@ import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment 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.dates.presentation.dates.DatesFragment import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.downloads.presentation.download.DownloadsFragment @@ -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 -> @@ -173,7 +180,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { } else { 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/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 9f096a7a3..f2d531918 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -240,6 +240,7 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -605,7 +606,8 @@ val screenModule = module { resourceManager = get(), datesInteractor = get(), corePreferences = get(), - analytics = 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 2ee6f7eec..06181c510 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.CourseDatesResponseEntity 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,7 +20,6 @@ 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.CourseDateEntity import org.openedx.dates.data.storage.DatesDao import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity @@ -42,6 +42,7 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEnrollmentDetailsEntity::class, CourseDateEntity::class, VideoProgressEntity::class, + CourseDatesResponseEntity::class, CourseProgressEntity::class, ], autoMigrations = [ 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..b803c4937 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml deleted file mode 100644 index e69de29bb..000000000 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/core/src/main/java/org/openedx/core/config/DatesConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt similarity index 82% rename from core/src/main/java/org/openedx/core/config/DatesConfig.kt rename to core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt index 0e48a5ed5..73392bf72 100644 --- a/core/src/main/java/org/openedx/core/config/DatesConfig.kt +++ b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt @@ -2,7 +2,7 @@ package org.openedx.core.config import com.google.gson.annotations.SerializedName -data class DatesConfig( +data class AppLevelDatesConfig( @SerializedName("ENABLED") val isEnabled: Boolean = true, ) 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/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt index c86500671..28a1b28dd 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -50,7 +50,9 @@ data class CourseDatesResponse( count = count, next = next, previous = previous, - results = results.mapNotNull { it.mapToDomain() } + results = results + .mapNotNull { it.mapToDomain() } + .sortedBy { it.dueDate } ) } } diff --git a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt similarity index 54% rename from dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt rename to core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt index de7705d54..5231a5604 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/CourseDateEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt @@ -1,17 +1,55 @@ -package org.openedx.dates.data.storage +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.data.model.CourseDatesResponse import org.openedx.core.utils.TimeUtils import org.openedx.core.domain.model.CourseDate as DomainCourseDate +import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesResponse -@Entity(tableName = "course_date_table") -data class CourseDateEntity( +@Entity(tableName = "course_dates_response_table") +data class CourseDatesResponseEntity( @PrimaryKey(autoGenerate = true) - @ColumnInfo("course_date_id") + @ColumnInfo("course_date_response_id") val id: Int, + @ColumnInfo("course_date_response_count") + val count: Int, + @ColumnInfo("course_date_response_next") + val next: String?, + @ColumnInfo("course_date_response_previous") + val previous: String?, + @ColumnInfo("course_date_response_results") + val results: List +) { + fun mapToDomain(): DomainCourseDatesResponse { + return DomainCourseDatesResponse( + count = count, + next = next, + previous = previous, + results = results + .mapNotNull { it.mapToDomain() } + .sortedBy { it.dueDate } + ) + } + + companion object { + fun createFrom(courseDatesResponse: CourseDatesResponse): CourseDatesResponseEntity { + with(courseDatesResponse) { + return CourseDatesResponseEntity( + id = 0, + count = count, + next = next, + previous = previous, + results = results.map { CourseDateDB.createFrom(it) } + ) + } + } + } +} + +data class CourseDateDB( @ColumnInfo("course_date_first_component_block_id") val firstComponentBlockId: String?, @ColumnInfo("course_date_courseId") @@ -42,10 +80,9 @@ data class CourseDateEntity( } companion object { - fun createFrom(courseDate: CourseDate): CourseDateEntity { + fun createFrom(courseDate: CourseDate): CourseDateDB { with(courseDate) { - return CourseDateEntity( - id = 0, + return CourseDateDB( courseId = courseId, firstComponentBlockId = firstComponentBlockId, dueDate = dueDate, 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 6243dae74..4230980be 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1277,7 +1277,7 @@ private fun RoundTab( } @Composable -fun MainScreenTitle( +fun MainScreenToolbar( modifier: Modifier = Modifier, label: String, onSettingsClick: () -> Unit, @@ -1314,7 +1314,7 @@ fun MainScreenTitle( @Composable private fun MainScreenTitlePreview() { OpenEdXTheme { - MainScreenTitle( + MainScreenToolbar( label = "Title", onSettingsClick = {} ) diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index b49a806e6..68829efd2 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -4,6 +4,7 @@ import androidx.room.TypeConverter import com.google.common.reflect.TypeToken import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb +import org.openedx.core.data.model.room.CourseDateDB import org.openedx.core.data.model.room.GradingPolicyDb import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb @@ -83,4 +84,16 @@ class CourseConverter { @TypeConverter fun toGradeRangeMap(value: String): Map = Gson().fromJson(value, object : TypeToken>() {}.type) + + @TypeConverter + fun fromListOfCourseDateDB(value: List): String { + val json = Gson().toJson(value) + return json.toString() + } + + @TypeConverter + fun toListOfCourseDateDB(value: String): List { + val type = genericType>() + return Gson().fromJson(value, type) + } } 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 54e4402ee..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.MainScreenTitle +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 ) { - MainScreenTitle( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -240,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - MainScreenTitle( + MainScreenToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = {} ) 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 index 69396450a..3ce6b463a 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,10 +1,10 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.CourseDatesResponseEntity 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.CourseDateEntity import org.openedx.dates.data.storage.DatesDao class DatesRepository( @@ -18,12 +18,22 @@ class DatesRepository( if (page == 1) { dao.clearCachedData() } - dao.insertCourseDateEntities(response.results.map { CourseDateEntity.createFrom(it) }) + dao.insertCourseDateResponses(CourseDatesResponseEntity.createFrom(response)) return response.mapToDomain() } suspend fun getUserDatesFromCache(): List { - return dao.getCourseDateEntities().mapNotNull { it.mapToDomain() } + return dao.getCourseDateResponses() + .map { it.mapToDomain() } + .map { it.results } + .flatten() + .sortedBy { it.dueDate } + } + + suspend fun preloadFirstPageCachedDates(): CourseDatesResponse? { + return dao.getCourseDateResponses() + .find { it.previous == null } + ?.mapToDomain() } suspend fun shiftDueDate() = api.shiftDueDate() 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 index 50b570112..1c46ad77d 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -4,16 +4,17 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import org.openedx.core.data.model.room.CourseDatesResponseEntity @Dao interface DatesDao { - @Query("SELECT * FROM course_date_table") - suspend fun getCourseDateEntities(): List + @Query("SELECT * FROM course_dates_response_table") + suspend fun getCourseDateResponses(): List @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertCourseDateEntities(courseDate: List) + suspend fun insertCourseDateResponses(courseDates: CourseDatesResponseEntity) - @Query("DELETE FROM course_date_table") + @Query("DELETE FROM course_dates_response_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 index e72b9dae0..96f7cf8ba 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -10,5 +10,7 @@ class DatesInteractor( suspend fun getUserDatesFromCache() = repository.getUserDatesFromCache() + suspend fun preloadFirstPageCachedDates() = repository.preloadFirstPageCachedDates() + suspend fun shiftDueDate() = repository.shiftDueDate() } 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 index ef53a6924..219cadbc3 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -3,71 +3,13 @@ package org.openedx.dates.presentation.dates import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -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.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.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.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.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -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 androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel -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.MainScreenTitle -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 class DatesFragment : Fragment() { @@ -128,269 +70,3 @@ class DatesFragment : Fragment() { const val LOAD_MORE_THRESHOLD = 0.8f } } - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private 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 = { - MainScreenTitle( - modifier = Modifier - .statusBarsInset() - .displayCutoutForLandscape(), - label = stringResource(id = R.string.dates), - 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) - ) { - 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( - modifier = Modifier.padding(top = 12.dp), - 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 - if (totalItemsCount > 0 && - lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() && - !uiState.isLoading && - !uiState.isRefreshing && - uiState.canLoadMore - ) { - 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( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), - 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/DatesScreen.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt new file mode 100644 index 000000000..8c62f576c --- /dev/null +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -0,0 +1,325 @@ +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.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.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.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.painterResource +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), + 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) + ) { + 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( + modifier = Modifier.padding(top = 12.dp), + 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 + if (totalItemsCount > 0 && + lastVisibleItemIndex >= (totalItemsCount * LOAD_MORE_THRESHOLD).toInt() + ) { + 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( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), + 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/DatesViewModel.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesViewModel.kt index 9397e033b..2da6ac783 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 @@ -20,6 +20,7 @@ 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 @@ -38,6 +39,7 @@ class DatesViewModel( private val resourceManager: ResourceManager, private val datesInteractor: DatesInteractor, private val analytics: DatesAnalytics, + private val calendarSyncScheduler: CalendarSyncScheduler, corePreferences: CorePreferences, ) : BaseViewModel() { @@ -58,21 +60,23 @@ class DatesViewModel( private var fetchDataJob: Job? = null 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 = getUserDates(refresh) - if (response != null) { - updateUIWithResponse(response, refresh) - } else { - updateUIWithCachedResponse() - } + val response = datesInteractor.getUserDates(page) + updateUIWithResponse(response, refresh) } catch (e: Exception) { page = -1 + updateUIWithCachedResponse() handleFetchException(e) } finally { clearLoadingState() @@ -89,39 +93,26 @@ class DatesViewModel( } } - private suspend fun getUserDates(refresh: Boolean): CourseDatesResponse? { - if (refresh) page = 1 - return if (networkConnection.isOnline() || page > 1) { - datesInteractor.getUserDates(page) - } else { - null - } - } - private fun updateUIWithResponse(response: CourseDatesResponse, refresh: Boolean) { - if (response.next.isNotNull()) { - _uiState.update { state -> state.copy(canLoadMore = true) } - page++ - } else { - _uiState.update { state -> state.copy(canLoadMore = false) } - page = -1 - } _uiState.update { state -> - if (refresh) { - state.copy( - dates = groupCourseDates(response.results) - ) + 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(canLoadMore = false) } - page = -1 _uiState.update { state -> state.copy( dates = groupCourseDates(cachedList) @@ -129,7 +120,19 @@ class DatesViewModel( } } - private suspend fun handleFetchException(e: Exception) { + private fun preloadFirstPageCachedDates() { + viewModelScope.launch { + val cachedList = datesInteractor.preloadFirstPageCachedDates()?.results ?: emptyList() + _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)) @@ -161,6 +164,7 @@ class DatesViewModel( } datesInteractor.shiftDueDate() refreshData() + calendarSyncScheduler.requestImmediateSync() } catch (e: Exception) { handleFetchException(e) } finally { @@ -174,7 +178,10 @@ class DatesViewModel( } fun fetchMore() { - if (!_uiState.value.isLoading && page != -1) { + if (!_uiState.value.isLoading && + !_uiState.value.isRefreshing && + _uiState.value.canLoadMore + ) { fetchDates(false) } } @@ -217,6 +224,8 @@ class DatesViewModel( 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 } diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index ffa7198a4..1a2e556c2 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -30,6 +30,7 @@ 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 @@ -52,6 +53,7 @@ class DatesViewModelTest { 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" @@ -66,6 +68,8 @@ class DatesViewModelTest { every { networkConnection.isOnline() } returns true every { corePreferences.isRelativeDatesEnabled } returns true every { analytics.logEvent(any(), any()) } returns Unit + coEvery { datesInteractor.preloadFirstPageCachedDates() } returns null + coEvery { datesInteractor.getUserDatesFromCache() } returns emptyList() } @After @@ -92,6 +96,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences, ) advanceUntilIdle() @@ -114,15 +119,13 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() - // When offline, getUserDates is not called. - coVerify(exactly = 0) { datesInteractor.getUserDates(any()) } coVerify(exactly = 1) { datesInteractor.getUserDatesFromCache() } assertFalse(viewModel.uiState.value.isLoading) - // Expect no further pages to load. assertFalse(viewModel.uiState.value.canLoadMore) } @@ -137,6 +140,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val message = async { @@ -162,6 +166,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val message = async { @@ -201,6 +206,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() @@ -237,6 +243,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() @@ -261,6 +268,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -277,6 +285,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) val fragmentManager = mockk(relaxed = true) @@ -320,6 +329,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() @@ -352,6 +362,7 @@ class DatesViewModelTest { resourceManager, datesInteractor, analytics, + calendarSyncScheduler, corePreferences ) advanceUntilIdle() diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index ac06ef7ba..952e041de 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,9 +31,6 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -APP_LEVEL_DATES: - ENABLED: true - FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 54b5e3e2a..d30d38719 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 @@ -70,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: true #Platform names PLATFORM_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 54b5e3e2a..d30d38719 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 @@ -70,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: true #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(), From 1cdbb3e644b039437586a984e0ef610174331a63 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 2 Apr 2025 12:52:13 +0300 Subject: [PATCH 37/41] fix: changes according code review --- .../main/java/org/openedx/app/MainFragment.kt | 3 +- .../app/data/networking/HeadersInterceptor.kt | 2 +- .../java/org/openedx/app/room/AppDatabase.kt | 2 +- .../res/drawable/app_ic_dates_selector.xml | 2 +- .../org/openedx/core/data/api/CourseApi.kt | 2 +- .../core/data/model/CourseDatesResponse.kt | 4 +- .../core/data/model/room/CourseDateEntity.kt | 60 ++++++++++++ .../model/room/CourseDatesResponseEntity.kt | 97 ------------------- .../core/presentation/dates/DatesUI.kt | 17 ++-- dates/proguard-rules.pro | 28 ++---- .../dates/data/repository/DatesRepository.kt | 22 ++--- .../openedx/dates/data/storage/DatesDao.kt | 13 ++- .../domain/interactor/DatesInteractor.kt | 2 +- .../dates/presentation/dates/DatesFragment.kt | 2 +- .../dates/presentation/dates/DatesScreen.kt | 2 +- .../presentation/dates/DatesViewModel.kt | 16 +-- dates/src/main/res/layout/fragment_dates.xml | 6 -- dates/src/main/res/values/strings.xml | 4 +- .../org/openedx/dates/DatesViewModelTest.kt | 14 +-- 19 files changed, 122 insertions(+), 176 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt delete mode 100644 core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt delete mode 100644 dates/src/main/res/layout/fragment_dates.xml diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 80ecd842a..fdea40e77 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -26,8 +26,8 @@ import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment 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.dates.presentation.dates.DatesFragment 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 @@ -180,6 +180,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { } else { 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/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/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index 06181c510..fd0461d8e 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -6,7 +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.CourseDatesResponseEntity +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 diff --git a/app/src/main/res/drawable/app_ic_dates_selector.xml b/app/src/main/res/drawable/app_ic_dates_selector.xml index b803c4937..9e20819bf 100644 --- a/app/src/main/res/drawable/app_ic_dates_selector.xml +++ b/app/src/main/res/drawable/app_ic_dates_selector.xml @@ -2,4 +2,4 @@ - \ No newline at end of file + 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 99b0e4e34..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 @@ -125,5 +125,5 @@ interface CourseApi { ): CourseProgressResponse @POST("/api/course_experience/v1/reset_all_relative_course_deadlines/") - suspend fun shiftDueDate() + 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 index 28a1b28dd..c86500671 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -50,9 +50,7 @@ data class CourseDatesResponse( count = count, next = next, previous = previous, - results = results - .mapNotNull { it.mapToDomain() } - .sortedBy { it.dueDate } + results = results.mapNotNull { it.mapToDomain() } ) } } 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/data/model/room/CourseDatesResponseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt deleted file mode 100644 index 5231a5604..000000000 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseDatesResponseEntity.kt +++ /dev/null @@ -1,97 +0,0 @@ -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.data.model.CourseDatesResponse -import org.openedx.core.utils.TimeUtils -import org.openedx.core.domain.model.CourseDate as DomainCourseDate -import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesResponse - -@Entity(tableName = "course_dates_response_table") -data class CourseDatesResponseEntity( - @PrimaryKey(autoGenerate = true) - @ColumnInfo("course_date_response_id") - val id: Int, - @ColumnInfo("course_date_response_count") - val count: Int, - @ColumnInfo("course_date_response_next") - val next: String?, - @ColumnInfo("course_date_response_previous") - val previous: String?, - @ColumnInfo("course_date_response_results") - val results: List -) { - fun mapToDomain(): DomainCourseDatesResponse { - return DomainCourseDatesResponse( - count = count, - next = next, - previous = previous, - results = results - .mapNotNull { it.mapToDomain() } - .sortedBy { it.dueDate } - ) - } - - companion object { - fun createFrom(courseDatesResponse: CourseDatesResponse): CourseDatesResponseEntity { - with(courseDatesResponse) { - return CourseDatesResponseEntity( - id = 0, - count = count, - next = next, - previous = previous, - results = results.map { CourseDateDB.createFrom(it) } - ) - } - } - } -} - -data class CourseDateDB( - @ColumnInfo("course_date_first_component_block_id") - val firstComponentBlockId: String?, - @ColumnInfo("course_date_courseId") - val courseId: String, - @ColumnInfo("course_date_dueDate") - val dueDate: String?, - @ColumnInfo("course_date_assignmentTitle") - val assignmentTitle: String?, - @ColumnInfo("course_date_learnerHasAccess") - val learnerHasAccess: Boolean?, - @ColumnInfo("course_date_relative") - val relative: Boolean?, - @ColumnInfo("course_date_courseName") - 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): CourseDateDB { - with(courseDate) { - return CourseDateDB( - courseId = courseId, - firstComponentBlockId = firstComponentBlockId, - dueDate = dueDate, - assignmentTitle = assignmentTitle, - learnerHasAccess = learnerHasAccess, - relative = relative, - courseName = courseName - ) - } - } - } -} 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 499d101e8..c57874865 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 @@ -35,6 +35,7 @@ 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( @@ -261,13 +262,15 @@ private fun CourseDateItem( if (isMiddleChild) { Spacer(modifier = Modifier.height(20.dp)) } - val timeTitle = formatToString(context, dateBlock.dueDate, useRelativeDates) - Text( - text = timeTitle, - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textDark, - maxLines = 1, - ) + 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() diff --git a/dates/proguard-rules.pro b/dates/proguard-rules.pro index 481bb4348..cdb308aa0 100644 --- a/dates/proguard-rules.pro +++ b/dates/proguard-rules.pro @@ -1,21 +1,7 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# 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/java/org/openedx/dates/data/repository/DatesRepository.kt b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt index 3ce6b463a..f261d312d 100644 --- a/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt +++ b/dates/src/main/java/org/openedx/dates/data/repository/DatesRepository.kt @@ -1,7 +1,7 @@ package org.openedx.dates.data.repository import org.openedx.core.data.api.CourseApi -import org.openedx.core.data.model.room.CourseDatesResponseEntity +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 @@ -18,23 +18,21 @@ class DatesRepository( if (page == 1) { dao.clearCachedData() } - dao.insertCourseDateResponses(CourseDatesResponseEntity.createFrom(response)) + dao.insertCourseDates(response.results.map { CourseDateEntity.createFrom(it) }) return response.mapToDomain() } suspend fun getUserDatesFromCache(): List { - return dao.getCourseDateResponses() - .map { it.mapToDomain() } - .map { it.results } - .flatten() - .sortedBy { it.dueDate } + return dao.getCourseDates().mapNotNull { it.mapToDomain() } } - suspend fun preloadFirstPageCachedDates(): CourseDatesResponse? { - return dao.getCourseDateResponses() - .find { it.previous == null } - ?.mapToDomain() + suspend fun preloadFirstPageCachedDates(): List { + return dao.getCourseDates(PAGE_SIZE).mapNotNull { it.mapToDomain() } } - suspend fun shiftDueDate() = api.shiftDueDate() + 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 index 1c46ad77d..e8df66ad2 100644 --- a/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt +++ b/dates/src/main/java/org/openedx/dates/data/storage/DatesDao.kt @@ -4,17 +4,20 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import org.openedx.core.data.model.room.CourseDatesResponseEntity +import org.openedx.core.data.model.room.CourseDateEntity @Dao interface DatesDao { - @Query("SELECT * FROM course_dates_response_table") - suspend fun getCourseDateResponses(): List + @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 insertCourseDateResponses(courseDates: CourseDatesResponseEntity) + suspend fun insertCourseDates(courseDates: List) - @Query("DELETE FROM course_dates_response_table") + @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 index 96f7cf8ba..5bcb8abf1 100644 --- a/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt +++ b/dates/src/main/java/org/openedx/dates/domain/interactor/DatesInteractor.kt @@ -12,5 +12,5 @@ class DatesInteractor( suspend fun preloadFirstPageCachedDates() = repository.preloadFirstPageCachedDates() - suspend fun shiftDueDate() = repository.shiftDueDate() + suspend fun shiftAllDueDates() = repository.shiftAllDueDates() } 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 index 219cadbc3..2d28bb389 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesFragment.kt @@ -50,7 +50,7 @@ class DatesFragment : Fragment() { } DatesViewActions.ShiftDueDate -> { - viewModel.shiftDueDate() + viewModel.shiftAllDueDates() } is DatesViewActions.OpenEvent -> { 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 index 8c62f576c..010f2b895 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -100,7 +100,7 @@ fun DatesScreen( modifier = Modifier .statusBarsInset() .displayCutoutForLandscape(), - label = stringResource(id = R.string.dates), + label = stringResource(id = R.string.dates_title), onSettingsClick = { onAction(DatesViewActions.OpenSettings) } 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 2da6ac783..3fa4f3d02 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 @@ -112,17 +112,17 @@ class DatesViewModel( private suspend fun updateUIWithCachedResponse() { val cachedList = datesInteractor.getUserDatesFromCache() - _uiState.update { state -> state.copy(canLoadMore = false) } _uiState.update { state -> state.copy( - dates = groupCourseDates(cachedList) + dates = groupCourseDates(cachedList), + canLoadMore = false ) } } private fun preloadFirstPageCachedDates() { viewModelScope.launch { - val cachedList = datesInteractor.preloadFirstPageCachedDates()?.results ?: emptyList() + val cachedList = datesInteractor.preloadFirstPageCachedDates() _uiState.update { state -> state.copy( dates = groupCourseDates(cachedList), @@ -153,7 +153,7 @@ class DatesViewModel( } } - fun shiftDueDate() { + fun shiftAllDueDates() { logEvent(DatesAnalyticsEvent.SHIFT_DUE_DATE_CLICK) viewModelScope.launch { try { @@ -162,7 +162,7 @@ class DatesViewModel( isShiftDueDatesPressed = true, ) } - datesInteractor.shiftDueDate() + datesInteractor.shiftAllDueDates() refreshData() calendarSyncScheduler.requestImmediateSync() } catch (e: Exception) { @@ -211,16 +211,16 @@ class DatesViewModel( private fun groupCourseDates(dates: List): Map> { val now = Date() - val calNow = Calendar.getInstance().apply { time = now } + 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 = calNow.get(Calendar.WEEK_OF_YEAR) + val weekNow = calendar.get(Calendar.WEEK_OF_YEAR) val weekDue = calDue.get(Calendar.WEEK_OF_YEAR) - val yearNow = calNow.get(Calendar.YEAR) + val yearNow = calendar.get(Calendar.YEAR) val yearDue = calDue.get(Calendar.YEAR) if (weekNow == weekDue && yearNow == yearDue) { DatesSection.THIS_WEEK diff --git a/dates/src/main/res/layout/fragment_dates.xml b/dates/src/main/res/layout/fragment_dates.xml deleted file mode 100644 index 77d9ef65f..000000000 --- a/dates/src/main/res/layout/fragment_dates.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml index 1a2c6f989..9aa26728d 100644 --- a/dates/src/main/res/values/strings.xml +++ b/dates/src/main/res/values/strings.xml @@ -1,9 +1,9 @@ - Dates + 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 past due assignments without losing any progress. Shift Due Dates - \ No newline at end of file + diff --git a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt index 1a2e556c2..4bb903753 100644 --- a/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt +++ b/dates/src/test/java/org/openedx/dates/DatesViewModelTest.kt @@ -68,7 +68,7 @@ class DatesViewModelTest { every { networkConnection.isOnline() } returns true every { corePreferences.isRelativeDatesEnabled } returns true every { analytics.logEvent(any(), any()) } returns Unit - coEvery { datesInteractor.preloadFirstPageCachedDates() } returns null + coEvery { datesInteractor.preloadFirstPageCachedDates() } returns emptyList() coEvery { datesInteractor.getUserDatesFromCache() } returns emptyList() } @@ -181,7 +181,7 @@ class DatesViewModelTest { } @Test - fun `shiftDueDate success`() = runTest { + 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) { @@ -211,16 +211,16 @@ class DatesViewModelTest { ) advanceUntilIdle() - viewModel.shiftDueDate() + viewModel.shiftAllDueDates() advanceUntilIdle() - coVerify { datesInteractor.shiftDueDate() } + coVerify { datesInteractor.shiftAllDueDates() } // isShiftDueDatesPressed should be reset to false after processing. assertFalse(viewModel.uiState.value.isShiftDueDatesPressed) } @Test - fun `shiftDueDate error emits error message and resets flag`() = + fun `shiftAllDueDates error emits error message and resets flag`() = runTest(UnconfinedTestDispatcher()) { every { networkConnection.isOnline() } returns true val courseDate: CourseDate = mockk(relaxed = true) { @@ -235,7 +235,7 @@ class DatesViewModelTest { results = listOf(courseDate) ) coEvery { datesInteractor.getUserDates(1) } returns courseDatesResponse - coEvery { datesInteractor.shiftDueDate() } throws Exception() + coEvery { datesInteractor.shiftAllDueDates() } throws Exception() val viewModel = DatesViewModel( datesRouter, @@ -248,7 +248,7 @@ class DatesViewModelTest { ) advanceUntilIdle() - viewModel.shiftDueDate() + viewModel.shiftAllDueDates() val message = async { withTimeoutOrNull(5000) { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage From d0104797eb62c4c38cf618635c669ad52fa09c2b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 16 Apr 2025 11:14:08 +0300 Subject: [PATCH 38/41] feat: according designer feedback --- app/src/main/java/org/openedx/app/MainFragment.kt | 2 +- .../main/java/org/openedx/app/room/AppDatabase.kt | 6 +++--- .../openedx/course/data/storage/CourseConverter.kt | 13 ------------- .../course/presentation/dates/CourseDatesScreen.kt | 7 ++++++- .../openedx/dates/presentation/dates/DatesScreen.kt | 4 ++-- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index fdea40e77..397216b74 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -106,7 +106,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { add(R.id.fragmentDownloads to { DownloadsFragment() }) } if (viewModel.isDatesFragmentEnabled) { - add(R.id.fragmentDates to DatesFragment()) + add(R.id.fragmentDates to { DatesFragment() }) } add(R.id.fragmentProfile to { ProfileFragment() }) } 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 fd0461d8e..3a3316bd0 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -25,7 +25,7 @@ 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") @@ -42,14 +42,14 @@ const val DATABASE_NAME = "OpenEdX_db" CourseEnrollmentDetailsEntity::class, CourseDateEntity::class, VideoProgressEntity::class, - CourseDatesResponseEntity::class, CourseProgressEntity::class, ], autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), AutoMigration(3, 4), - AutoMigration(4, DATABASE_VERSION), + AutoMigration(4, 5), + AutoMigration(5, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 68829efd2..b49a806e6 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -4,7 +4,6 @@ import androidx.room.TypeConverter import com.google.common.reflect.TypeToken import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb -import org.openedx.core.data.model.room.CourseDateDB import org.openedx.core.data.model.room.GradingPolicyDb import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb @@ -84,16 +83,4 @@ class CourseConverter { @TypeConverter fun toGradeRangeMap(value: String): Map = Gson().fromJson(value, object : TypeToken>() {}.type) - - @TypeConverter - fun fromListOfCourseDateDB(value: List): String { - val json = Gson().toJson(value) - return json.toString() - } - - @TypeConverter - fun toListOfCourseDateDB(value: String): List { - val type = genericType>() - return Gson().fromJson(value, type) - } } 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 d98dad502..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 @@ -15,9 +15,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,6 +27,7 @@ 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.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -32,6 +35,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable @@ -47,8 +51,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -58,7 +64,6 @@ 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.course.CourseViewMode import org.openedx.core.presentation.dates.CourseDateBlockSection import org.openedx.core.presentation.dialog.alert.ActionDialogFragment import org.openedx.core.presentation.settings.calendarsync.CalendarSyncState 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 index 010f2b895..e5e9e2444 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -134,7 +134,8 @@ fun DatesScreen( LazyColumn( modifier = contentWidth.fillMaxSize(), state = scrollState, - contentPadding = PaddingValues(bottom = 48.dp) + contentPadding = PaddingValues(bottom = 48.dp, top = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { uiState.dates.keys.forEach { sectionKey -> val dates = uiState.dates[sectionKey].orEmpty() @@ -144,7 +145,6 @@ fun DatesScreen( if (isHavePastRelatedDates) { item { ShiftDueDatesCard( - modifier = Modifier.padding(top = 12.dp), isButtonEnabled = !uiState.isShiftDueDatesPressed, onClick = { onAction(DatesViewActions.ShiftDueDate) From 8dea5084163adbe66622ee590d36a84b648d00bc Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Dec 2025 16:44:05 +0200 Subject: [PATCH 39/41] fix: assignment default color fix --- .../core/data/model/CourseProgressResponse.kt | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) 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..5b8540e36 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,25 @@ 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 +119,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( From ed7ebdcb647234053d9812f9d04eb0ee5b4eb70c Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 4 Dec 2025 16:59:26 +0200 Subject: [PATCH 40/41] fix: empty state icon --- .../org.openedx.app.room.AppDatabase/6.json | 1206 +++++++++++++++++ .../core/data/model/CourseProgressResponse.kt | 1 - .../core/presentation/dates/DatesUI.kt | 3 +- .../dates/presentation/dates/DatesScreen.kt | 7 +- 4 files changed, 1213 insertions(+), 4 deletions(-) create mode 100644 app/schemas/org.openedx.app.room.AppDatabase/6.json 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/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index 5b8540e36..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 @@ -107,7 +107,6 @@ data class CourseProgressResponse( ) } - fun mapToRoomEntity() = GradingPolicyDb( assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), gradeRange = gradeRange ?: emptyMap(), 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/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt index e5e9e2444..89f38f13f 100644 --- a/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt +++ b/dates/src/main/java/org/openedx/dates/presentation/dates/DatesScreen.kt @@ -9,6 +9,7 @@ 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 @@ -22,6 +23,8 @@ 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 @@ -36,7 +39,6 @@ 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.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -271,7 +273,8 @@ private fun EmptyState( horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), + modifier = Modifier.size(100.dp), + imageVector = Icons.Outlined.CalendarMonth, tint = MaterialTheme.appColors.textFieldBorder, contentDescription = null ) From b754a663c1cb5ffa428fedba61564214f72b0ca6 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 10 Dec 2025 22:18:00 +0200 Subject: [PATCH 41/41] chore: refactor uiMessage flow --- .../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 +- .../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 +- .../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 +- 106 files changed, 1077 insertions(+), 1166 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/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 d373467a0..c4b720933 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 @@ -42,7 +42,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 @@ -70,7 +69,8 @@ class CourseContentAllViewModel( preferencesManager, workerController, coreAnalytics, - downloadHelper + downloadHelper, + resourceManager, ) { val isCourseDropdownNavigationEnabled get() = config.getCourseUIConfig().isCourseDropdownNavigationEnabled @@ -79,10 +79,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() @@ -143,7 +139,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) ) @@ -241,11 +237,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 { @@ -296,19 +290,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 3fa4f3d02..c1544be1d 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() @@ -77,7 +67,7 @@ class DatesViewModel( } catch (e: Exception) { page = -1 updateUIWithCachedResponse() - handleFetchException(e) + handleErrorUiMessage(e) } finally { clearLoadingState() } @@ -132,18 +122,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( @@ -166,7 +144,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/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() }