From 99d4cbc171fd0ab730eb9fcd3f0c92da5a601d90 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Feb 2025 20:20:17 +0200 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 76d84d859ca91247875b33e8a4f5b2794ef97123 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 22 Dec 2025 12:08:28 +0200 Subject: [PATCH 21/21] fix: string --- dates/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dates/src/main/res/values/strings.xml b/dates/src/main/res/values/strings.xml index 9aa26728d..93eaa1bc9 100644 --- a/dates/src/main/res/values/strings.xml +++ b/dates/src/main/res/values/strings.xml @@ -4,6 +4,6 @@ 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. + Don\'t worry - shift our suggested schedule to complete the due assignments without losing any progress. Shift Due Dates