From 19e44549e1dc154b774158c7c4162e8c78100690 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 13:14:12 +0200 Subject: [PATCH 01/29] feat: added downloads tab to main navigation --- app/build.gradle | 1 + .../main/java/org/openedx/app/AppAnalytics.kt | 4 + .../main/java/org/openedx/app/AppRouter.kt | 4 +- .../main/java/org/openedx/app/MainFragment.kt | 16 +- .../java/org/openedx/app/MainViewModel.kt | 6 + .../java/org/openedx/app/deeplink/HomeTab.kt | 1 + .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 19 +- app/src/main/res/menu/bottom_view_menu.xml | 6 + app/src/main/res/values/strings.xml | 1 + .../java/org/openedx/core/config/Config.kt | 5 + .../openedx/core/config/DownloadsConfig.kt | 8 + .../res/drawable/app_ic_download_cloud.xml | 9 + .../container/CourseContainerTab.kt | 4 +- default_config/dev/config.yaml | 3 + default_config/prod/config.yaml | 3 + default_config/stage/config.yaml | 3 + downloads/.gitignore | 1 + downloads/build.gradle | 64 +++++ downloads/consumer-rules.pro | 0 downloads/proguard-rules.pro | 7 + downloads/src/main/AndroidManifest.xml | 4 + .../downloads/presentation/DownloadsRouter.kt | 8 + .../presentation/dates/DownloadsFragment.kt | 246 ++++++++++++++++++ .../presentation/dates/DownloadsUIState.kt | 7 + .../presentation/dates/DownloadsViewModel.kt | 67 +++++ downloads/src/main/res/values/strings.xml | 4 + settings.gradle | 1 + 28 files changed, 499 insertions(+), 5 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/config/DownloadsConfig.kt create mode 100644 core/src/main/res/drawable/app_ic_download_cloud.xml create mode 100644 downloads/.gitignore create mode 100644 downloads/build.gradle create mode 100644 downloads/consumer-rules.pro create mode 100644 downloads/proguard-rules.pro create mode 100644 downloads/src/main/AndroidManifest.xml create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt create mode 100644 downloads/src/main/res/values/strings.xml diff --git a/app/build.gradle b/app/build.gradle index e863910ef..2c17ea1c1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -125,6 +125,7 @@ dependencies { implementation project(path: ':profile') implementation project(path: ':discussion') implementation project(path: ':whatsnew') + 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 0fe3ed4be..55b26b492 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" ), + DOWNLOADS( + "MainDashboard:Downloads", + "edx.bi.app.main_dashboard.downloads" + ), PROFILE( "MainDashboard:Profile", "edx.bi.app.main_dashboard.profile" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 0130d6b31..cfe1ecc44 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -44,6 +44,7 @@ import org.openedx.discussion.presentation.responses.DiscussionResponsesFragment import org.openedx.discussion.presentation.search.DiscussionSearchThreadFragment import org.openedx.discussion.presentation.threads.DiscussionAddThreadFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment +import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment @@ -67,7 +68,8 @@ class AppRouter : ProfileRouter, AppUpgradeRouter, WhatsNewRouter, - CalendarRouter { + CalendarRouter, + DownloadsRouter { // region AuthRouter override fun navigateToMain( diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 3ab735d27..00da800f2 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -17,6 +17,7 @@ import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.downloads.presentation.dates.DownloadsFragment import org.openedx.learn.presentation.LearnFragment import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment @@ -40,6 +41,9 @@ class MainFragment : Fragment(R.layout.fragment_main) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + if (!viewModel.isDownloadsFragmentEnabled) { + binding.bottomNavView.menu.removeItem(R.id.fragmentDownloads) + } initViewPager() @@ -55,9 +59,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.setCurrentItem(1, false) } + R.id.fragmentDownloads -> { + viewModel.logDownloadsTabClickedEvent() + binding.viewPager.setCurrentItem(2, false) + } + R.id.fragmentProfile -> { viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) + binding.viewPager.setCurrentItem(3, false) } } true @@ -100,6 +109,10 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.selectedItemId = R.id.fragmentDiscover } + HomeTab.DOWNLOADS.name -> { + binding.bottomNavView.selectedItemId = R.id.fragmentDownloads + } + HomeTab.PROFILE.name -> { binding.bottomNavView.selectedItemId = R.id.fragmentProfile } @@ -122,6 +135,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { adapter = NavigationFragmentAdapter(this).apply { addFragment(LearnFragment.newInstance(openTab = learnTab.name)) addFragment(viewModel.getDiscoveryFragment) + addFragment(DownloadsFragment()) addFragment(ProfileFragment()) } binding.viewPager.adapter = adapter diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 69c809b5c..2d2033769 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -33,6 +33,8 @@ class MainViewModel( val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() + val isDownloadsFragmentEnabled get() = config.getDownloadsConfig().isEnabled + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) notifier.notifier @@ -57,6 +59,10 @@ class MainViewModel( logScreenEvent(AppAnalyticsEvent.DISCOVER) } + fun logDownloadsTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DOWNLOADS) + } + 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 c020cf636..ce72703ad 100644 --- a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -4,5 +4,6 @@ enum class HomeTab { LEARN, PROGRAMS, DISCOVER, + 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 ce6e20cd9..5d6ab0ccd 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -68,6 +68,7 @@ import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import org.openedx.profile.data.storage.ProfilePreferences @@ -127,6 +128,7 @@ val appModule = module { single { get() } single { DeepLinkRouter(get(), get(), get(), get(), get(), 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 6b7692f99..c6028e664 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -54,6 +54,7 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.downloads.presentation.dates.DownloadsViewModel import org.openedx.foundation.presentation.WindowSize import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository @@ -190,7 +191,16 @@ val screenModule = module { profileRouter = get(), ) } - viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), get(), account) } + viewModel { (account: Account) -> + EditProfileViewModel( + get(), + get(), + get(), + get(), + get(), + account + ) + } viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } @@ -482,4 +492,11 @@ val screenModule = module { get(), ) } + + viewModel { + DownloadsViewModel( + downloadsRouter = get(), + networkConnection = get() + ) + } } diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml index f97e849f7..f970775ca 100644 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ b/app/src/main/res/menu/bottom_view_menu.xml @@ -13,6 +13,12 @@ android:icon="@drawable/app_ic_home" android:title="@string/app_navigation_discovery" /> + + Learn Programs Profile + Downloads 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 f240b9531..fd6e333f4 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -92,6 +92,10 @@ class Config(context: Context) { return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) } + fun getDownloadsConfig(): DownloadsConfig { + return getObjectOrNewInstance(DOWNLOADS, DownloadsConfig::class.java) + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -179,6 +183,7 @@ class Config(context: Context) { private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" + private const val DOWNLOADS = "DOWNLOADS" private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" diff --git a/core/src/main/java/org/openedx/core/config/DownloadsConfig.kt b/core/src/main/java/org/openedx/core/config/DownloadsConfig.kt new file mode 100644 index 000000000..94374bc11 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DownloadsConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DownloadsConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) diff --git a/core/src/main/res/drawable/app_ic_download_cloud.xml b/core/src/main/res/drawable/app_ic_download_cloud.xml new file mode 100644 index 000000000..8e623dc60 --- /dev/null +++ b/core/src/main/res/drawable/app_ic_download_cloud.xml @@ -0,0 +1,9 @@ + + + diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index 255b7e88b..b591c7ecf 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -4,9 +4,9 @@ import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.TextSnippet +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.CalendarMonth -import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector import org.openedx.core.ui.TabItem @@ -20,7 +20,7 @@ enum class CourseContainerTab( HOME(R.string.course_container_nav_home, Icons.Default.Home), VIDEOS(R.string.course_container_nav_videos, Icons.Rounded.PlayCircleFilled), DATES(R.string.course_container_nav_dates, Icons.Outlined.CalendarMonth), - OFFLINE(R.string.course_container_nav_downloads, Icons.Outlined.CloudDownload), + OFFLINE(R.string.course_container_nav_downloads, Icons.Filled.CloudDownload), DISCUSSIONS(R.string.course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), MORE(R.string.course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 4d1d694ec..8bd583150 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +DOWNLOADS: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 4d1d694ec..8bd583150 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +DOWNLOADS: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 4d1d694ec..8bd583150 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,6 +31,9 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' +DOWNLOADS: + ENABLED: true + FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false diff --git a/downloads/.gitignore b/downloads/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/downloads/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloads/build.gradle b/downloads/build.gradle new file mode 100644 index 000000000..f67f8b05a --- /dev/null +++ b/downloads/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.downloads' + + 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" +} \ No newline at end of file diff --git a/downloads/consumer-rules.pro b/downloads/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/downloads/proguard-rules.pro b/downloads/proguard-rules.pro new file mode 100644 index 000000000..dccbe504f --- /dev/null +++ b/downloads/proguard-rules.pro @@ -0,0 +1,7 @@ +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate \ No newline at end of file diff --git a/downloads/src/main/AndroidManifest.xml b/downloads/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/downloads/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt new file mode 100644 index 000000000..fd36f0222 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt @@ -0,0 +1,8 @@ +package org.openedx.downloads.presentation + +import androidx.fragment.app.FragmentManager + +interface DownloadsRouter { + + fun navigateToSettings(fm: FragmentManager) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt new file mode 100644 index 000000000..82c4dee1a --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt @@ -0,0 +1,246 @@ +package org.openedx.downloads.presentation.dates + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringResource +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.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.Toolbar +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.downloads.R +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +class DownloadsFragment : 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) + DownloadsScreen( + uiState = uiState, + uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DownloadsViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DownloadsViewActions.SwipeRefresh -> { + viewModel.refreshData() + } + } + } + ) + } + } + } + +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun DownloadsScreen( + uiState: DownloadsUIState, + uiMessage: UIMessage?, + hasInternetConnection: Boolean, + onAction: (DownloadsViewActions) -> 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(DownloadsViewActions.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + Toolbar( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.downloads), + canShowSettingsIcon = true, + onSettingsClick = { + onAction(DownloadsViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else if (uiState.courses.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) + ) { + + } + } + } + + 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(DownloadsViewActions.SwipeRefresh) + } + ) + } + } + } + ) +} + +@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 { + DownloadsScreen( + uiState = DownloadsUIState(isLoading = false), + uiMessage = null, + hasInternetConnection = true, + onAction = {} + ) + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt new file mode 100644 index 000000000..a49341558 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt @@ -0,0 +1,7 @@ +package org.openedx.downloads.presentation.dates + +data class DownloadsUIState( + val isLoading: Boolean = true, + val isRefreshing: Boolean = false, + val courses: List = emptyList() +) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt new file mode 100644 index 000000000..7c1d32acc --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt @@ -0,0 +1,67 @@ +package org.openedx.downloads.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.core.system.connection.NetworkConnection +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.presentation.UIMessage + +class DownloadsViewModel( + private val downloadsRouter: DownloadsRouter, + private val networkConnection: NetworkConnection, +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(DownloadsUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + fetchDates() + } + + private fun fetchDates() { + viewModelScope.launch { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false + ) + } + } + } + + fun refreshData() { + _uiState.update { state -> + state.copy( + isRefreshing = true + ) + } + fetchDates() + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + downloadsRouter.navigateToSettings(fragmentManager) + } +} + +interface DownloadsViewActions { + object OpenSettings : DownloadsViewActions + object SwipeRefresh : DownloadsViewActions +} diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml new file mode 100644 index 000000000..9aa02f001 --- /dev/null +++ b/downloads/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Downloads + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 40beee473..bdb401703 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,3 +46,4 @@ include ':discovery' include ':profile' include ':discussion' include ':whatsnew' +include ':downloads' From 6b0ead4c5f02664cf5c53e966dcde67d4e4c03d4 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Feb 2025 16:28:47 +0200 Subject: [PATCH 02/29] feat: course item UI --- .../java/org/openedx/core/ui/ComposeCommon.kt | 17 ++ .../presentation/dates/DownloadsFragment.kt | 197 +++++++++++++++++- downloads/src/main/res/values/strings.xml | 1 + 3 files changed, 212 insertions(+), 3 deletions(-) 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 aaaa0711d..b9280a8b1 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1404,6 +1404,23 @@ private fun RoundTab( } } +@Composable +fun OpenEdXDropdownMenuItem( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit +) { + Text( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) +} + @Preview @Composable private fun StaticSearchBarPreview() { diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt index 82c4dee1a..0f4c7b9b4 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt @@ -3,17 +3,35 @@ package org.openedx.downloads.presentation.dates import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.foundation.background 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.defaultMinSize 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.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -27,21 +45,33 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow 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 coil.compose.AsyncImage +import coil.request.ImageRequest import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXDropdownMenuItem import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.crop 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.downloads.R import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize @@ -144,7 +174,7 @@ private fun DownloadsScreen( ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } - } else if (uiState.courses.isEmpty()) { + } else if (false) { EmptyState() } else { Box( @@ -157,9 +187,14 @@ private fun DownloadsScreen( ) { LazyColumn( modifier = contentWidth, - contentPadding = PaddingValues(bottom = 20.dp) + contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp) ) { - + item { + CourseItem( + apiHostUrl = "", + onClick = {} + ) + } } } } @@ -191,6 +226,151 @@ private fun DownloadsScreen( ) } +@Composable +private fun CourseItem( + modifier: Modifier = Modifier, + apiHostUrl: String, + onClick: () -> Unit, +) { + var isDropdownExpanded by remember { mutableStateOf(false) } + val progress: Float = try { + 1.toFloat() / 2.toFloat() + } catch (_: ArithmeticException) { + 0f + } + Card( + modifier = modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + ) { + Box { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) +// .data(course.course.courseImage.toImageLink(apiHostUrl)) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 12.dp), + ) { + Text( + text = "course.name", + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + progress = progress, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.divider + ) + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Filled.CloudDone, + color = MaterialTheme.appColors.successGreen, + text = "qwe" + ) + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textPrimaryVariant, + text = "rty" + ) + Spacer(modifier = Modifier.height(8.dp)) + OpenEdXButton( + onClick = onClick, + content = { + IconText( + text = stringResource(R.string.downloads_download_course), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } + + Column( + modifier = Modifier + .align(Alignment.TopEnd), + ) { + MoreButton( + onClick = { + isDropdownExpanded = true + } + ) + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .defaultMinSize(minWidth = 269.dp) + .background(MaterialTheme.appColors.background), + expanded = isDropdownExpanded, + onDismissRequest = { isDropdownExpanded = false }, + ) { + Column { + OpenEdXDropdownMenuItem( + text = "Dropdown option1", + onClick = {} + ) + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + OpenEdXDropdownMenuItem( + text = "Dropdown option2", + onClick = {} + ) + } + } + } + + } + } +} + +@Composable +private fun MoreButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IconButton( + modifier = modifier, + onClick = onClick + ) { + Icon( + modifier = Modifier + .size(30.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(alpha = 0.5f), + shape = CircleShape + ) + .padding(4.dp), + imageVector = Icons.Default.MoreHoriz, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + @Composable private fun EmptyState( modifier: Modifier = Modifier @@ -244,3 +424,14 @@ private fun DatesScreenPreview() { ) } } + +@Preview +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + apiHostUrl = "", + onClick = {} + ) + } +} diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml index 9aa02f001..6a9ef1d92 100644 --- a/downloads/src/main/res/values/strings.xml +++ b/downloads/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Downloads + Download course \ No newline at end of file From d7501575f04deaa73c9a8f4201a97f065152d2b5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 24 Feb 2025 21:00:22 +0200 Subject: [PATCH 03/29] feat: download course list request --- .../java/org/openedx/app/di/ScreenModule.kt | 19 +++++- .../java/org/openedx/app/room/AppDatabase.kt | 5 +- core/build.gradle | 2 + .../org/openedx/core/data/api/CourseApi.kt | 6 ++ .../core/data/model/DownloadCoursePreview.kt | 34 +++++++++++ .../data/model/room/DownloadCoursePreview.kt | 28 +++++++++ .../domain/model/DownloadCoursePreview.kt | 8 +++ .../org/openedx/core/module/db/DownloadDao.kt | 7 +++ .../data/repository/DownloadRepository.kt | 26 ++++++++ .../domain/interactor/DownloadInteractor.kt | 9 +++ .../presentation/dates/DownloadsFragment.kt | 61 +++++++++++-------- .../presentation/dates/DownloadsUIState.kt | 4 +- .../presentation/dates/DownloadsViewModel.kt | 56 +++++++++++++++-- downloads/src/main/res/values/strings.xml | 2 + 14 files changed, 234 insertions(+), 33 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.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 c6028e664..fe0d4f855 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -54,6 +54,8 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.downloads.data.repository.DownloadRepository +import org.openedx.downloads.domain.interactor.DownloadInteractor import org.openedx.downloads.presentation.dates.DownloadsViewModel import org.openedx.foundation.presentation.WindowSize import org.openedx.learn.presentation.LearnViewModel @@ -493,10 +495,25 @@ val screenModule = module { ) } + single { + DownloadInteractor( + repository = get() + ) + } + single { + DownloadRepository( + api = get(), + corePreferences = get(), + dao = get() + ) + } viewModel { DownloadsViewModel( downloadsRouter = get(), - networkConnection = get() + networkConnection = get(), + interactor = get(), + resourceManager = get(), + config = 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 eec5b1811..1e7845d13 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -8,13 +8,14 @@ import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.CalendarDao import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter -import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.converter.DiscoveryConverter import org.openedx.discovery.data.model.room.CourseEntity @@ -32,6 +33,8 @@ const val DATABASE_NAME = "OpenEdX_db" OfflineXBlockProgress::class, CourseCalendarEventEntity::class, CourseCalendarStateEntity::class, + DownloadCoursePreview::class, + CourseCalendarStateEntity::class, CourseEnrollmentDetailsEntity::class ], autoMigrations = [ diff --git a/core/build.gradle b/core/build.gradle index f1ae6be5e..f7b5d1cec 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -119,6 +119,8 @@ dependencies { // OpenEdx libs api("com.github.openedx:openedx-app-foundation-android:1.0.0") + debugApi "androidx.compose.ui:ui-tooling:1.7.8" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' 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 8b5f0913a..50cd81d6b 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 @@ -9,6 +9,7 @@ import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseStructureModel +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 @@ -100,4 +101,9 @@ interface CourseApi { suspend fun getEnrollmentDetails( @Path("course_id") courseId: String, ): CourseEnrollmentDetails + + @GET("/api/mobile/v1/download_courses/{username}") + suspend fun getDownloadCoursesPreview( + @Path("username") username: String + ): List } diff --git a/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..2731b8b5d --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt @@ -0,0 +1,34 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.DownloadCoursePreview as EntityDownloadCoursePreview +import org.openedx.core.domain.model.DownloadCoursePreview as DomainDownloadCoursePreview + +data class DownloadCoursePreview( + @SerializedName("course_id") + val id: String, + @SerializedName("course_name") + val name: String?, + @SerializedName("course_image") + val image: String?, + @SerializedName("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } + + fun mapToRoomEntity(): EntityDownloadCoursePreview { + return EntityDownloadCoursePreview( + id = id, + name = name, + image = image, + totalSize = totalSize, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt new file mode 100644 index 000000000..b4806f0f3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.DownloadCoursePreview as DomainDownloadCoursePreview + +@Entity(tableName = "download_course_preview_table") +data class DownloadCoursePreview( + @PrimaryKey + @ColumnInfo("course_id") + val id: String, + @ColumnInfo("course_name") + val name: String?, + @ColumnInfo("course_image") + val image: String?, + @ColumnInfo("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..d4fccf4e0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt @@ -0,0 +1,8 @@ +package org.openedx.core.domain.model + +data class DownloadCoursePreview( + val id: String, + val name: String, + val image: String, + val totalSize: Long, +) diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index a07329e4d..a082554c0 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow +import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress @Dao @@ -46,4 +47,10 @@ interface DownloadDao { @Query("DELETE FROM offline_x_block_progress_table") suspend fun clearOfflineProgress() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDownloadCoursePreview(downloadCoursePreview: List) + + @Query("SELECT * FROM download_course_preview_table") + fun getDownloadCoursesPreview(): List } diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt new file mode 100644 index 000000000..ea60fddea --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -0,0 +1,26 @@ +package org.openedx.downloads.data.repository + +import kotlinx.coroutines.flow.flow +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.db.DownloadDao + +class DownloadRepository( + private val api: CourseApi, + private val dao: DownloadDao, + private val corePreferences: CorePreferences, +) { + fun getDownloadCoursesPreview(refresh: Boolean) = flow { + if (!refresh) { + val cachedDownloadCoursesPreview = dao.getDownloadCoursesPreview() + emit(cachedDownloadCoursesPreview.map { it.mapToDomain() }) + } + val username = corePreferences.user?.username ?: "" + val response = api.getDownloadCoursesPreview(username) + val downloadCoursesPreview = response.map { it.mapToDomain() } + val downloadCoursesPreviewEntity = response.map { it.mapToRoomEntity() } + dao.insertDownloadCoursePreview(downloadCoursesPreviewEntity) + emit(downloadCoursesPreview) + } + +} diff --git a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt new file mode 100644 index 000000000..b7545b2b0 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -0,0 +1,9 @@ +package org.openedx.downloads.domain.interactor + +import org.openedx.downloads.data.repository.DownloadRepository + +class DownloadInteractor( + private val repository: DownloadRepository +) { + fun getDownloadCoursesPreview(refresh: Boolean) = repository.getDownloadCoursesPreview(refresh) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt index 0f4c7b9b4..37863ca22 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -14,8 +15,10 @@ 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.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator @@ -50,6 +53,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -59,6 +63,7 @@ import androidx.fragment.app.Fragment import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText import org.openedx.core.ui.OfflineModeDialog @@ -73,6 +78,7 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.downloads.R +import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue @@ -99,6 +105,7 @@ class DownloadsFragment : Fragment() { DownloadsScreen( uiState = uiState, uiMessage = uiMessage, + apiHostUrl = viewModel.apiHostUrl, hasInternetConnection = viewModel.hasInternetConnection, onAction = { action -> when (action) { @@ -123,6 +130,7 @@ class DownloadsFragment : Fragment() { private fun DownloadsScreen( uiState: DownloadsUIState, uiMessage: UIMessage?, + apiHostUrl: String, hasInternetConnection: Boolean, onAction: (DownloadsViewActions) -> Unit, ) { @@ -174,7 +182,7 @@ private fun DownloadsScreen( ) { CircularProgressIndicator(color = MaterialTheme.appColors.primary) } - } else if (false) { + } else if (uiState.downloadCoursePreviews.isEmpty()) { EmptyState() } else { Box( @@ -187,11 +195,13 @@ private fun DownloadsScreen( ) { LazyColumn( modifier = contentWidth, - contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp) + contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) ) { - item { + items(uiState.downloadCoursePreviews) { CourseItem( - apiHostUrl = "", + downloadCoursePreview = it, + apiHostUrl = apiHostUrl, onClick = {} ) } @@ -229,6 +239,7 @@ private fun DownloadsScreen( @Composable private fun CourseItem( modifier: Modifier = Modifier, + downloadCoursePreview: DownloadCoursePreview, apiHostUrl: String, onClick: () -> Unit, ) { @@ -249,7 +260,7 @@ private fun CourseItem( Column { AsyncImage( model = ImageRequest.Builder(LocalContext.current) -// .data(course.course.courseImage.toImageLink(apiHostUrl)) + .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) .error(org.openedx.core.R.drawable.core_no_image_course) .placeholder(org.openedx.core.R.drawable.core_no_image_course) .build(), @@ -265,7 +276,7 @@ private fun CourseItem( .padding(top = 8.dp, bottom = 12.dp), ) { Text( - text = "course.name", + text = downloadCoursePreview.name, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textDark, overflow = TextOverflow.Ellipsis, @@ -328,7 +339,7 @@ private fun CourseItem( ) { Column { OpenEdXDropdownMenuItem( - text = "Dropdown option1", + text = stringResource(R.string.downloads_remove_course_downloads), onClick = {} ) Divider( @@ -336,7 +347,7 @@ private fun CourseItem( color = MaterialTheme.appColors.divider ) OpenEdXDropdownMenuItem( - text = "Dropdown option2", + text = stringResource(R.string.downloads_cancel_download), onClick = {} ) } @@ -375,20 +386,20 @@ private fun MoreButton( 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)) + 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") @@ -408,8 +419,8 @@ private fun EmptyState( // style = MaterialTheme.appTypography.labelMedium, // textAlign = TextAlign.Center // ) -// } -// } + } + } } @Preview @@ -419,6 +430,7 @@ private fun DatesScreenPreview() { DownloadsScreen( uiState = DownloadsUIState(isLoading = false), uiMessage = null, + apiHostUrl = "", hasInternetConnection = true, onAction = {} ) @@ -430,6 +442,7 @@ private fun DatesScreenPreview() { private fun CourseItemPreview() { OpenEdXTheme { CourseItem( + downloadCoursePreview = DownloadCoursePreview("", "name", "", 2000), apiHostUrl = "", onClick = {} ) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt index a49341558..615c6a1f3 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt @@ -1,7 +1,9 @@ package org.openedx.downloads.presentation.dates +import org.openedx.core.domain.model.DownloadCoursePreview + data class DownloadsUIState( val isLoading: Boolean = true, val isRefreshing: Boolean = false, - val courses: List = emptyList() + val downloadCoursePreviews: List = emptyList() ) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt index 7c1d32acc..89f932aec 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt @@ -2,24 +2,37 @@ package org.openedx.downloads.presentation.dates import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.openedx.core.R +import org.openedx.core.config.Config import org.openedx.core.system.connection.NetworkConnection +import org.openedx.downloads.domain.interactor.DownloadInteractor import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager class DownloadsViewModel( private val downloadsRouter: DownloadsRouter, private val networkConnection: NetworkConnection, + private val interactor: DownloadInteractor, + private val resourceManager: ResourceManager, + private val config: Config, ) : BaseViewModel() { + val apiHostUrl get() = config.getApiHostURL() + private val _uiState = MutableStateFlow(DownloadsUIState()) val uiState: StateFlow get() = _uiState.asStateFlow() @@ -33,17 +46,48 @@ class DownloadsViewModel( get() = networkConnection.isOnline() init { - fetchDates() + fetchDates(false) } - private fun fetchDates() { - viewModelScope.launch { + private fun fetchDates(refresh: Boolean) { + viewModelScope.launch(Dispatchers.IO) { _uiState.update { state -> state.copy( - isLoading = false, - isRefreshing = false + isLoading = !refresh, + isRefreshing = refresh ) } + interactor.getDownloadCoursesPreview(refresh) + .onCompletion { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false + ) + } + } + .catch { e -> + 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) + ) + ) + } + } + .collect { downloadCoursePreviews -> + _uiState.update { state -> + state.copy( + downloadCoursePreviews = downloadCoursePreviews + ) + } + } } } @@ -53,7 +97,7 @@ class DownloadsViewModel( isRefreshing = true ) } - fetchDates() + fetchDates(true) } fun onSettingsClick(fragmentManager: FragmentManager) { diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml index 6a9ef1d92..d1b2cf081 100644 --- a/downloads/src/main/res/values/strings.xml +++ b/downloads/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ Downloads Download course + Remove course downloads + Cancel download \ No newline at end of file From 7e392c86527fccceaaf90e9897748195e6d3c32a Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 25 Feb 2025 11:21:37 +0200 Subject: [PATCH 04/29] feat: downloads fragment empty state --- .../presentation/dates/DownloadsFragment.kt | 40 ++++++++++--------- downloads/src/main/res/values/strings.xml | 2 + 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt index 37863ca22..f6f91f47c 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt @@ -53,8 +53,10 @@ import androidx.compose.ui.layout.ContentScale 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 import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -400,25 +402,25 @@ private fun EmptyState( 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 -// ) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource(id = R.string.downloads_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.downloads_empty_state_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) } } } diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml index d1b2cf081..61e1de4a3 100644 --- a/downloads/src/main/res/values/strings.xml +++ b/downloads/src/main/res/values/strings.xml @@ -4,4 +4,6 @@ Download course Remove course downloads Cancel download + No Courses with Downloadable Content + You currently have no courses with downloadable content. \ No newline at end of file From 17a464fd5aeb0a74629cd3ad6f5c4f4f8122d6b4 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 4 Mar 2025 19:36:59 +0200 Subject: [PATCH 05/29] feat: downloading logic --- .../main/java/org/openedx/app/AppActivity.kt | 2 +- .../main/java/org/openedx/app/MainFragment.kt | 2 +- .../main/java/org/openedx/app/di/AppModule.kt | 2 +- .../java/org/openedx/app/di/ScreenModule.kt | 16 +- .../org/openedx/app/room/DatabaseManager.kt | 2 +- .../openedx/core}/data/storage/CourseDao.kt | 2 +- .../domain/interactor/ICourseInteractor.kt | 15 + .../org/openedx/core/domain/model/Block.kt | 8 + .../domain/model/DownloadDialogResource.kt | 2 +- .../module/download/BaseDownloadViewModel.kt | 54 ++-- .../DownloadConfirmDialogFragment.kt | 14 +- .../DownloadConfirmDialogType.kt | 2 +- .../downloaddialog}/DownloadDialogItem.kt | 2 +- .../downloaddialog}/DownloadDialogManager.kt | 31 +- .../downloaddialog}/DownloadDialogUIState.kt | 2 +- .../DownloadErrorDialogFragment.kt | 24 +- .../DownloadErrorDialogType.kt | 2 +- .../DownloadStorageErrorDialogFragment.kt | 32 +- .../dialog/downloaddialog}/DownloadView.kt | 2 +- .../res/drawable/core_download_waiting.png | Bin .../src/main/res/drawable/core_ic_error.xml | 0 core/src/main/res/values/strings.xml | 40 +++ .../data/repository/CourseRepository.kt | 8 +- .../domain/interactor/CourseInteractor.kt | 11 +- .../offline/CourseOfflineScreen.kt | 7 +- .../offline/CourseOfflineViewModel.kt | 7 +- .../outline/CourseOutlineViewModel.kt | 17 +- .../course/presentation/ui/CourseUI.kt | 6 +- .../course/presentation/ui/CourseVideosUI.kt | 22 +- .../unit/NotAvailableUnitFragment.kt | 10 +- .../videos/CourseVideoViewModel.kt | 31 +- .../download/DownloadQueueFragment.kt | 9 +- .../download/DownloadQueueViewModel.kt | 3 +- course/src/main/res/values/strings.xml | 38 --- .../outline/CourseOutlineViewModelTest.kt | 12 +- .../videos/CourseVideoViewModelTest.kt | 8 +- downloads/build.gradle | 1 + .../data/repository/DownloadRepository.kt | 30 ++ .../domain/interactor/DownloadInteractor.kt | 6 + .../presentation/dates/DownloadsUIState.kt | 9 - .../presentation/dates/DownloadsViewModel.kt | 111 ------- .../{dates => download}/DownloadsFragment.kt | 238 +++++++++++---- .../presentation/download/DownloadsUIState.kt | 13 + .../download/DownloadsViewModel.kt | 273 ++++++++++++++++++ downloads/src/main/res/values/strings.xml | 3 + 45 files changed, 766 insertions(+), 363 deletions(-) rename {course/src/main/java/org/openedx/course => core/src/main/java/org/openedx/core}/data/storage/CourseDao.kt (96%) create mode 100644 core/src/main/java/org/openedx/core/domain/interactor/ICourseInteractor.kt rename {course/src/main/java/org/openedx/course => core/src/main/java/org/openedx/core}/domain/model/DownloadDialogResource.kt (81%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadConfirmDialogFragment.kt (95%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadConfirmDialogType.kt (74%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadDialogItem.kt (83%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadDialogManager.kt (92%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadDialogUIState.kt (89%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadErrorDialogFragment.kt (93%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadErrorDialogType.kt (74%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadStorageErrorDialogFragment.kt (90%) rename {course/src/main/java/org/openedx/course/presentation/download => core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog}/DownloadView.kt (97%) rename course/src/main/res/drawable/course_download_waiting.png => core/src/main/res/drawable/core_download_waiting.png (100%) rename course/src/main/res/drawable/course_ic_error.xml => core/src/main/res/drawable/core_ic_error.xml (100%) delete mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt delete mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt rename downloads/src/main/java/org/openedx/downloads/presentation/{dates => download}/DownloadsFragment.kt (61%) create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 19c096338..cbb496501 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -27,11 +27,11 @@ import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.ApiConstants import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.utils.Logger import org.openedx.core.worker.CalendarSyncScheduler -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.requestApplyInsetsWhenAttached import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 00da800f2..be0ed42e4 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -17,7 +17,7 @@ import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.discovery.presentation.DiscoveryRouter -import org.openedx.downloads.presentation.dates.DownloadsFragment +import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment 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 5d6ab0ccd..148395665 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -42,6 +42,7 @@ import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter @@ -58,7 +59,6 @@ import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics 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 fe0d4f855..82325642c 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -13,6 +13,7 @@ import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.interactor.ICourseInteractor import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.core.repository.CalendarRepository @@ -56,7 +57,7 @@ import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel import org.openedx.downloads.data.repository.DownloadRepository import org.openedx.downloads.domain.interactor.DownloadInteractor -import org.openedx.downloads.presentation.dates.DownloadsViewModel +import org.openedx.downloads.presentation.download.DownloadsViewModel import org.openedx.foundation.presentation.WindowSize import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository @@ -232,6 +233,7 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -504,7 +506,8 @@ val screenModule = module { DownloadRepository( api = get(), corePreferences = get(), - dao = get() + dao = get(), + courseDao = get() ) } viewModel { @@ -513,7 +516,14 @@ val screenModule = module { networkConnection = get(), interactor = get(), resourceManager = get(), - config = get() + config = get(), + preferencesManager = get(), + coreAnalytics = get(), + downloadDao = get(), + workerController = get(), + downloadHelper = get(), + downloadDialogManager = get(), + fileUtil = get() ) } } diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index bcc123763..d24eb54f9 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -4,8 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.openedx.core.DatabaseManager +import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.DownloadDao -import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.storage.DiscoveryDao diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt similarity index 96% rename from course/src/main/java/org/openedx/course/data/storage/CourseDao.kt rename to core/src/main/java/org/openedx/core/data/storage/CourseDao.kt index 8c2d94f03..1ce813242 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseDao.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -1,4 +1,4 @@ -package org.openedx.course.data.storage +package org.openedx.core.data.storage import androidx.room.Dao import androidx.room.Insert diff --git a/core/src/main/java/org/openedx/core/domain/interactor/ICourseInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/ICourseInteractor.kt new file mode 100644 index 000000000..4a7781a2c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/ICourseInteractor.kt @@ -0,0 +1,15 @@ +package org.openedx.core.domain.interactor + +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadModel + +interface ICourseInteractor { + suspend fun getCourseStructure( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure + + suspend fun getAllDownloadModels(): List +} \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index ba7b91a41..d2c36a0f3 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -81,6 +81,14 @@ data class Block( return count } + fun getFileSize(): Long { + return when { + type == BlockType.VIDEO -> downloadModel?.size ?: 0L + isxBlock -> offlineDownload?.fileSize ?: 0L + else -> 0L + } + } + val isVideoBlock get() = type == BlockType.VIDEO val isDiscussionBlock get() = type == BlockType.DISCUSSION val isHTMLBlock get() = type == BlockType.HTML diff --git a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt similarity index 81% rename from course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt rename to core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt index cded4944a..a0666f2b1 100644 --- a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt @@ -1,4 +1,4 @@ -package org.openedx.course.domain.model +package org.openedx.core.domain.model import androidx.compose.ui.graphics.painter.Painter diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 0fcf962a3..1e685c83e 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -19,7 +19,6 @@ import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.foundation.presentation.BaseViewModel abstract class BaseDownloadViewModel( - private val courseId: String, private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, @@ -66,8 +65,8 @@ abstract class BaseDownloadViewModel( updateParentStatus(parentId, children.size, downloadingCount, downloadedCount) } - downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } - _downloadingModelsFlow.emit(downloadingModelsList) +// downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } + _downloadingModelsFlow.emit(models) } private fun updateChildrenStatus( @@ -116,6 +115,10 @@ abstract class BaseDownloadViewModel( allBlocks.putAll(list.map { it.id to it }) } + protected fun addBlocks(list: List) { + allBlocks.putAll(list.map { it.id to it }) + } + fun isBlockDownloading(id: String): Boolean { val blockDownloadingState = downloadModelsStatus[id] return blockDownloadingState?.isWaitingOrDownloading == true @@ -126,22 +129,22 @@ abstract class BaseDownloadViewModel( return blockDownloadingState == DownloadedState.DOWNLOADED } - open fun saveDownloadModels(folder: String, id: String) { + open fun saveDownloadModels(folder: String, courseId: String, id: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - logSubsectionDownloadEvent(id, saveBlocksIds.size) - saveDownloadModels(folder, saveBlocksIds) + logSubsectionDownloadEvent(id, saveBlocksIds.size, courseId) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - open fun saveAllDownloadModels(folder: String) { + open fun saveAllDownloadModels(folder: String, courseId: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap.values.flatten() - saveDownloadModels(folder, saveBlocksIds) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + suspend fun saveDownloadModels(folder: String, courseId: String, saveBlocksIds: List) { val downloadModels = mutableListOf() val downloadModelList = getDownloadModelList() for (blockId in saveBlocksIds) { @@ -200,10 +203,10 @@ abstract class BaseDownloadViewModel( fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] - open fun removeDownloadModels(blockId: String) { + open fun removeDownloadModels(blockId: String, courseId: String) { viewModelScope.launch { val downloadableChildren = downloadableChildrenMap[blockId] ?: listOf() - logSubsectionDeleteEvent(blockId, downloadableChildren.size) + logSubsectionDeleteEvent(blockId, downloadableChildren.size, courseId) workerController.removeModels(downloadableChildren) } } @@ -242,36 +245,51 @@ abstract class BaseDownloadViewModel( downloadableChildrenMap[parentId] = children + childId } - fun logBulkDownloadToggleEvent(toggle: Boolean) { + fun logBulkDownloadToggleEvent(toggle: Boolean, courseId: String) { logEvent( CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, buildMap { put(CoreAnalyticsKey.ACTION.key, toggle) - } + }, + courseId ) } - private fun logSubsectionDownloadEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDownloadEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DOWNLOAD_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - } + }, + courseId ) } - private fun logSubsectionDeleteEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDeleteEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DELETE_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - } + }, + courseId ) } - private fun logEvent(event: CoreAnalyticsEvent, param: Map = emptyMap()) { + private fun logEvent( + event: CoreAnalyticsEvent, + param: Map = emptyMap(), + courseId: String + ) { analytics.logEvent( event.eventName, buildMap { diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt similarity index 95% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index c591966f4..8e93b6f5e 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color import android.graphics.drawable.ColorDrawable @@ -33,6 +33,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.IconText @@ -41,13 +43,10 @@ import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.foundation.extension.parcelable import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager import androidx.compose.ui.graphics.Color as ComposeColor -import org.openedx.core.R as coreR class DownloadConfirmDialogFragment : DialogFragment() { @@ -66,7 +65,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { val sizeSumString = uiState.sizeSum.toFileSize(1, false) val dialogData = when (dialogType) { DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( - title = stringResource(id = coreR.string.course_confirm_download), + title = stringResource(id = R.string.course_confirm_download), description = stringResource( id = R.string.course_download_confirm_dialog_description, sizeSumString @@ -79,7 +78,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { id = R.string.course_download_on_cellural_dialog_description, sizeSumString ), - icon = painterResource(id = coreR.drawable.core_ic_warning), + icon = painterResource(id = R.drawable.core_ic_warning), ) DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( @@ -112,7 +111,6 @@ class DownloadConfirmDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadConfirmDialogFragment" const val ARG_DIALOG_TYPE = "dialogType" const val ARG_UI_STATE = "uiState" @@ -216,7 +214,7 @@ private fun DownloadConfirmDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), + text = stringResource(id = R.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt similarity index 74% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt index 9c0833ff3..a14a1033c 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt similarity index 83% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt index 9f3cfc4d4..2e29ccec4 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import androidx.compose.ui.graphics.vector.ImageVector diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt similarity index 92% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 434f74c67..7604d919d 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import androidx.fragment.app.FragmentManager import kotlinx.coroutines.CoroutineScope @@ -7,17 +7,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.ICourseInteractor import org.openedx.core.domain.model.Block import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadModel import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.course.domain.interactor.CourseInteractor class DownloadDialogManager( private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, - private val interactor: CourseInteractor, + private val interactor: ICourseInteractor, private val workerController: DownloadWorkerController ) { @@ -87,7 +87,7 @@ class DownloadDialogManager( isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean = false, fragmentManager: FragmentManager, - removeDownloadModels: (blockId: String) -> Unit, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, ) { createDownloadItems( @@ -150,7 +150,7 @@ class DownloadDialogManager( val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds } - val totalSize = blocks.sumOf { getFileSize(it) } + val totalSize = blocks.sumOf { it.getFileSize() } if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionBlock) if (totalSize > 0) { @@ -188,7 +188,7 @@ class DownloadDialogManager( fragmentManager: FragmentManager, isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean, - removeDownloadModels: (blockId: String) -> Unit, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, ) { coroutineScope.launch { @@ -204,7 +204,7 @@ class DownloadDialogManager( (!onlyVideoBlocks || it.type == BlockType.VIDEO) } } - val size = blocks.sumOf { getFileSize(it) } + val size = blocks.sumOf { it.getFileSize() } if (size > 0) DownloadDialogItem(title = subSectionBlock.displayName, size = size) else null } @@ -215,18 +215,17 @@ class DownloadDialogManager( isDownloadFailed = false, sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, - removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, + removeDownloadModels = { + subSectionsBlocks.forEach { + removeDownloadModels( + it.id, + courseId + ) + } + }, saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } ) ) } } - - private fun getFileSize(block: Block): Long { - return when { - block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0L - block.isxBlock -> block.offlineDownload?.fileSize ?: 0L - else -> 0L - } - } } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt similarity index 89% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt index b58e856bd..915bebfe5 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import androidx.fragment.app.FragmentManager diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt similarity index 93% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index 96cdf3d40..1de8b611a 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color import android.graphics.drawable.ColorDrawable @@ -29,6 +29,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXButton @@ -36,11 +38,8 @@ import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.foundation.extension.parcelable import org.openedx.foundation.system.PreviewFragmentManager -import org.openedx.core.R as coreR class DownloadErrorDialogFragment : DialogFragment() { @@ -58,21 +57,21 @@ class DownloadErrorDialogFragment : DialogFragment() { val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme val downloadDialogResource = when (dialogType) { DownloadErrorDialogType.NO_CONNECTION -> DownloadDialogResource( - title = stringResource(id = coreR.string.core_no_internet_connection), + title = stringResource(id = R.string.core_no_internet_connection), description = stringResource(id = R.string.course_download_no_internet_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( title = stringResource(id = R.string.course_wifi_required), description = stringResource(id = R.string.course_download_wifi_required_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( title = stringResource(id = R.string.course_download_failed), description = stringResource(id = R.string.course_download_failed_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + icon = painterResource(id = R.drawable.core_ic_error), ) } @@ -93,7 +92,6 @@ class DownloadErrorDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadErrorDialogFragment" const val ARG_DIALOG_TYPE = "dialogType" const val ARG_UI_STATE = "uiState" @@ -122,8 +120,8 @@ private fun DownloadErrorDialogView( ) { val scrollState = rememberScrollState() val dismissButtonText = when (dialogType) { - DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = coreR.string.core_cancel) - else -> stringResource(id = coreR.string.core_close) + DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = R.string.core_cancel) + else -> stringResource(id = R.string.core_close) } DefaultDialogBox( modifier = modifier, @@ -167,7 +165,7 @@ private fun DownloadErrorDialogView( ) if (dialogType == DownloadErrorDialogType.DOWNLOAD_FAILED) { OpenEdXButton( - text = stringResource(id = coreR.string.core_error_try_again), + text = stringResource(id = R.string.core_error_try_again), backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = onTryAgainClick, ) @@ -194,7 +192,7 @@ private fun DownloadErrorDialogViewPreview() { downloadDialogResource = DownloadDialogResource( title = "Title", description = "Description Description Description Description Description Description Description ", - icon = painterResource(id = R.drawable.course_ic_error) + icon = painterResource(id = R.drawable.core_ic_error) ), uiState = DownloadDialogUIState( downloadDialogItems = listOf( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt similarity index 74% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt index 85f01cf1a..5bb035f07 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt similarity index 90% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index 5b99e6123..7615ab111 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color import android.graphics.drawable.ColorDrawable @@ -41,21 +41,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.core.presentation.dialog.downloaddialog.DownloadStorageErrorDialogFragment.Companion.STORAGE_BAR_MIN_SIZE import org.openedx.core.system.StorageManager import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource -import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR -import org.openedx.course.presentation.download.DownloadStorageErrorDialogFragment.Companion.STORAGE_BAR_MIN_SIZE import org.openedx.foundation.extension.parcelable import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager -import org.openedx.core.R as coreR class DownloadStorageErrorDialogFragment : DialogFragment() { @@ -72,7 +71,7 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { val downloadDialogResource = DownloadDialogResource( title = stringResource(id = R.string.course_device_storage_full), description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadStorageErrorDialogView( @@ -87,7 +86,6 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadStorageErrorDialogFragment" const val ARG_UI_STATE = "uiState" const val STORAGE_BAR_MIN_SIZE = 0.1f @@ -158,7 +156,7 @@ private fun DownloadStorageErrorDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), + text = stringResource(id = R.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, @@ -214,7 +212,12 @@ private fun StorageBar( modifier = Modifier .weight(freePercentage) .fillMaxHeight() - .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) + .padding( + top = boxPadding, + bottom = boxPadding, + start = boxPadding, + end = boxPadding / 2 + ) .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) .background(MaterialTheme.appColors.cardViewBorder) ) @@ -222,7 +225,12 @@ private fun StorageBar( modifier = Modifier .weight(animReqPercentage.value) .fillMaxHeight() - .padding(top = boxPadding, bottom = boxPadding, end = boxPadding, start = boxPadding / 2) + .padding( + top = boxPadding, + bottom = boxPadding, + end = boxPadding, + start = boxPadding / 2 + ) .clip(RoundedCornerShape(topEnd = cornerRadius, bottomEnd = cornerRadius)) .background(MaterialTheme.appColors.error) ) @@ -258,7 +266,7 @@ private fun DownloadStorageErrorDialogViewPreview() { downloadDialogResource = DownloadDialogResource( title = "Title", description = "Description Description Description Description Description Description Description ", - icon = painterResource(id = R.drawable.course_ic_error) + icon = painterResource(id = R.drawable.core_ic_error) ), uiState = DownloadDialogUIState( downloadDialogItems = listOf( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt similarity index 97% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt index fd70dd723..4469f0b8e 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/course/src/main/res/drawable/course_download_waiting.png b/core/src/main/res/drawable/core_download_waiting.png similarity index 100% rename from course/src/main/res/drawable/course_download_waiting.png rename to core/src/main/res/drawable/core_download_waiting.png diff --git a/course/src/main/res/drawable/course_ic_error.xml b/core/src/main/res/drawable/core_ic_error.xml similarity index 100% rename from course/src/main/res/drawable/course_ic_error.xml rename to core/src/main/res/drawable/core_ic_error.xml diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index f15a693bb..fab9ea55e 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -187,6 +187,46 @@ Not Synced Syncing to calendar… Next + + Downloads + (Untitled) + Download + The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? + Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? + Are you sure you want to delete all video(s) for \"%s\"? + Are you sure you want to delete video(s) for \"%s\"? + %1$s - %2$s - %3$d / %4$d + Downloading this content requires an active internet connection. Please connect to the internet and try again. + Wi-Fi Required + Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + Download Failed + Unfortunately, this content failed to download. Please try again later or report this issue. + Downloading this %1$s of content will save available blocks offline. + Download on Cellular? + Downloading this content will use %1$s of cellular data. + Remove Offline Content? + Removing this content will free up %1$s. + Download + Remove + Device Storage Full + Your device does not have enough free space to download this content. Please free up some space and try again. + %1$s used, %2$s free + 0MB + Available to download + None of this course’s content is currently available to download offline. + Download all + Downloaded + Ready to Download + You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + Downloading + Largest Downloads + Remove all downloads + Cancel Course Download + This component is not yet available offline + Explore other parts of this course or view this when you reconnect. + This component is not downloaded + Explore other parts of this course or download this when you reconnect. + Authorization Please enter the system to continue with course enrollment. diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index bc508821d..bf39cc80c 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -9,6 +9,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.room.OfflineXBlockProgress import org.openedx.core.data.model.room.XBlockProgressData import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesResult @@ -18,7 +19,6 @@ import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.channelFlowWithAwait import org.openedx.core.module.db.DownloadDao import org.openedx.core.system.connection.NetworkConnection -import org.openedx.course.data.storage.CourseDao import java.net.URLDecoder import java.nio.charset.StandardCharsets @@ -218,7 +218,11 @@ class CourseRepository( submitOfflineXBlockProgress(blockId, courseId, jsonProgressData) } - private suspend fun submitOfflineXBlockProgress(blockId: String, courseId: String, jsonProgressData: String?) { + private suspend fun submitOfflineXBlockProgress( + blockId: String, + courseId: String, + jsonProgressData: String? + ) { if (!jsonProgressData.isNullOrEmpty()) { val parts = mutableListOf() val decodedQuery = URLDecoder.decode(jsonProgressData, StandardCharsets.UTF_8.name()) diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 4678c9115..9a31ff083 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -2,6 +2,7 @@ package org.openedx.course.domain.interactor import kotlinx.coroutines.flow.Flow import org.openedx.core.BlockType +import org.openedx.core.domain.interactor.ICourseInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure @@ -10,7 +11,7 @@ import org.openedx.course.data.repository.CourseRepository @Suppress("TooManyFunctions") class CourseInteractor( private val repository: CourseRepository -) { +) : ICourseInteractor { suspend fun getCourseStructureFlow( courseId: String, @@ -19,14 +20,14 @@ class CourseInteractor( return repository.getCourseStructureFlow(courseId, forceRefresh) } - suspend fun getCourseStructure( + override suspend fun getCourseStructure( courseId: String, - isNeedRefresh: Boolean = false + isNeedRefresh: Boolean ): CourseStructure { return repository.getCourseStructure(courseId, isNeedRefresh) } - suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + override suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { return repository.getCourseStructureFromCache(courseId) } @@ -101,7 +102,7 @@ class CourseInteractor( fun getDownloadModels() = repository.getDownloadModels() - suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + override suspend fun getAllDownloadModels() = repository.getAllDownloadModels() suspend fun saveXBlockProgress(blockId: String, courseId: String, jsonProgress: String) { repository.saveOfflineXBlockProgress(blockId, courseId, jsonProgress) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index e7c69397a..bbad78826 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager +import org.openedx.core.R import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -59,12 +60,10 @@ import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.core.R as coreR @Composable fun CourseOfflineScreen( @@ -223,9 +222,9 @@ private fun LargestDownloads( mutableStateOf(false) } val text = if (!isEditingEnabled) { - stringResource(coreR.string.core_edit) + stringResource(R.string.core_edit) } else { - stringResource(coreR.string.core_label_done) + stringResource(R.string.core_label_done) } LaunchedEffect(isDownloading) { diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 19d67f79b..13cf24640 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -21,10 +21,10 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.course.presentation.download.DownloadDialogItem -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.utils.FileUtil @@ -41,7 +41,6 @@ class CourseOfflineViewModel( workerController: DownloadWorkerController, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - courseId, downloadDao, preferencesManager, workerController, @@ -100,7 +99,7 @@ class CourseOfflineViewModel( fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) } ) } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index 916213026..50fedd2dc 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -28,6 +28,7 @@ import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent @@ -40,7 +41,6 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent import org.openedx.course.presentation.CourseAnalyticsKey import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -65,7 +65,6 @@ class CourseOutlineViewModel( workerController: DownloadWorkerController, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - courseId, downloadDao, preferencesManager, workerController, @@ -136,10 +135,10 @@ class CourseOutlineViewModel( getCourseData() } - override fun saveDownloadModels(folder: String, id: String) { + override fun saveDownloadModels(folder: String, courseId: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { _uiMessage.emit( @@ -150,7 +149,7 @@ class CourseOutlineViewModel( } } } else { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } } @@ -472,7 +471,7 @@ class CourseOutlineViewModel( fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) } ) } @@ -499,7 +498,11 @@ class CourseOutlineViewModel( outdatedBlockIds.forEach { blockId -> interactor.removeDownloadModel(blockId) } - saveDownloadModels(fileUtil.getExternalAppDir().path, outdatedBlockIds) + saveDownloadModels( + fileUtil.getExternalAppDir().path, + courseId, + outdatedBlockIds + ) } isOfflineBlocksUpToDate = true } diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 1a6cd60a7..0806d9c22 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -246,7 +246,7 @@ fun OfflineQueueCard( .weight(1f) ) { Text( - text = downloadModel.title.ifEmpty { stringResource(id = R.string.course_download_untitled) }, + text = downloadModel.title.ifEmpty { stringResource(id = coreR.string.course_download_untitled) }, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, overflow = TextOverflow.Ellipsis, @@ -748,7 +748,7 @@ fun CourseExpandableChapterCard( ) } else if (downloadedState == DownloadedState.WAITING) { Icon( - painter = painterResource(id = R.drawable.course_download_waiting), + painter = painterResource(id = coreR.drawable.core_download_waiting), contentDescription = stringResource( id = R.string.course_accessibility_stop_downloading_course_section ), @@ -832,7 +832,7 @@ fun CourseSubSectionItem( if (isAssignmentEnable) { val assignmentString = stringResource( - R.string.course_subsection_assignment_info, + coreR.string.course_subsection_assignment_info, block.assignmentProgress?.assignmentType ?: "", stringResource(id = coreR.string.core_date_format_assignment_due, due), block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 5e2c0b8fa..6c9bbe9b5 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -71,7 +71,6 @@ import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState import org.openedx.foundation.extension.toFileSize @@ -81,6 +80,7 @@ import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue import org.openedx.foundation.utils.FileUtil import java.util.Date +import org.openedx.core.R as coreR @Composable fun CourseVideosScreen( @@ -132,12 +132,15 @@ fun CourseVideosScreen( ) }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + viewModel.logBulkDownloadToggleEvent( + !isAllBlocksDownloadedOrDownloading, + viewModel.courseId + ) if (isAllBlocksDownloadedOrDownloading) { viewModel.removeAllDownloadModels() } else { viewModel.saveAllDownloadModels( - fileUtil.getExternalAppDir().path + fileUtil.getExternalAppDir().path, viewModel.courseId ) } }, @@ -308,12 +311,12 @@ private fun CourseVideosUI( AlertDialog( title = { Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_title) + text = stringResource(id = coreR.string.course_download_big_files_confirmation_title) ) }, text = { Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_text) + text = stringResource(id = coreR.string.course_download_big_files_confirmation_text) ) }, onDismissRequest = { @@ -344,14 +347,15 @@ private fun CourseVideosUI( } if (isDeleteDownloadsConfirmationShowed) { - val downloadModelsSize = (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize + val downloadModelsSize = + (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize val isDownloadedAllVideos = downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && downloadModelsSize.remainingCount == 0 val dialogTextId = if (isDownloadedAllVideos) { - R.string.course_delete_confirmation + coreR.string.course_delete_confirmation } else { - R.string.course_delete_in_process_confirmation + coreR.string.course_delete_in_process_confirmation } AlertDialog( @@ -402,7 +406,7 @@ private fun CourseVideosUI( text = { Text( text = stringResource( - id = R.string.course_delete_download_confirmation_text, + id = coreR.string.course_delete_download_confirmation_text, deleteDownloadBlock?.displayName ?: "" ) ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt index 5fe50a0e6..f4e916f6e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -45,6 +45,7 @@ import org.openedx.foundation.extension.parcelable import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR import org.openedx.course.R as courseR class NotAvailableUnitFragment : Fragment() { @@ -80,14 +81,15 @@ class NotAvailableUnitFragment : Fragment() { } NotAvailableUnitType.OFFLINE_UNSUPPORTED -> { - title = stringResource(id = courseR.string.course_not_available_offline) - description = stringResource(id = courseR.string.course_explore_other_parts_when_reconnect) + title = stringResource(id = coreR.string.course_not_available_offline) + description = + stringResource(id = coreR.string.course_explore_other_parts_when_reconnect) } NotAvailableUnitType.NOT_DOWNLOADED -> { - title = stringResource(id = courseR.string.course_not_downloaded) + title = stringResource(id = coreR.string.course_not_downloaded) description = - stringResource(id = courseR.string.course_explore_other_parts_when_reconnect_or_download) + stringResource(id = coreR.string.course_explore_other_parts_when_reconnect_or_download) } else -> { diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 809a399eb..242b667b7 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -19,6 +19,7 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -29,7 +30,6 @@ import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -53,7 +53,6 @@ class CourseVideoViewModel( workerController: DownloadWorkerController, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - courseId, downloadDao, preferencesManager, workerController, @@ -123,10 +122,10 @@ class CourseVideoViewModel( getVideos() } - override fun saveDownloadModels(folder: String, id: String) { + override fun saveDownloadModels(folder: String, courseId: String, id: String) { if (preferencesManager.videoSettings.wifiDownloadOnly) { if (networkConnection.isWifiConnected()) { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } else { viewModelScope.launch { _uiMessage.emit( @@ -137,11 +136,11 @@ class CourseVideoViewModel( } } } else { - super.saveDownloadModels(folder, id) + super.saveDownloadModels(folder, courseId, id) } } - override fun saveAllDownloadModels(folder: String) { + override fun saveAllDownloadModels(folder: String, courseId: String) { if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { viewModelScope.launch { _uiMessage.emit( @@ -151,7 +150,7 @@ class CourseVideoViewModel( return } - super.saveAllDownloadModels(folder) + super.saveAllDownloadModels(folder, courseId) } fun getVideos() { @@ -261,10 +260,12 @@ class CourseVideoViewModel( viewModelScope.launch { val courseData = _uiState.value as? CourseVideosUIState.CourseData ?: return@launch - val subSectionsBlocks = courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } + val subSectionsBlocks = + courseData.courseSubSections.values.flatten().filter { it.id in blocksIds } val blocks = subSectionsBlocks.flatMap { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } allBlocks.values.filter { it.id in verticalBlocks.flatMap { it.descendants } } } @@ -273,9 +274,12 @@ class CourseVideoViewModel( val isAllBlocksDownloaded = downloadableBlocks.all { isBlockDownloaded(it.id) } val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSectionsBlock -> - val verticalBlocks = allBlocks.values.filter { it.id in subSectionsBlock.descendants } + val verticalBlocks = + allBlocks.values.filter { it.id in subSectionsBlock.descendants } val notDownloadedBlocks = allBlocks.values.filter { - it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded(it.id) + it.id in verticalBlocks.flatMap { it.descendants } && it.isDownloadable && !isBlockDownloaded( + it.id + ) } if (notDownloadedBlocks.isNotEmpty()) subSectionsBlock else null } @@ -285,7 +289,8 @@ class CourseVideoViewModel( } if (downloadingBlocks.isNotEmpty()) { - val downloadableChildren = downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } + val downloadableChildren = + downloadingBlocks.flatMap { getDownloadableChildren(it).orEmpty() } if (config.getCourseUIConfig().isCourseDownloadQueueEnabled) { courseRouter.navigateToDownloadQueue(fragmentManager, downloadableChildren) } else { @@ -304,7 +309,7 @@ class CourseVideoViewModel( fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, blockId) + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) } ) } diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index 4f63f6883..0710bfb5a 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -53,12 +52,12 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R import org.openedx.course.presentation.ui.OfflineQueueCard import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR class DownloadQueueFragment : Fragment() { @@ -89,7 +88,7 @@ class DownloadQueueFragment : Fragment() { requireActivity().supportFragmentManager.popBackStack() }, onDownloadClick = { - viewModel.removeDownloadModels(it.id) + viewModel.removeDownloadModels(it.id, "") } ) } @@ -156,7 +155,7 @@ private fun DownloadQueueScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 56.dp), - text = stringResource(id = R.string.course_download_queue_title), + text = stringResource(id = coreR.string.course_download_queue_title), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 1, @@ -218,7 +217,7 @@ private fun DownloadQueueScreen( } } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.TABLET) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun DownloadQueueScreenPreview() { diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt index 03c3c01c2..67e161378 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -22,7 +22,6 @@ class DownloadQueueViewModel( coreAnalytics: CoreAnalytics, downloadHelper: DownloadHelper, ) : BaseDownloadViewModel( - "", downloadDao, preferencesManager, workerController, @@ -74,7 +73,7 @@ class DownloadQueueViewModel( } } - override fun removeDownloadModels(blockId: String) { + override fun removeDownloadModels(blockId: String, courseId: String) { viewModelScope.launch { workerController.removeModel(blockId) } diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 59c536295..e4ae9e39d 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -55,44 +55,6 @@ Section completed Section uncompleted - Downloads - (Untitled) - Download - The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? - Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? - Are you sure you want to delete all video(s) for \"%s\"? - Are you sure you want to delete video(s) for \"%s\"? - %1$s - %2$s - %3$d / %4$d - Downloading this content requires an active internet connection. Please connect to the internet and try again. - Wi-Fi Required - Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. - Download Failed - Unfortunately, this content failed to download. Please try again later or report this issue. - Downloading this %1$s of content will save available blocks offline. - Download on Cellular? - Downloading this content will use %1$s of cellular data. - Remove Offline Content? - Removing this content will free up %1$s. - Download - Remove - Device Storage Full - Your device does not have enough free space to download this content. Please free up some space and try again. - %1$s used, %2$s free - 0MB - Available to download - None of this course’s content is currently available to download offline. - Download all - Downloaded - Ready to Download - You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. - Downloading - Largest Downloads - Remove all downloads - Cancel Course Download - This component is not yet available offline - Explore other parts of this course or view this when you reconnect. - This component is not downloaded - Explore other parts of this course or download this when you reconnect. %1$s of %2$s assignment complete diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index c95916668..9f086481a 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -52,13 +52,13 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -343,10 +343,8 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( - courseStructure - ) - every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure + )every { networkConnection.isOnline() } returns true coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( @@ -581,7 +579,7 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() verify(exactly = 1) { coreAnalytics.logEvent( @@ -633,7 +631,7 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index b84bb61eb..e9517bb1c 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -45,6 +45,7 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -54,7 +55,6 @@ import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -366,7 +366,7 @@ class CourseVideoViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) @@ -410,7 +410,7 @@ class CourseVideoViewModelTest { } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) @@ -451,7 +451,7 @@ class CourseVideoViewModelTest { } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() diff --git a/downloads/build.gradle b/downloads/build.gradle index f67f8b05a..237ab0f40 100644 --- a/downloads/build.gradle +++ b/downloads/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.library' id 'org.jetbrains.kotlin.android' id "org.jetbrains.kotlin.plugin.compose" + id 'kotlin-parcelize' } android { diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt index ea60fddea..01f52e9eb 100644 --- a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -1,13 +1,18 @@ package org.openedx.downloads.data.repository import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import org.openedx.core.data.api.CourseApi import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.exception.NoCachedDataException import org.openedx.core.module.db.DownloadDao class DownloadRepository( private val api: CourseApi, private val dao: DownloadDao, + private val courseDao: CourseDao, private val corePreferences: CorePreferences, ) { fun getDownloadCoursesPreview(refresh: Boolean) = flow { @@ -23,4 +28,29 @@ class DownloadRepository( emit(downloadCoursesPreview) } + fun getDownloadModels() = dao.getAllDataFlow().map { list -> + list.map { it.mapToDomain() } + } + + suspend fun getCourseStructure(courseId: String): CourseStructure { + try { + val response = api.getCourseStructure( + "stale-if-error=0", + "v4", + corePreferences.user?.username, + courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + return response.mapToDomain() + } catch (_: Exception) { + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } + } + } + + suspend fun getAllDownloadModels() = dao.readAllData().map { it.mapToDomain() } } diff --git a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt index b7545b2b0..7ee7820a0 100644 --- a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -6,4 +6,10 @@ class DownloadInteractor( private val repository: DownloadRepository ) { fun getDownloadCoursesPreview(refresh: Boolean) = repository.getDownloadCoursesPreview(refresh) + + fun getDownloadModels() = repository.getDownloadModels() + + suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + + suspend fun getCourseStructure(courseId: String) = repository.getCourseStructure(courseId) } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt deleted file mode 100644 index 615c6a1f3..000000000 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsUIState.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.openedx.downloads.presentation.dates - -import org.openedx.core.domain.model.DownloadCoursePreview - -data class DownloadsUIState( - val isLoading: Boolean = true, - val isRefreshing: Boolean = false, - val downloadCoursePreviews: List = emptyList() -) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt deleted file mode 100644 index 89f932aec..000000000 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsViewModel.kt +++ /dev/null @@ -1,111 +0,0 @@ -package org.openedx.downloads.presentation.dates - -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.openedx.core.R -import org.openedx.core.config.Config -import org.openedx.core.system.connection.NetworkConnection -import org.openedx.downloads.domain.interactor.DownloadInteractor -import org.openedx.downloads.presentation.DownloadsRouter -import org.openedx.foundation.extension.isInternetError -import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage -import org.openedx.foundation.system.ResourceManager - -class DownloadsViewModel( - private val downloadsRouter: DownloadsRouter, - private val networkConnection: NetworkConnection, - private val interactor: DownloadInteractor, - private val resourceManager: ResourceManager, - private val config: Config, -) : BaseViewModel() { - - val apiHostUrl get() = config.getApiHostURL() - - private val _uiState = MutableStateFlow(DownloadsUIState()) - val uiState: StateFlow - get() = _uiState.asStateFlow() - - private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() - - - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() - - init { - fetchDates(false) - } - - private fun fetchDates(refresh: Boolean) { - viewModelScope.launch(Dispatchers.IO) { - _uiState.update { state -> - state.copy( - isLoading = !refresh, - isRefreshing = refresh - ) - } - interactor.getDownloadCoursesPreview(refresh) - .onCompletion { - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false - ) - } - } - .catch { e -> - 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) - ) - ) - } - } - .collect { downloadCoursePreviews -> - _uiState.update { state -> - state.copy( - downloadCoursePreviews = downloadCoursePreviews - ) - } - } - } - } - - fun refreshData() { - _uiState.update { state -> - state.copy( - isRefreshing = true - ) - } - fetchDates(true) - } - - fun onSettingsClick(fragmentManager: FragmentManager) { - downloadsRouter.navigateToSettings(fragmentManager) - } -} - -interface DownloadsViewActions { - object OpenSettings : DownloadsViewActions - object SwipeRefresh : DownloadsViewActions -} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt similarity index 61% rename from downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt rename to downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt index f6f91f47c..d32dfdfa0 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/dates/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -1,4 +1,4 @@ -package org.openedx.downloads.presentation.dates +package org.openedx.downloads.presentation.download import android.os.Bundle import android.view.LayoutInflater @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize @@ -32,6 +33,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.outlined.CloudDownload @@ -49,6 +51,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext @@ -66,6 +69,8 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText import org.openedx.core.ui.OfflineModeDialog @@ -80,10 +85,12 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.downloads.R +import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR class DownloadsFragment : Fragment() { @@ -118,6 +125,24 @@ class DownloadsFragment : Fragment() { DownloadsViewActions.SwipeRefresh -> { viewModel.refreshData() } + + is DownloadsViewActions.DownloadCourse -> { + viewModel.downloadCourse( + requireActivity().supportFragmentManager, + action.courseId + ) + } + + is DownloadsViewActions.CancelDownloading -> { + viewModel.cancelDownloading(action.courseId) + } + + is DownloadsViewActions.RemoveDownloads -> { + viewModel.removeDownloads( + requireActivity().supportFragmentManager, + action.courseId + ) + } } } ) @@ -200,11 +225,25 @@ private fun DownloadsScreen( contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { - items(uiState.downloadCoursePreviews) { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED CourseItem( - downloadCoursePreview = it, + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, apiHostUrl = apiHostUrl, - onClick = {} + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } ) } } @@ -242,12 +281,22 @@ private fun DownloadsScreen( private fun CourseItem( modifier: Modifier = Modifier, downloadCoursePreview: DownloadCoursePreview, + downloadModels: List, + downloadedState: DownloadedState, apiHostUrl: String, - onClick: () -> Unit, + onDownloadClick: () -> Unit, + onRemoveClick: () -> Unit, + onCancelClick: () -> Unit ) { + var isButtonEnabled by remember { mutableStateOf(true) } var isDropdownExpanded by remember { mutableStateOf(false) } + val downloadedSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + val availableSize = downloadCoursePreview.totalSize - downloadedSize + val availableSizeString = availableSize.toFileSize(space = false) val progress: Float = try { - 1.toFloat() / 2.toFloat() + downloadedSize.toFloat() / availableSize.toFloat() } catch (_: ArithmeticException) { 0f } @@ -286,39 +335,103 @@ private fun CourseItem( maxLines = 2 ) Spacer(modifier = Modifier.height(8.dp)) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(CircleShape), - progress = progress, - color = MaterialTheme.appColors.successGreen, - backgroundColor = MaterialTheme.appColors.divider - ) - Spacer(modifier = Modifier.height(4.dp)) - IconText( - icon = Icons.Filled.CloudDone, - color = MaterialTheme.appColors.successGreen, - text = "qwe" - ) - Spacer(modifier = Modifier.height(4.dp)) - IconText( - icon = Icons.Outlined.CloudDownload, - color = MaterialTheme.appColors.textPrimaryVariant, - text = "rty" - ) + if (downloadedState != DownloadedState.DOWNLOADED && downloadedSize != 0L) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + progress = progress, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.divider + ) + } + if (downloadedSize != 0L) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Filled.CloudDone, + color = MaterialTheme.appColors.successGreen, + text = stringResource( + R.string.downloaded_downloaded_size, + downloadedSize.toFileSize(space = false) + ) + ) + } + if (downloadedState != DownloadedState.DOWNLOADED) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textPrimaryVariant, + text = stringResource( + R.string.downloaded_available_size, + availableSizeString + ) + ) + } Spacer(modifier = Modifier.height(8.dp)) - OpenEdXButton( - onClick = onClick, - content = { - IconText( - text = stringResource(R.string.downloads_download_course), - icon = Icons.Outlined.CloudDownload, - color = MaterialTheme.appColors.primaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge + if (downloadedState.isWaitingOrDownloading) { + isButtonEnabled = true + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box(contentAlignment = Alignment.Center) { + if (downloadedState == DownloadedState.DOWNLOADING) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + } else { + Icon( + painter = painterResource(id = coreR.drawable.core_download_waiting), + contentDescription = stringResource( + id = R.string.course_accessibility_stop_downloading_course + ), + tint = MaterialTheme.appColors.error + ) + } + IconButton( + modifier = Modifier + .size(28.dp) + .padding(2.dp), + onClick = onCancelClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource( + id = R.string.course_accessibility_stop_downloading_course + ), + tint = MaterialTheme.appColors.error + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(coreR.string.course_downloading), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary ) } - ) + } else if (downloadedState == DownloadedState.NOT_DOWNLOADED) { + OpenEdXButton( + onClick = { + isButtonEnabled = false + onDownloadClick() + }, + enabled = isButtonEnabled, + content = { + IconText( + text = stringResource(R.string.downloads_download_course), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } } } @@ -326,11 +439,13 @@ private fun CourseItem( modifier = Modifier .align(Alignment.TopEnd), ) { - MoreButton( - onClick = { - isDropdownExpanded = true - } - ) + if (downloadedSize != 0L || downloadedState.isWaitingOrDownloading) { + MoreButton( + onClick = { + isDropdownExpanded = true + } + ) + } DropdownMenu( modifier = Modifier .crop(vertical = 8.dp) @@ -340,22 +455,31 @@ private fun CourseItem( onDismissRequest = { isDropdownExpanded = false }, ) { Column { - OpenEdXDropdownMenuItem( - text = stringResource(R.string.downloads_remove_course_downloads), - onClick = {} - ) - Divider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.appColors.divider - ) - OpenEdXDropdownMenuItem( - text = stringResource(R.string.downloads_cancel_download), - onClick = {} - ) + if (downloadedSize != 0L) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_remove_course_downloads), + onClick = { + isDropdownExpanded = false + onRemoveClick() + } + ) + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + if (downloadedState.isWaitingOrDownloading) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_cancel_download), + onClick = { + isDropdownExpanded = false + onCancelClick() + } + ) + } } } } - } } } @@ -445,8 +569,12 @@ private fun CourseItemPreview() { OpenEdXTheme { CourseItem( downloadCoursePreview = DownloadCoursePreview("", "name", "", 2000), + downloadModels = emptyList(), apiHostUrl = "", - onClick = {} + downloadedState = DownloadedState.NOT_DOWNLOADED, + onDownloadClick = {}, + onCancelClick = {}, + onRemoveClick = {}, ) } } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt new file mode 100644 index 000000000..efb20e0ae --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt @@ -0,0 +1,13 @@ +package org.openedx.downloads.presentation.download + +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState + +data class DownloadsUIState( + val isLoading: Boolean = true, + val isRefreshing: Boolean = false, + val downloadCoursePreviews: List = emptyList(), + val downloadModels: List = emptyList(), + val courseDownloadState: Map = emptyMap() +) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt new file mode 100644 index 000000000..d1d380deb --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -0,0 +1,273 @@ +package org.openedx.downloads.presentation.download + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil + +class DownloadsViewModel( + private val downloadsRouter: DownloadsRouter, + private val networkConnection: NetworkConnection, + private val interactor: DownloadInteractor, + private val downloadDialogManager: DownloadDialogManager, + private val resourceManager: ResourceManager, + private val fileUtil: FileUtil, + private val config: Config, + preferencesManager: CorePreferences, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + val apiHostUrl get() = config.getApiHostURL() + + private val _uiState = MutableStateFlow(DownloadsUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val blockIdsByCourseId = mutableMapOf>() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + fetchDownloads(false) + + viewModelScope.launch { + downloadingModelsFlow.collect { downloadModels -> + _uiState.update { state -> + state.copy(downloadModels = downloadModels) + } + } + } + + viewModelScope.launch { + downloadModelsStatusFlow.collect { statusMap -> + val downloadingCourseState = blockIdsByCourseId + .mapValues { (_, blockIds) -> + val blockStates = blockIds.mapNotNull { statusMap[it] } + if (blockStates.isEmpty()) DownloadedState.NOT_DOWNLOADED + else determineCourseState(blockStates) + } + + _uiState.update { state -> + state.copy(courseDownloadState = downloadingCourseState) + } + } + } + } + + private fun determineCourseState(blockStates: List): DownloadedState { + return when { + blockStates.all { it == DownloadedState.DOWNLOADED } -> DownloadedState.DOWNLOADED + blockStates.all { it == DownloadedState.WAITING } -> DownloadedState.WAITING + blockStates.any { it == DownloadedState.DOWNLOADING } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + } + + private fun fetchDownloads(refresh: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { state -> + state.copy( + isLoading = !refresh, + isRefreshing = refresh + ) + } + interactor.getDownloadCoursesPreview(refresh) + .onCompletion { + _uiState.update { state -> + state.copy( + isLoading = false, + isRefreshing = false + ) + } + } + .catch { e -> + 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) + ) + ) + } + } + .collect { downloadCoursePreviews -> + downloadCoursePreviews.map { + initBlocks(it.id) + } + val subSectionsBlocks = + allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + subSectionsBlocks.map { subSection -> + addDownloadableChildrenForSequentialBlock(subSection) + } + initDownloadModelsStatus() + _uiState.update { state -> + state.copy( + downloadCoursePreviews = downloadCoursePreviews + ) + } + } + } + } + + fun refreshData() { + _uiState.update { state -> + state.copy( + isRefreshing = true + ) + } + fetchDownloads(true) + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + downloadsRouter.navigateToSettings(fragmentManager) + } + + fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { + viewModelScope.launch { + try { + downloadAllBlocks(fragmentManager, courseId) + } 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 cancelDownloading(courseId: String) { + viewModelScope.launch { + interactor.getAllDownloadModels() + .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } + .forEach { removeBlockDownloadModel(it.id) } + } + } + + fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { + viewModelScope.launch { + val downloadModels = + interactor.getDownloadModels().first().filter { it.courseId == courseId } + val totalSize = downloadModels.sumOf { it.size } + val title = _uiState.value.downloadCoursePreviews.find { it.id == courseId }?.name ?: "" + val downloadDialogItem = DownloadDialogItem( + title = title, + size = totalSize, + icon = Icons.AutoMirrored.Outlined.InsertDriveFile + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + } + ) + } + } + + private suspend fun initBlocks(courseId: String): CourseStructure { + val courseStructure = interactor.getCourseStructure(courseId) + blockIdsByCourseId[courseStructure.id] = courseStructure.blockData.map { it.id } + addBlocks(courseStructure.blockData) + return courseStructure + } + + private fun downloadAllBlocks(fragmentManager: FragmentManager, courseId: String) { + viewModelScope.launch { + val courseStructure = initBlocks(courseId) + val downloadModels = interactor.getDownloadModels() + .map { list -> list.filter { it.courseId in courseId } } + .first() + val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSection -> + addDownloadableChildrenForSequentialBlock(subSection) + val verticalBlocks = allBlocks.values.filter { it.id in subSection.descendants } + val notDownloadedBlocks = courseStructure.blockData.filter { block -> + block.id in verticalBlocks.flatMap { it.descendants } && + block.isDownloadable && + downloadModels.none { it.id == block.id } + } + if (notDownloadedBlocks.isNotEmpty()) subSection else null + } + + downloadDialogManager.showPopup( + subSectionsBlocks = notDownloadedSubSectionBlocks, + courseId = courseId, + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) + } + ) + } + } +} + +interface DownloadsViewActions { + object OpenSettings : DownloadsViewActions + object SwipeRefresh : DownloadsViewActions + data class DownloadCourse(val courseId: String) : DownloadsViewActions + data class CancelDownloading(val courseId: String) : DownloadsViewActions + data class RemoveDownloads(val courseId: String) : DownloadsViewActions +} diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml index 61e1de4a3..7acdff193 100644 --- a/downloads/src/main/res/values/strings.xml +++ b/downloads/src/main/res/values/strings.xml @@ -6,4 +6,7 @@ Cancel download No Courses with Downloadable Content You currently have no courses with downloadable content. + %1$s downloaded + %1$s available + Stop downloading course \ No newline at end of file From 60c7e136bd8fdd28d2a18b6a62d8c5ba611d33a0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 5 Mar 2025 15:06:26 +0200 Subject: [PATCH 06/29] refactor: dynamic main menu --- .../main/java/org/openedx/app/MainFragment.kt | 139 +++++++++--------- .../java/org/openedx/app/di/ScreenModule.kt | 13 +- .../res/drawable/app_ic_download_cloud.xml | 0 app/src/main/res/layout/fragment_main.xml | 3 +- app/src/main/res/menu/bottom_view_menu.xml | 28 ---- app/src/main/res/values/main_manu_tab_ids.xml | 7 + ...ourseInteractor.kt => CourseInteractor.kt} | 4 +- .../downloaddialog/DownloadDialogManager.kt | 4 +- .../domain/interactor/CourseInteractor.kt | 4 +- .../course/presentation/ui/CourseVideosUI.kt | 3 +- .../download/DownloadsFragment.kt | 10 +- .../presentation/download/DownloadsUIState.kt | 3 +- .../download/DownloadsViewModel.kt | 31 +++- 13 files changed, 126 insertions(+), 123 deletions(-) rename {core => app}/src/main/res/drawable/app_ic_download_cloud.xml (100%) delete mode 100644 app/src/main/res/menu/bottom_view_menu.xml create mode 100644 app/src/main/res/values/main_manu_tab_ids.xml rename core/src/main/java/org/openedx/core/domain/interactor/{ICourseInteractor.kt => CourseInteractor.kt} (92%) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index be0ed42e4..022a8a0ed 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -1,6 +1,7 @@ package org.openedx.app import android.os.Bundle +import android.view.Menu import android.view.View import androidx.core.os.bundleOf import androidx.core.view.forEach @@ -41,33 +42,69 @@ class MainFragment : Fragment(R.layout.fragment_main) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - if (!viewModel.isDownloadsFragmentEnabled) { - binding.bottomNavView.menu.removeItem(R.id.fragmentDownloads) + + requireArguments().apply { + getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> + val infoType = getString(ARG_INFO_TYPE) + if (viewModel.isDiscoveryTypeWebView && infoType != null) { + router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) + } else { + router.navigateToCourseDetail(parentFragmentManager, courseId) + } + putString(ARG_COURSE_ID, "") + putString(ARG_INFO_TYPE, "") + } } - initViewPager() + val openTabArg = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val learnFragment = LearnFragment.newInstance( + openTab = if (openTabArg == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS.name + } else { + LearnTab.COURSES.name + } + ) + val tabList = mutableListOf>().apply { + add(R.id.fragmentLearn to learnFragment) + add(R.id.fragmentDiscover to viewModel.getDiscoveryFragment) + if (viewModel.isDownloadsFragmentEnabled) { + add(R.id.fragmentDownloads to DownloadsFragment()) + } + add(R.id.fragmentProfile to ProfileFragment()) + } - binding.bottomNavView.setOnItemSelectedListener { - when (it.itemId) { - R.id.fragmentLearn -> { - viewModel.logLearnTabClickedEvent() - binding.viewPager.setCurrentItem(0, false) - } + val menu = binding.bottomNavView.menu + menu.clear() + val tabTitles = mapOf( + 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.fragmentProfile to resources.getString(R.string.app_navigation_profile), + ) + val tabIcons = mapOf( + R.id.fragmentLearn to R.drawable.app_ic_rows, + R.id.fragmentDiscover to R.drawable.app_ic_home, + R.id.fragmentDownloads to R.drawable.app_ic_download_cloud, + R.id.fragmentProfile to R.drawable.app_ic_profile + ) + for ((id, _) in tabList) { + val menuItem = menu.add(Menu.NONE, id, Menu.NONE, tabTitles[id] ?: "") + tabIcons[id]?.let { menuItem.setIcon(it) } + } - R.id.fragmentDiscover -> { - viewModel.logDiscoveryTabClickedEvent() - binding.viewPager.setCurrentItem(1, false) - } + initViewPager(tabList) - R.id.fragmentDownloads -> { - viewModel.logDownloadsTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } + val menuIdToIndex = tabList.mapIndexed { index, pair -> pair.first to index }.toMap() - R.id.fragmentProfile -> { - viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) - } + binding.bottomNavView.setOnItemSelectedListener { menuItem -> + when (menuItem.itemId) { + R.id.fragmentLearn -> viewModel.logLearnTabClickedEvent() + R.id.fragmentDiscover -> viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentDownloads -> viewModel.logDownloadsTabClickedEvent() + R.id.fragmentProfile -> viewModel.logProfileTabClickedEvent() + } + menuIdToIndex[menuItem.itemId]?.let { index -> + binding.viewPager.setCurrentItem(index, false) } true } @@ -84,59 +121,27 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } - requireArguments().apply { - getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> - val infoType = getString(ARG_INFO_TYPE) - - if (viewModel.isDiscoveryTypeWebView && infoType != null) { - router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) - } else { - router.navigateToCourseDetail(parentFragmentManager, courseId) - } - - // Clear arguments after navigation - putString(ARG_COURSE_ID, "") - putString(ARG_INFO_TYPE, "") - } - - when (requireArguments().getString(ARG_OPEN_TAB, "")) { - HomeTab.LEARN.name, - HomeTab.PROGRAMS.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentLearn - } - - HomeTab.DISCOVER.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentDiscover - } - - HomeTab.DOWNLOADS.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentDownloads - } - - HomeTab.PROFILE.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentProfile - } - } - requireArguments().remove(ARG_OPEN_TAB) + val initialMenuId = when (openTabArg) { + HomeTab.LEARN.name, HomeTab.PROGRAMS.name -> R.id.fragmentLearn + HomeTab.DISCOVER.name -> R.id.fragmentDiscover + HomeTab.DOWNLOADS.name -> if (viewModel.isDownloadsFragmentEnabled) R.id.fragmentDownloads else R.id.fragmentLearn + HomeTab.PROFILE.name -> R.id.fragmentProfile + else -> R.id.fragmentLearn } + binding.bottomNavView.selectedItemId = initialMenuId + + requireArguments().remove(ARG_OPEN_TAB) } @Suppress("MagicNumber") - private fun initViewPager() { + private fun initViewPager(tabList: List>) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.offscreenPageLimit = 4 + binding.viewPager.offscreenPageLimit = tabList.size - val openTab = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) - val learnTab = if (openTab == HomeTab.PROGRAMS.name) { - LearnTab.PROGRAMS - } else { - LearnTab.COURSES - } adapter = NavigationFragmentAdapter(this).apply { - addFragment(LearnFragment.newInstance(openTab = learnTab.name)) - addFragment(viewModel.getDiscoveryFragment) - addFragment(DownloadsFragment()) - addFragment(ProfileFragment()) + tabList.forEach { (_, fragment) -> + addFragment(fragment) + } } binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false 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 82325642c..ec6002f1d 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -13,7 +13,6 @@ import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator import org.openedx.core.domain.interactor.CalendarInteractor -import org.openedx.core.domain.interactor.ICourseInteractor import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.core.repository.CalendarRepository @@ -233,7 +232,7 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } - single { get() } + single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -497,11 +496,6 @@ val screenModule = module { ) } - single { - DownloadInteractor( - repository = get() - ) - } single { DownloadRepository( api = get(), @@ -510,6 +504,11 @@ val screenModule = module { courseDao = get() ) } + single { + DownloadInteractor( + repository = get() + ) + } viewModel { DownloadsViewModel( downloadsRouter = get(), diff --git a/core/src/main/res/drawable/app_ic_download_cloud.xml b/app/src/main/res/drawable/app_ic_download_cloud.xml similarity index 100% rename from core/src/main/res/drawable/app_ic_download_cloud.xml rename to app/src/main/res/drawable/app_ic_download_cloud.xml diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 9794b7bd7..9a4861379 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -25,7 +25,6 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:menu="@menu/bottom_view_menu" /> + app:layout_constraintStart_toStartOf="parent" /> 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 f970775ca..000000000 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values/main_manu_tab_ids.xml b/app/src/main/res/values/main_manu_tab_ids.xml new file mode 100644 index 000000000..65a44f9d8 --- /dev/null +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/domain/interactor/ICourseInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt similarity index 92% rename from core/src/main/java/org/openedx/core/domain/interactor/ICourseInteractor.kt rename to core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt index 4a7781a2c..ef5a8b7c5 100644 --- a/core/src/main/java/org/openedx/core/domain/interactor/ICourseInteractor.kt +++ b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt @@ -3,7 +3,7 @@ package org.openedx.core.domain.interactor import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadModel -interface ICourseInteractor { +interface CourseInteractor { suspend fun getCourseStructure( courseId: String, isNeedRefresh: Boolean = false @@ -12,4 +12,4 @@ interface ICourseInteractor { suspend fun getCourseStructureFromCache(courseId: String): CourseStructure suspend fun getAllDownloadModels(): List -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 7604d919d..b69f1e902 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.interactor.ICourseInteractor +import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadModel @@ -17,7 +17,7 @@ import org.openedx.core.system.connection.NetworkConnection class DownloadDialogManager( private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, - private val interactor: ICourseInteractor, + private val interactor: CourseInteractor, private val workerController: DownloadWorkerController ) { diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 9a31ff083..8fab7bba7 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -2,7 +2,7 @@ package org.openedx.course.domain.interactor import kotlinx.coroutines.flow.Flow import org.openedx.core.BlockType -import org.openedx.core.domain.interactor.ICourseInteractor +import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseStructure @@ -11,7 +11,7 @@ import org.openedx.course.data.repository.CourseRepository @Suppress("TooManyFunctions") class CourseInteractor( private val repository: CourseRepository -) : ICourseInteractor { +) : CourseInteractor { suspend fun getCourseStructureFlow( courseId: String, diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 6c9bbe9b5..fd609ae14 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -140,7 +140,8 @@ fun CourseVideosScreen( viewModel.removeAllDownloadModels() } else { viewModel.saveAllDownloadModels( - fileUtil.getExternalAppDir().path, viewModel.courseId + fileUtil.getExternalAppDir().path, + viewModel.courseId ) } }, diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt index d32dfdfa0..be9541808 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -149,7 +149,6 @@ class DownloadsFragment : Fragment() { } } } - } @OptIn(ExperimentalMaterialApi::class) @@ -230,10 +229,12 @@ private fun DownloadsScreen( uiState.downloadModels.filter { it.courseId == item.id } val downloadState = uiState.courseDownloadState[item.id] ?: DownloadedState.NOT_DOWNLOADED + val isButtonEnabled = uiState.enableButton[item.id] ?: false CourseItem( downloadCoursePreview = item, downloadModels = downloadModels, downloadedState = downloadState, + isButtonEnabled = isButtonEnabled, apiHostUrl = apiHostUrl, onDownloadClick = { onAction(DownloadsViewActions.DownloadCourse(item.id)) @@ -283,12 +284,12 @@ private fun CourseItem( downloadCoursePreview: DownloadCoursePreview, downloadModels: List, downloadedState: DownloadedState, + isButtonEnabled: Boolean, apiHostUrl: String, onDownloadClick: () -> Unit, onRemoveClick: () -> Unit, onCancelClick: () -> Unit ) { - var isButtonEnabled by remember { mutableStateOf(true) } var isDropdownExpanded by remember { mutableStateOf(false) } val downloadedSize = downloadModels .filter { it.downloadedState == DownloadedState.DOWNLOADED } @@ -370,7 +371,6 @@ private fun CourseItem( } Spacer(modifier = Modifier.height(8.dp)) if (downloadedState.isWaitingOrDownloading) { - isButtonEnabled = true Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -418,7 +418,6 @@ private fun CourseItem( } else if (downloadedState == DownloadedState.NOT_DOWNLOADED) { OpenEdXButton( onClick = { - isButtonEnabled = false onDownloadClick() }, enabled = isButtonEnabled, @@ -568,10 +567,11 @@ private fun DatesScreenPreview() { private fun CourseItemPreview() { OpenEdXTheme { CourseItem( - downloadCoursePreview = DownloadCoursePreview("", "name", "", 2000), + downloadCoursePreview = DownloadCoursePreview("", "name", "", 100), downloadModels = emptyList(), apiHostUrl = "", downloadedState = DownloadedState.NOT_DOWNLOADED, + isButtonEnabled = true, onDownloadClick = {}, onCancelClick = {}, onRemoveClick = {}, diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt index efb20e0ae..63af6467f 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt @@ -9,5 +9,6 @@ data class DownloadsUIState( val isRefreshing: Boolean = false, val downloadCoursePreviews: List = emptyList(), val downloadModels: List = emptyList(), - val courseDownloadState: Map = emptyMap() + val courseDownloadState: Map = emptyMap(), + val enableButton: Map = emptyMap() ) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index d1d380deb..5f44e9a10 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -89,8 +89,11 @@ class DownloadsViewModel( val downloadingCourseState = blockIdsByCourseId .mapValues { (_, blockIds) -> val blockStates = blockIds.mapNotNull { statusMap[it] } - if (blockStates.isEmpty()) DownloadedState.NOT_DOWNLOADED - else determineCourseState(blockStates) + if (blockStates.isEmpty()) { + DownloadedState.NOT_DOWNLOADED + } else { + determineCourseState(blockStates) + } } _uiState.update { state -> @@ -153,7 +156,8 @@ class DownloadsViewModel( initDownloadModelsStatus() _uiState.update { state -> state.copy( - downloadCoursePreviews = downloadCoursePreviews + downloadCoursePreviews = downloadCoursePreviews, + enableButton = downloadCoursePreviews.associate { it.id to true } ) } } @@ -176,8 +180,18 @@ class DownloadsViewModel( fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { viewModelScope.launch { try { + _uiState.update { state -> + state.copy( + enableButton = state.enableButton.toMap() + (courseId to false) + ) + } downloadAllBlocks(fragmentManager, courseId) } catch (e: Exception) { + _uiState.update { state -> + state.copy( + enableButton = state.enableButton.toMap() + (courseId to true) + ) + } if (e.isInternetError()) { _uiMessage.emit( UIMessage.SnackBarMessage( @@ -195,7 +209,6 @@ class DownloadsViewModel( } } - fun cancelDownloading(courseId: String) { viewModelScope.launch { interactor.getAllDownloadModels() @@ -232,8 +245,8 @@ class DownloadsViewModel( return courseStructure } - private fun downloadAllBlocks(fragmentManager: FragmentManager, courseId: String) { - viewModelScope.launch { + private suspend fun downloadAllBlocks(fragmentManager: FragmentManager, courseId: String) { + try { val courseStructure = initBlocks(courseId) val downloadModels = interactor.getDownloadModels() .map { list -> list.filter { it.courseId in courseId } } @@ -260,6 +273,12 @@ class DownloadsViewModel( saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) } ) + } finally { + _uiState.update { state -> + state.copy( + enableButton = state.enableButton.toMap() + (courseId to true) + ) + } } } } From b4e62417bda6df1cf33bde70c0baf71da0f88792 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 5 Mar 2025 17:56:29 +0200 Subject: [PATCH 07/29] feat: show loading course structure state --- .../main/java/org/openedx/app/MainFragment.kt | 9 +- .../java/org/openedx/app/room/AppDatabase.kt | 1 - .../openedx/core/module/db/DownloadModel.kt | 4 +- .../module/download/BaseDownloadViewModel.kt | 1 - .../DownloadConfirmDialogFragment.kt | 19 ++-- .../downloaddialog/DownloadDialogManager.kt | 22 +++- .../downloaddialog/DownloadDialogUIState.kt | 3 +- .../DownloadErrorDialogFragment.kt | 15 ++- .../DownloadStorageErrorDialogFragment.kt | 11 +- core/src/main/res/values/strings.xml | 76 ++++++------- .../offline/CourseOfflineScreen.kt | 28 ++--- .../course/presentation/ui/CourseUI.kt | 4 +- .../course/presentation/ui/CourseVideosUI.kt | 10 +- .../unit/NotAvailableUnitFragment.kt | 8 +- .../download/DownloadQueueFragment.kt | 2 +- .../outline/CourseOutlineViewModelTest.kt | 6 +- .../data/repository/DownloadRepository.kt | 16 ++- .../domain/interactor/DownloadInteractor.kt | 3 + .../download/DownloadsFragment.kt | 42 +++---- .../presentation/download/DownloadsUIState.kt | 1 - .../download/DownloadsViewModel.kt | 107 +++++++++++------- downloads/src/main/res/values/strings.xml | 3 +- 22 files changed, 226 insertions(+), 165 deletions(-) diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 022a8a0ed..7fa948af1 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -40,6 +40,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } + @Suppress("LongMethod", "CyclomaticComplexMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -124,7 +125,12 @@ class MainFragment : Fragment(R.layout.fragment_main) { val initialMenuId = when (openTabArg) { HomeTab.LEARN.name, HomeTab.PROGRAMS.name -> R.id.fragmentLearn HomeTab.DISCOVER.name -> R.id.fragmentDiscover - HomeTab.DOWNLOADS.name -> if (viewModel.isDownloadsFragmentEnabled) R.id.fragmentDownloads else R.id.fragmentLearn + HomeTab.DOWNLOADS.name -> if (viewModel.isDownloadsFragmentEnabled) { + R.id.fragmentDownloads + } else { + R.id.fragmentLearn + } + HomeTab.PROFILE.name -> R.id.fragmentProfile else -> R.id.fragmentLearn } @@ -133,7 +139,6 @@ class MainFragment : Fragment(R.layout.fragment_main) { requireArguments().remove(ARG_OPEN_TAB) } - @Suppress("MagicNumber") private fun initViewPager(tabList: List>) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = tabList.size 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 1e7845d13..bfdcee43f 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -34,7 +34,6 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarEventEntity::class, CourseCalendarStateEntity::class, DownloadCoursePreview::class, - CourseCalendarStateEntity::class, CourseEnrollmentDetailsEntity::class ], autoMigrations = [ diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index da736ba28..9f5abd3f4 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -17,11 +17,11 @@ data class DownloadModel( ) : Parcelable enum class DownloadedState { - WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED; + WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED, LOADING_COURSE_STRUCTURE; val isWaitingOrDownloading: Boolean get() { - return this == WAITING || this == DOWNLOADING + return this == WAITING || this == DOWNLOADING || this == LOADING_COURSE_STRUCTURE } val isDownloaded: Boolean diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 1e685c83e..1f4de150a 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -65,7 +65,6 @@ abstract class BaseDownloadViewModel( updateParentStatus(parentId, children.size, downloadingCount, downloadedCount) } -// downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } _downloadingModelsFlow.emit(models) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index 8e93b6f5e..78259f297 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -48,7 +48,9 @@ import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager import androidx.compose.ui.graphics.Color as ComposeColor -class DownloadConfirmDialogFragment : DialogFragment() { +class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, @@ -67,24 +69,24 @@ class DownloadConfirmDialogFragment : DialogFragment() { DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( title = stringResource(id = R.string.course_confirm_download), description = stringResource( - id = R.string.course_download_confirm_dialog_description, + id = R.string.core_download_confirm_dialog_description, sizeSumString ), ) DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_on_cellural), + title = stringResource(id = R.string.core_download_on_cellural), description = stringResource( - id = R.string.course_download_on_cellural_dialog_description, + id = R.string.core_download_on_cellural_dialog_description, sizeSumString ), icon = painterResource(id = R.drawable.core_ic_warning), ) DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_remove_offline_content), + title = stringResource(id = R.string.core_download_remove_offline_content), description = stringResource( - id = R.string.course_download_remove_dialog_description, + id = R.string.core_download_remove_dialog_description, sizeSumString ) ) @@ -104,6 +106,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancel() } ) } @@ -186,14 +189,14 @@ private fun DownloadConfirmDialogView( val onClick: () -> Unit when (dialogType) { DownloadConfirmDialogType.REMOVE -> { - buttonText = stringResource(id = R.string.course_remove) + buttonText = stringResource(id = R.string.core_remove) buttonIcon = Icons.Rounded.Delete buttonColor = MaterialTheme.appColors.error onClick = onRemoveClick } else -> { - buttonText = stringResource(id = R.string.course_download) + buttonText = stringResource(id = R.string.core_download) buttonIcon = Icons.Outlined.CloudDownload buttonColor = MaterialTheme.appColors.secondaryButtonBackground onClick = onConfirmClick diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index b69f1e902..2988f1e71 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -14,6 +14,14 @@ import org.openedx.core.module.db.DownloadModel import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection +interface DownloadDialogListener { + fun onCancel() +} + +interface DownloadDialog { + var listener: DownloadDialogListener? +} + class DownloadDialogManager( private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, @@ -76,6 +84,12 @@ class DownloadDialogManager( else -> null } + val dialogListener = object : DownloadDialogListener { + override fun onCancel() { + state.onDismissClick() + } + } + dialog?.listener = dialogListener dialog?.show(state.fragmentManager, dialog::class.java.simpleName) ?: state.saveDownloadModels() } } @@ -89,6 +103,7 @@ class DownloadDialogManager( fragmentManager: FragmentManager, removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, ) { createDownloadItems( subSectionsBlocks = subSectionsBlocks, @@ -97,7 +112,8 @@ class DownloadDialogManager( isBlocksDownloaded = isBlocksDownloaded, onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, - saveDownloadModels = saveDownloadModels + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick ) } @@ -190,6 +206,7 @@ class DownloadDialogManager( onlyVideoBlocks: Boolean, removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, ) { coroutineScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) @@ -223,7 +240,8 @@ class DownloadDialogManager( ) } }, - saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, + onDismissClick = onDismissClick, ) ) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt index 915bebfe5..af75e4c66 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt @@ -13,5 +13,6 @@ data class DownloadDialogUIState( val isDownloadFailed: Boolean, val fragmentManager: @RawValue FragmentManager, val removeDownloadModels: () -> Unit, - val saveDownloadModels: () -> Unit + val saveDownloadModels: () -> Unit, + val onDismissClick: () -> Unit = {}, ) : Parcelable diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index 1de8b611a..bf1e6c3e2 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -41,7 +41,9 @@ import org.openedx.core.ui.theme.appTypography import org.openedx.foundation.extension.parcelable import org.openedx.foundation.system.PreviewFragmentManager -class DownloadErrorDialogFragment : DialogFragment() { +class DownloadErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, @@ -58,19 +60,19 @@ class DownloadErrorDialogFragment : DialogFragment() { val downloadDialogResource = when (dialogType) { DownloadErrorDialogType.NO_CONNECTION -> DownloadDialogResource( title = stringResource(id = R.string.core_no_internet_connection), - description = stringResource(id = R.string.course_download_no_internet_dialog_description), + description = stringResource(id = R.string.core_download_no_internet_dialog_description), icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( - title = stringResource(id = R.string.course_wifi_required), - description = stringResource(id = R.string.course_download_wifi_required_dialog_description), + title = stringResource(id = R.string.core_wifi_required), + description = stringResource(id = R.string.core_download_wifi_required_dialog_description), icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_failed), - description = stringResource(id = R.string.course_download_failed_dialog_description), + title = stringResource(id = R.string.core_download_failed), + description = stringResource(id = R.string.core_download_failed_dialog_description), icon = painterResource(id = R.drawable.core_ic_error), ) } @@ -85,6 +87,7 @@ class DownloadErrorDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancel() } ) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index 7615ab111..a1de5e4cc 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -56,7 +56,9 @@ import org.openedx.foundation.extension.parcelable import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager -class DownloadStorageErrorDialogFragment : DialogFragment() { +class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, @@ -69,8 +71,8 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { OpenEdXTheme { val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme val downloadDialogResource = DownloadDialogResource( - title = stringResource(id = R.string.course_device_storage_full), - description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), + title = stringResource(id = R.string.core_device_storage_full), + description = stringResource(id = R.string.core_download_device_storage_full_dialog_description), icon = painterResource(id = R.drawable.core_ic_error), ) @@ -79,6 +81,7 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { downloadDialogResource = downloadDialogResource, onCancelClick = { dismiss() + listener?.onCancel() } ) } @@ -241,7 +244,7 @@ private fun StorageBar( ) { Text( text = stringResource( - R.string.course_used_free_storage, + R.string.core_used_free_storage, usedSpace.toFileSize(1, false), freeSpace.toFileSize(1, false) ), diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index fab9ea55e..99df5b3d4 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -188,44 +188,44 @@ Syncing to calendar… Next - Downloads - (Untitled) - Download - The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? - Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? - Are you sure you want to delete all video(s) for \"%s\"? - Are you sure you want to delete video(s) for \"%s\"? - %1$s - %2$s - %3$d / %4$d - Downloading this content requires an active internet connection. Please connect to the internet and try again. - Wi-Fi Required - Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. - Download Failed - Unfortunately, this content failed to download. Please try again later or report this issue. - Downloading this %1$s of content will save available blocks offline. - Download on Cellular? - Downloading this content will use %1$s of cellular data. - Remove Offline Content? - Removing this content will free up %1$s. - Download - Remove - Device Storage Full - Your device does not have enough free space to download this content. Please free up some space and try again. - %1$s used, %2$s free - 0MB - Available to download - None of this course’s content is currently available to download offline. - Download all - Downloaded - Ready to Download - You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. - Downloading - Largest Downloads - Remove all downloads - Cancel Course Download - This component is not yet available offline - Explore other parts of this course or view this when you reconnect. - This component is not downloaded - Explore other parts of this course or download this when you reconnect. + Downloads + (Untitled) + Download + The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? + Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? + Are you sure you want to delete all video(s) for \"%s\"? + Are you sure you want to delete video(s) for \"%s\"? + %1$s - %2$s - %3$d / %4$d + Downloading this content requires an active internet connection. Please connect to the internet and try again. + Wi-Fi Required + Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + Download Failed + Unfortunately, this content failed to download. Please try again later or report this issue. + Downloading this %1$s of content will save available blocks offline. + Download on Cellular? + Downloading this content will use %1$s of cellular data. + Remove Offline Content? + Removing this content will free up %1$s. + Download + Remove + Device Storage Full + Your device does not have enough free space to download this content. Please free up some space and try again. + %1$s used, %2$s free + 0MB + Available to download + None of this course’s content is currently available to download offline. + Download all + Downloaded + Ready to Download + You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + Downloading + Largest Downloads + Remove all downloads + Cancel Course Download + This component is not yet available offline + Explore other parts of this course or view this when you reconnect. + This component is not downloaded + Explore other parts of this course or download this when you reconnect. Authorization Please enter the system to continue with course enrollment. diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt index bbad78826..0356b0164 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineScreen.kt @@ -158,7 +158,7 @@ private fun CourseOfflineUI( if (uiState.progressBarValue != 1f && !uiState.isDownloading && hasInternetConnection) { Spacer(modifier = Modifier.height(20.dp)) OpenEdXButton( - text = stringResource(R.string.course_download_all), + text = stringResource(R.string.core_download_all), backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = onDownloadAllClick, enabled = uiState.isHaveDownloadableBlocks, @@ -169,7 +169,7 @@ private fun CourseOfflineUI( MaterialTheme.appColors.textPrimaryVariant } IconText( - text = stringResource(R.string.course_download_all), + text = stringResource(R.string.core_download_all), icon = Icons.Outlined.CloudDownload, color = textColor, textStyle = MaterialTheme.appTypography.labelLarge @@ -180,14 +180,14 @@ private fun CourseOfflineUI( Spacer(modifier = Modifier.height(20.dp)) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.course_cancel_course_download), + text = stringResource(R.string.core_cancel_course_download), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.error, textColor = MaterialTheme.appColors.error, onClick = onCancelDownloadClick, content = { IconText( - text = stringResource(R.string.course_cancel_course_download), + text = stringResource(R.string.core_cancel_course_download), icon = Icons.Rounded.Close, color = MaterialTheme.appColors.error, textStyle = MaterialTheme.appTypography.labelLarge @@ -237,7 +237,7 @@ private fun LargestDownloads( Row { Text( modifier = Modifier.weight(1f), - text = stringResource(R.string.course_largest_downloads), + text = stringResource(R.string.core_largest_downloads), style = MaterialTheme.appTypography.titleMedium, color = MaterialTheme.appColors.textDark ) @@ -263,14 +263,14 @@ private fun LargestDownloads( if (!isDownloading) { OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.course_remove_all_downloads), + text = stringResource(R.string.core_remove_all_downloads), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.error, textColor = MaterialTheme.appColors.error, onClick = onDeleteAllClick, content = { IconText( - text = stringResource(R.string.course_remove_all_downloads), + text = stringResource(R.string.core_remove_all_downloads), icon = Icons.Rounded.Delete, color = MaterialTheme.appColors.error, textStyle = MaterialTheme.appTypography.labelLarge @@ -383,21 +383,21 @@ private fun DownloadProgress( horizontalArrangement = Arrangement.SpaceBetween ) { IconText( - text = stringResource(R.string.course_downloaded), + text = stringResource(R.string.core_downloaded), icon = Icons.Default.CloudDone, color = MaterialTheme.appColors.successGreen, textStyle = MaterialTheme.appTypography.labelLarge ) if (!uiState.isDownloading) { IconText( - text = stringResource(R.string.course_ready_to_download), + text = stringResource(R.string.core_ready_to_download), icon = Icons.Outlined.CloudDownload, color = MaterialTheme.appColors.textDark, textStyle = MaterialTheme.appTypography.labelLarge ) } else { IconText( - text = stringResource(R.string.course_downloading), + text = stringResource(R.string.core_downloading), icon = Icons.Outlined.CloudDownload, color = MaterialTheme.appColors.textDark, textStyle = MaterialTheme.appTypography.labelLarge @@ -417,7 +417,7 @@ private fun DownloadProgress( ) } else { Text( - text = stringResource(R.string.course_you_can_download_course_content_offline), + text = stringResource(R.string.core_you_can_download_course_content_offline), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textDark ) @@ -433,20 +433,20 @@ private fun NoDownloadableBlocksProgress( modifier = modifier ) { Text( - text = stringResource(R.string.course_0mb), + text = stringResource(R.string.core_0mb), style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textFieldHint ) Spacer(modifier = Modifier.height(4.dp)) IconText( - text = stringResource(R.string.course_available_to_download), + text = stringResource(R.string.core_available_to_download), icon = Icons.Outlined.CloudDownload, color = MaterialTheme.appColors.textFieldHint, textStyle = MaterialTheme.appTypography.labelLarge ) Spacer(modifier = Modifier.height(20.dp)) Text( - text = stringResource(R.string.course_no_available_to_download_offline), + text = stringResource(R.string.core_no_available_to_download_offline), style = MaterialTheme.appTypography.labelLarge, color = MaterialTheme.appColors.textDark ) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 0806d9c22..695049c75 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -246,7 +246,7 @@ fun OfflineQueueCard( .weight(1f) ) { Text( - text = downloadModel.title.ifEmpty { stringResource(id = coreR.string.course_download_untitled) }, + text = downloadModel.title.ifEmpty { stringResource(id = coreR.string.core_download_untitled) }, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, overflow = TextOverflow.Ellipsis, @@ -832,7 +832,7 @@ fun CourseSubSectionItem( if (isAssignmentEnable) { val assignmentString = stringResource( - coreR.string.course_subsection_assignment_info, + coreR.string.core_subsection_assignment_info, block.assignmentProgress?.assignmentType ?: "", stringResource(id = coreR.string.core_date_format_assignment_due, due), block.assignmentProgress?.numPointsEarned?.toInt() ?: 0, diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index fd609ae14..5f498c162 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -312,12 +312,12 @@ private fun CourseVideosUI( AlertDialog( title = { Text( - text = stringResource(id = coreR.string.course_download_big_files_confirmation_title) + text = stringResource(id = coreR.string.core_download_big_files_confirmation_title) ) }, text = { Text( - text = stringResource(id = coreR.string.course_download_big_files_confirmation_text) + text = stringResource(id = coreR.string.core_download_big_files_confirmation_text) ) }, onDismissRequest = { @@ -354,9 +354,9 @@ private fun CourseVideosUI( downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && downloadModelsSize.remainingCount == 0 val dialogTextId = if (isDownloadedAllVideos) { - coreR.string.course_delete_confirmation + coreR.string.core_delete_confirmation } else { - coreR.string.course_delete_in_process_confirmation + coreR.string.core_delete_in_process_confirmation } AlertDialog( @@ -407,7 +407,7 @@ private fun CourseVideosUI( text = { Text( text = stringResource( - id = coreR.string.course_delete_download_confirmation_text, + id = coreR.string.core_delete_download_confirmation_text, deleteDownloadBlock?.displayName ?: "" ) ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt index f4e916f6e..9e4dbde3e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/NotAvailableUnitFragment.kt @@ -81,15 +81,15 @@ class NotAvailableUnitFragment : Fragment() { } NotAvailableUnitType.OFFLINE_UNSUPPORTED -> { - title = stringResource(id = coreR.string.course_not_available_offline) + title = stringResource(id = coreR.string.core_not_available_offline) description = - stringResource(id = coreR.string.course_explore_other_parts_when_reconnect) + stringResource(id = coreR.string.core_explore_other_parts_when_reconnect) } NotAvailableUnitType.NOT_DOWNLOADED -> { - title = stringResource(id = coreR.string.course_not_downloaded) + title = stringResource(id = coreR.string.core_not_downloaded) description = - stringResource(id = coreR.string.course_explore_other_parts_when_reconnect_or_download) + stringResource(id = coreR.string.core_explore_other_parts_when_reconnect_or_download) } else -> { diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt index 0710bfb5a..612056392 100644 --- a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -155,7 +155,7 @@ private fun DownloadQueueScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 56.dp), - text = stringResource(id = coreR.string.course_download_queue_title), + text = stringResource(id = coreR.string.core_download_queue_title), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium, maxLines = 1, diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index 9f086481a..1aefc1fdb 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -343,8 +343,10 @@ class CourseOutlineViewModelTest { @Test fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure - )every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true coEvery { downloadDao.getAllDataFlow() } returns flow { emit( listOf( diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt index 01f52e9eb..58689e3e5 100644 --- a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -32,6 +32,15 @@ class DownloadRepository( list.map { it.mapToDomain() } } + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } + } + suspend fun getCourseStructure(courseId: String): CourseStructure { try { val response = api.getCourseStructure( @@ -43,12 +52,7 @@ class DownloadRepository( courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) return response.mapToDomain() } catch (_: Exception) { - val cachedCourseStructure = courseDao.getCourseStructureById(courseId) - if (cachedCourseStructure != null) { - return cachedCourseStructure.mapToDomain() - } else { - throw NoCachedDataException() - } + return getCourseStructureFromCache(courseId) } } diff --git a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt index 7ee7820a0..b67d411b3 100644 --- a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -11,5 +11,8 @@ class DownloadInteractor( suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + suspend fun getCourseStructureFromCache(courseId: String) = + repository.getCourseStructureFromCache(courseId) + suspend fun getCourseStructure(courseId: String) = repository.getCourseStructure(courseId) } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt index be9541808..d98869b80 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -3,6 +3,7 @@ package org.openedx.downloads.presentation.download import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -71,6 +72,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.module.db.DownloadModel 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.OfflineModeDialog @@ -229,12 +231,10 @@ private fun DownloadsScreen( uiState.downloadModels.filter { it.courseId == item.id } val downloadState = uiState.courseDownloadState[item.id] ?: DownloadedState.NOT_DOWNLOADED - val isButtonEnabled = uiState.enableButton[item.id] ?: false CourseItem( downloadCoursePreview = item, downloadModels = downloadModels, downloadedState = downloadState, - isButtonEnabled = isButtonEnabled, apiHostUrl = apiHostUrl, onDownloadClick = { onAction(DownloadsViewActions.DownloadCourse(item.id)) @@ -284,7 +284,6 @@ private fun CourseItem( downloadCoursePreview: DownloadCoursePreview, downloadModels: List, downloadedState: DownloadedState, - isButtonEnabled: Boolean, apiHostUrl: String, onDownloadClick: () -> Unit, onRemoveClick: () -> Unit, @@ -309,7 +308,9 @@ private fun CourseItem( elevation = 4.dp, ) { Box { - Column { + Column( + modifier = Modifier.animateContentSize() + ) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) @@ -377,22 +378,12 @@ private fun CourseItem( verticalAlignment = Alignment.CenterVertically ) { Box(contentAlignment = Alignment.Center) { - if (downloadedState == DownloadedState.DOWNLOADING) { - CircularProgressIndicator( - modifier = Modifier.size(36.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - } else { - Icon( - painter = painterResource(id = coreR.drawable.core_download_waiting), - contentDescription = stringResource( - id = R.string.course_accessibility_stop_downloading_course - ), - tint = MaterialTheme.appColors.error - ) - } + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) IconButton( modifier = Modifier .size(28.dp) @@ -402,15 +393,20 @@ private fun CourseItem( Icon( imageVector = Icons.Filled.Close, contentDescription = stringResource( - id = R.string.course_accessibility_stop_downloading_course + id = R.string.downloads_accessibility_stop_downloading_course ), tint = MaterialTheme.appColors.error ) } } Spacer(modifier = Modifier.width(8.dp)) + val text = if (downloadedState == LOADING_COURSE_STRUCTURE) { + stringResource(R.string.downloads_loading_course_structure) + } else { + stringResource(coreR.string.core_downloading) + } Text( - text = stringResource(coreR.string.course_downloading), + text = text, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary ) @@ -420,7 +416,6 @@ private fun CourseItem( onClick = { onDownloadClick() }, - enabled = isButtonEnabled, content = { IconText( text = stringResource(R.string.downloads_download_course), @@ -571,7 +566,6 @@ private fun CourseItemPreview() { downloadModels = emptyList(), apiHostUrl = "", downloadedState = DownloadedState.NOT_DOWNLOADED, - isButtonEnabled = true, onDownloadClick = {}, onCancelClick = {}, onRemoveClick = {}, diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt index 63af6467f..e3f24b666 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt @@ -10,5 +10,4 @@ data class DownloadsUIState( val downloadCoursePreviews: List = emptyList(), val downloadModels: List = emptyList(), val courseDownloadState: Map = emptyMap(), - val enableButton: Map = emptyMap() ) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index 5f44e9a10..8251e5fea 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -73,6 +74,8 @@ class DownloadsViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + private var downloadJobs = mutableMapOf() + init { fetchDownloads(false) @@ -87,13 +90,22 @@ class DownloadsViewModel( viewModelScope.launch { downloadModelsStatusFlow.collect { statusMap -> val downloadingCourseState = blockIdsByCourseId - .mapValues { (_, blockIds) -> + .mapValues { (courseId, blockIds) -> + val currentCourseState = uiState.value.courseDownloadState[courseId] val blockStates = blockIds.mapNotNull { statusMap[it] } - if (blockStates.isEmpty()) { + val courseDownloadState = if (blockStates.isEmpty()) { DownloadedState.NOT_DOWNLOADED } else { determineCourseState(blockStates) } + val isLoadingCourseStructure = + currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && + courseDownloadState == DownloadedState.NOT_DOWNLOADED + if (isLoadingCourseStructure) { + DownloadedState.LOADING_COURSE_STRUCTURE + } else { + courseDownloadState + } } _uiState.update { state -> @@ -146,7 +158,11 @@ class DownloadsViewModel( } .collect { downloadCoursePreviews -> downloadCoursePreviews.map { - initBlocks(it.id) + try { + initBlocks(it.id, true) + } catch (e: Exception) { + e.printStackTrace() + } } val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } @@ -157,7 +173,6 @@ class DownloadsViewModel( _uiState.update { state -> state.copy( downloadCoursePreviews = downloadCoursePreviews, - enableButton = downloadCoursePreviews.associate { it.id to true } ) } } @@ -178,18 +193,20 @@ class DownloadsViewModel( } fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { - viewModelScope.launch { + downloadJobs[courseId] = viewModelScope.launch { try { _uiState.update { state -> state.copy( - enableButton = state.enableButton.toMap() + (courseId to false) + courseDownloadState = state.courseDownloadState.toMap() + + (courseId to DownloadedState.LOADING_COURSE_STRUCTURE) ) } downloadAllBlocks(fragmentManager, courseId) } catch (e: Exception) { _uiState.update { state -> state.copy( - enableButton = state.enableButton.toMap() + (courseId to true) + courseDownloadState = state.courseDownloadState.toMap() + + (courseId to DownloadedState.NOT_DOWNLOADED) ) } if (e.isInternetError()) { @@ -211,6 +228,13 @@ class DownloadsViewModel( fun cancelDownloading(courseId: String) { viewModelScope.launch { + _uiState.update { state -> + state.copy( + courseDownloadState = state.courseDownloadState.toMap() + + (courseId to DownloadedState.NOT_DOWNLOADED) + ) + } + downloadJobs[courseId]?.cancel() interactor.getAllDownloadModels() .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } .forEach { removeBlockDownloadModel(it.id) } @@ -238,48 +262,51 @@ class DownloadsViewModel( } } - private suspend fun initBlocks(courseId: String): CourseStructure { - val courseStructure = interactor.getCourseStructure(courseId) + private suspend fun initBlocks(courseId: String, cached: Boolean): CourseStructure { + val courseStructure = if (cached) { + interactor.getCourseStructureFromCache(courseId) + } else { + interactor.getCourseStructure(courseId) + } blockIdsByCourseId[courseStructure.id] = courseStructure.blockData.map { it.id } addBlocks(courseStructure.blockData) return courseStructure } private suspend fun downloadAllBlocks(fragmentManager: FragmentManager, courseId: String) { - try { - val courseStructure = initBlocks(courseId) - val downloadModels = interactor.getDownloadModels() - .map { list -> list.filter { it.courseId in courseId } } - .first() - val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } - val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSection -> - addDownloadableChildrenForSequentialBlock(subSection) - val verticalBlocks = allBlocks.values.filter { it.id in subSection.descendants } - val notDownloadedBlocks = courseStructure.blockData.filter { block -> - block.id in verticalBlocks.flatMap { it.descendants } && - block.isDownloadable && - downloadModels.none { it.id == block.id } - } - if (notDownloadedBlocks.isNotEmpty()) subSection else null + val courseStructure = initBlocks(courseId, false) + val downloadModels = interactor.getDownloadModels() + .map { list -> list.filter { it.courseId in courseId } } + .first() + val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } + val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSection -> + addDownloadableChildrenForSequentialBlock(subSection) + val verticalBlocks = allBlocks.values.filter { it.id in subSection.descendants } + val notDownloadedBlocks = courseStructure.blockData.filter { block -> + block.id in verticalBlocks.flatMap { it.descendants } && + block.isDownloadable && + downloadModels.none { it.id == block.id } } - - downloadDialogManager.showPopup( - subSectionsBlocks = notDownloadedSubSectionBlocks, - courseId = courseId, - isBlocksDownloaded = false, - fragmentManager = fragmentManager, - removeDownloadModels = ::removeDownloadModels, - saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) + if (notDownloadedBlocks.isNotEmpty()) subSection else null + } + downloadDialogManager.showPopup( + subSectionsBlocks = notDownloadedSubSectionBlocks, + courseId = courseId, + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { blockId -> + saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) + }, + onDismissClick = { + _uiState.update { state -> + state.copy( + courseDownloadState = state.courseDownloadState.toMap() + + (courseId to DownloadedState.NOT_DOWNLOADED) + ) } - ) - } finally { - _uiState.update { state -> - state.copy( - enableButton = state.enableButton.toMap() + (courseId to true) - ) } - } + ) } } diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml index 7acdff193..5a0503db1 100644 --- a/downloads/src/main/res/values/strings.xml +++ b/downloads/src/main/res/values/strings.xml @@ -8,5 +8,6 @@ You currently have no courses with downloadable content. %1$s downloaded %1$s available - Stop downloading course + Stop downloading course + Loading course structure… \ No newline at end of file From 528dd3fc26411a7d70802a19d90200b007fdfd81 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 6 Mar 2025 14:53:42 +0200 Subject: [PATCH 08/29] feat: downloads analytic --- .../java/org/openedx/app/AnalyticsManager.kt | 4 +- .../main/java/org/openedx/app/di/AppModule.kt | 2 + .../java/org/openedx/app/di/ScreenModule.kt | 3 +- .../org/openedx/core/module/DownloadWorker.kt | 20 +++++++- .../core/presentation/DownloadsAnalytics.kt | 49 +++++++++++++++++++ .../DownloadConfirmDialogFragment.kt | 3 +- .../downloaddialog/DownloadDialogManager.kt | 38 ++++++++++---- .../downloaddialog/DownloadDialogUIState.kt | 1 + .../DownloadErrorDialogFragment.kt | 2 +- .../DownloadStorageErrorDialogFragment.kt | 2 +- .../download/DownloadsViewModel.kt | 22 +++++++++ 11 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 138692348..6c29cdf12 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -2,6 +2,7 @@ package org.openedx.app import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.presentation.CoreAnalytics +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 @@ -21,7 +22,8 @@ class AnalyticsManager : DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, - WhatsNewAnalytics { + WhatsNewAnalytics, + DownloadsAnalytics { 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 148395665..b4633cc27 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -40,6 +40,7 @@ import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager @@ -207,6 +208,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } 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 ec6002f1d..1e95debdd 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -522,7 +522,8 @@ val screenModule = module { workerController = get(), downloadHelper = get(), downloadDialogManager = get(), - fileUtil = get() + fileUtil = get(), + analytics = get() ) } } diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index afb2f6383..f91a19c6a 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -23,6 +23,9 @@ import org.openedx.core.module.download.AbstractDownloader.DownloadResult import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged @@ -33,12 +36,14 @@ class DownloadWorker( parameters: WorkerParameters, ) : CoroutineWorker(context, parameters), CoroutineScope { - private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) + private val analytics: DownloadsAnalytics by inject(DownloadsAnalytics::class.java) private var downloadEnqueue = listOf() private var downloadError = mutableListOf() @@ -134,9 +139,11 @@ class DownloadWorker( ) ) ) + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_STARTED) val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) when (downloadResult) { DownloadResult.SUCCESS -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COMPLETED) val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) if (updatedModel == null) { downloadDao.removeDownloadModel(downloadTask.id) @@ -149,10 +156,12 @@ class DownloadWorker( } DownloadResult.CANCELED -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) downloadDao.removeDownloadModel(downloadTask.id) } DownloadResult.ERROR -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) downloadDao.removeDownloadModel(downloadTask.id) downloadError.add(downloadTask) } @@ -173,6 +182,15 @@ class DownloadWorker( notificationManager.createNotificationChannel(notificationChannel) } + fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(DownloadsAnalyticsKey.NAME.key, event.biValue) + } + ) + } + companion object { const val WORKER_TAG = "downloadWorker" diff --git a/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt new file mode 100644 index 000000000..625140d4f --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt @@ -0,0 +1,49 @@ +package org.openedx.core.presentation + +interface DownloadsAnalytics { + fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) +} + +enum class DownloadsAnalyticsEvent(val eventName: String, val biValue: String) { + DOWNLOAD_COURSE_CLICKED( + "Downloads:Download Course Clicked", + "edx.bi.app.downloads.downloadCourseClicked" + ), + CANCEL_DOWNLOAD_CLICKED( + "Downloads:Cancel Download Clicked", + "edx.bi.app.downloads.cancelDownloadClicked" + ), + REMOVE_DOWNLOAD_CLICKED( + "Downloads:Remove Download Clicked", + "edx.bi.app.downloads.removeDownloadClicked" + ), + DOWNLOAD_CONFIRMED( + "Downloads:Download Confirmed", + "edx.bi.app.downloads.downloadConfirmed" + ), + DOWNLOAD_CANCELLED( + "Downloads:Download Cancelled", + "edx.bi.app.downloads.downloadCancelled" + ), + DOWNLOAD_REMOVED( + "Downloads:Download Removed", + "edx.bi.app.downloads.downloadRemoved" + ), + DOWNLOAD_ERROR( + "Downloads:Download Error", + "edx.bi.app.downloads.downloadError" + ), + DOWNLOAD_COMPLETED( + "Downloads:Download Completed", + "edx.bi.app.downloads.downloadCompleted" + ), + DOWNLOAD_STARTED( + "Downloads:Download Started", + "edx.bi.app.downloads.downloadStarted" + ), +} + +enum class DownloadsAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index 78259f297..a8e8321d7 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -99,6 +99,7 @@ class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { onConfirmClick = { uiState.saveDownloadModels() dismiss() + listener?.onConfirmClick() }, onRemoveClick = { uiState.removeDownloadModels() @@ -106,7 +107,7 @@ class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { }, onCancelClick = { dismiss() - listener?.onCancel() + listener?.onCancelClick() } ) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 2988f1e71..32880fa16 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -15,7 +15,8 @@ import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection interface DownloadDialogListener { - fun onCancel() + fun onCancelClick() + fun onConfirmClick() } interface DownloadDialog { @@ -85,12 +86,21 @@ class DownloadDialogManager( } val dialogListener = object : DownloadDialogListener { - override fun onCancel() { + override fun onCancelClick() { state.onDismissClick() } + + override fun onConfirmClick() { + state.onConfirmClick() + } + } + if (dialog != null) { + dialog.listener = dialogListener + dialog.show(state.fragmentManager, dialog::class.java.simpleName) + } else { + state.onConfirmClick() + state.saveDownloadModels() } - dialog?.listener = dialogListener - dialog?.show(state.fragmentManager, dialog::class.java.simpleName) ?: state.saveDownloadModels() } } } @@ -104,6 +114,7 @@ class DownloadDialogManager( removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, ) { createDownloadItems( subSectionsBlocks = subSectionsBlocks, @@ -113,7 +124,8 @@ class DownloadDialogManager( onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, saveDownloadModels = saveDownloadModels, - onDismissClick = onDismissClick + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick ) } @@ -159,10 +171,12 @@ class DownloadDialogManager( courseIds.forEach { courseId -> val courseStructure = interactor.getCourseStructureFromCache(courseId) - val allSubSectionBlocks = courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } + val allSubSectionBlocks = + courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } allSubSectionBlocks.forEach { subSectionBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val verticalBlocks = + courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds } @@ -207,13 +221,15 @@ class DownloadDialogManager( removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, ) { coroutineScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) val downloadModelIds = interactor.getAllDownloadModels().map { it.id } val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val verticalBlocks = + courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = verticalBlocks.flatMap { verticalBlock -> courseStructure.blockData.filter { it.id in verticalBlock.descendants && @@ -222,7 +238,10 @@ class DownloadDialogManager( } } val size = blocks.sumOf { it.getFileSize() } - if (size > 0) DownloadDialogItem(title = subSectionBlock.displayName, size = size) else null + if (size > 0) DownloadDialogItem( + title = subSectionBlock.displayName, + size = size + ) else null } uiState.emit( @@ -242,6 +261,7 @@ class DownloadDialogManager( }, saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, ) ) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt index af75e4c66..72288449b 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt @@ -15,4 +15,5 @@ data class DownloadDialogUIState( val removeDownloadModels: () -> Unit, val saveDownloadModels: () -> Unit, val onDismissClick: () -> Unit = {}, + val onConfirmClick: () -> Unit = {}, ) : Parcelable diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index bf1e6c3e2..202e8f540 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -87,7 +87,7 @@ class DownloadErrorDialogFragment : DialogFragment(), DownloadDialog { }, onCancelClick = { dismiss() - listener?.onCancel() + listener?.onCancelClick() } ) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index a1de5e4cc..94ccee80f 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -81,7 +81,7 @@ class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { downloadDialogResource = downloadDialogResource, onCancelClick = { dismiss() - listener?.onCancel() + listener?.onCancelClick() } ) } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index 8251e5fea..cc7450300 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -29,6 +29,9 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection @@ -47,6 +50,7 @@ class DownloadsViewModel( private val resourceManager: ResourceManager, private val fileUtil: FileUtil, private val config: Config, + private val analytics: DownloadsAnalytics, preferencesManager: CorePreferences, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, @@ -193,6 +197,7 @@ class DownloadsViewModel( } fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COURSE_CLICKED) downloadJobs[courseId] = viewModelScope.launch { try { _uiState.update { state -> @@ -203,6 +208,7 @@ class DownloadsViewModel( } downloadAllBlocks(fragmentManager, courseId) } catch (e: Exception) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) _uiState.update { state -> state.copy( courseDownloadState = state.courseDownloadState.toMap() + @@ -227,6 +233,7 @@ class DownloadsViewModel( } fun cancelDownloading(courseId: String) { + logEvent(DownloadsAnalyticsEvent.CANCEL_DOWNLOAD_CLICKED) viewModelScope.launch { _uiState.update { state -> state.copy( @@ -242,6 +249,7 @@ class DownloadsViewModel( } fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) viewModelScope.launch { val downloadModels = interactor.getDownloadModels().first().filter { it.courseId == courseId } @@ -257,6 +265,7 @@ class DownloadsViewModel( fragmentManager = fragmentManager, removeDownloadModels = { downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_REMOVED) } ) } @@ -299,12 +308,25 @@ class DownloadsViewModel( saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) }, onDismissClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) _uiState.update { state -> state.copy( courseDownloadState = state.courseDownloadState.toMap() + (courseId to DownloadedState.NOT_DOWNLOADED) ) } + }, + onConfirmClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CONFIRMED) + } + ) + } + + fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(DownloadsAnalyticsKey.NAME.key, event.biValue) } ) } From 26eb430864bc3e740d387f74439f441f7d095dbc Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 6 Mar 2025 16:58:17 +0200 Subject: [PATCH 09/29] feat: junit test --- .../downloaddialog/DownloadDialogManager.kt | 12 +- .../downloads/DownloadsViewModelTest.kt | 372 ++++++++++++++++++ 2 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 32880fa16..b1ce29986 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -238,10 +238,14 @@ class DownloadDialogManager( } } val size = blocks.sumOf { it.getFileSize() } - if (size > 0) DownloadDialogItem( - title = subSectionBlock.displayName, - size = size - ) else null + if (size > 0) { + DownloadDialogItem( + title = subSectionBlock.displayName, + size = size + ) + } else { + null + } } uiState.emit( diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt new file mode 100644 index 000000000..5d59e5d22 --- /dev/null +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -0,0 +1,372 @@ +package org.openedx.downloads + +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.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.downloads.presentation.download.DownloadsViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import java.net.UnknownHostException +import java.util.Date + +class DownloadsViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + // Mocks for all dependencies + private val downloadsRouter = mockk(relaxed = true) + private val networkConnection = mockk(relaxed = true) + private val interactor = mockk(relaxed = true) + private val downloadDialogManager = mockk(relaxed = true) + private val resourceManager = mockk(relaxed = true) + private val fileUtil = mockk(relaxed = true) + private val config = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val preferencesManager = mockk(relaxed = true) + private val coreAnalytics = mockk(relaxed = true) + private val downloadDao = mockk(relaxed = true) + private val workerController = mockk(relaxed = true) + private val downloadHelper = mockk(relaxed = true) + + private val noInternet = "No connection" + private val unknownError = "Unknown error" + + private val downloadCoursePreview = + DownloadCoursePreview(id = "", name = "", image = "", totalSize = 0) + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f + ) + private val blocks = listOf( + Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id1", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("id2"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id2", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ) + ) + + private val downloadModel = DownloadModel( + "id", + "title", + "", + 0, + "", + "url", + FileType.VIDEO, + DownloadedState.NOT_DOWNLOADED, + null + ) + + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { config.getApiHostURL() } returns "http://localhost:8000" + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns unknownError + every { networkConnection.isOnline() } returns true + + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { + emit(listOf(downloadCoursePreview)) + } + coEvery { interactor.getCourseStructureFromCache("course1") } returns courseStructure + coEvery { interactor.getCourseStructure("course1") } returns courseStructure + coEvery { interactor.getDownloadModels() } returns flowOf(emptyList()) + coEvery { interactor.getAllDownloadModels() } returns emptyList() + coEvery { downloadDao.getAllDataFlow() } returns flowOf( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) + ) + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `onSettingsClick should navigate to settings`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.onSettingsClick(fragmentManager) + verify(exactly = 1) { downloadsRouter.navigateToSettings(fragmentManager) } + } + + @Test + fun `downloadCourse should update courseDownloadState and show download dialog`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + + coVerify(exactly = 1) { + downloadDialogManager.showPopup( + any(), any(), any(), any(), any(), any(), any(), any(), any() + ) + } + } + + @Test + fun `cancelDownloading should update courseDownloadState to NOT_DOWNLOADED and cancel download job`() = + runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + viewModel.cancelDownloading("course1") + advanceUntilIdle() + + assertEquals( + DownloadedState.NOT_DOWNLOADED, + viewModel.uiState.value.courseDownloadState["course1"] + ) + + coVerify { interactor.getAllDownloadModels() } + } + + @Test + fun `removeDownloads should show remove popup with correct parameters`() = runTest { + coEvery { interactor.getDownloadModels() } returns flowOf(listOf(downloadModel)) + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.removeDownloads(fragmentManager, "course1") + advanceUntilIdle() + + coVerify { + downloadDialogManager.showRemoveDownloadModelPopup( + any(), + any(), + any() + ) + } + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + } + + @Test + fun `refreshData no internet error should emit snack bar message`() = runTest { + every { networkConnection.isOnline() } returns true + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { throw UnknownHostException() } + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + val deferred = async { viewModel.uiMessage.first() } + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) + // Also verify that the refreshing flag is cleared. + assertFalse(viewModel.uiState.value.isRefreshing) + } +} From cc4f47fb443124d51953ee36459cbea09b1ea03c Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 7 Mar 2025 12:26:12 +0200 Subject: [PATCH 10/29] feat: navigate to course outline, swipe refresh on empty state --- .../java/org/openedx/app/di/ScreenModule.kt | 4 ++- .../downloads/presentation/DownloadsRouter.kt | 6 +++++ .../download/DownloadsFragment.kt | 24 +++++++++++++++-- .../download/DownloadsViewModel.kt | 26 +++++++++++++++++++ .../downloads/DownloadsViewModelTest.kt | 20 +++++++++----- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 1e95debdd..8415b73e3 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -523,7 +523,9 @@ val screenModule = module { downloadHelper = get(), downloadDialogManager = get(), fileUtil = get(), - analytics = get() + analytics = get(), + discoveryNotifier = get(), + router = get() ) } } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt index fd36f0222..0b6445f19 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt @@ -5,4 +5,10 @@ import androidx.fragment.app.FragmentManager interface DownloadsRouter { fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + ) } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt index d98869b80..f1e59270e 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -21,7 +21,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider @@ -128,6 +130,13 @@ class DownloadsFragment : Fragment() { viewModel.refreshData() } + is DownloadsViewActions.OpenCourse -> { + viewModel.navigateToCourseOutline( + fm = requireActivity().supportFragmentManager, + courseId = action.courseId + ) + } + is DownloadsViewActions.DownloadCourse -> { viewModel.downloadCourse( requireActivity().supportFragmentManager, @@ -211,7 +220,11 @@ private fun DownloadsScreen( CircularProgressIndicator(color = MaterialTheme.appColors.primary) } } else if (uiState.downloadCoursePreviews.isEmpty()) { - EmptyState() + EmptyState( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) } else { Box( modifier = Modifier @@ -236,6 +249,9 @@ private fun DownloadsScreen( downloadModels = downloadModels, downloadedState = downloadState, apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, onDownloadClick = { onAction(DownloadsViewActions.DownloadCourse(item.id)) }, @@ -278,6 +294,7 @@ private fun DownloadsScreen( ) } +@OptIn(ExperimentalMaterialApi::class) @Composable private fun CourseItem( modifier: Modifier = Modifier, @@ -285,6 +302,7 @@ private fun CourseItem( downloadModels: List, downloadedState: DownloadedState, apiHostUrl: String, + onCourseClick: () -> Unit, onDownloadClick: () -> Unit, onRemoveClick: () -> Unit, onCancelClick: () -> Unit @@ -306,6 +324,7 @@ private fun CourseItem( backgroundColor = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.courseImageShape, elevation = 4.dp, + onClick = onCourseClick ) { Box { Column( @@ -507,7 +526,7 @@ private fun EmptyState( modifier: Modifier = Modifier ) { Box( - modifier = modifier.fillMaxSize(), + modifier = modifier, contentAlignment = Alignment.Center ) { Column( @@ -566,6 +585,7 @@ private fun CourseItemPreview() { downloadModels = emptyList(), apiHostUrl = "", downloadedState = DownloadedState.NOT_DOWNLOADED, + onCourseClick = {}, onDownloadClick = {}, onCancelClick = {}, onRemoveClick = {}, diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index cc7450300..e9d2d8cd9 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -35,6 +35,8 @@ import org.openedx.core.presentation.DownloadsAnalyticsKey import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.downloads.domain.interactor.DownloadInteractor import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.foundation.extension.isInternetError @@ -51,6 +53,8 @@ class DownloadsViewModel( private val fileUtil: FileUtil, private val config: Config, private val analytics: DownloadsAnalytics, + private val discoveryNotifier: DiscoveryNotifier, + private val router: DownloadsRouter, preferencesManager: CorePreferences, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, @@ -83,6 +87,14 @@ class DownloadsViewModel( init { fetchDownloads(false) + viewModelScope.launch { + discoveryNotifier.notifier.collect { + if (it is CourseDashboardUpdate) { + fetchDownloads(true) + } + } + } + viewModelScope.launch { downloadingModelsFlow.collect { downloadModels -> _uiState.update { state -> @@ -322,6 +334,19 @@ class DownloadsViewModel( ) } + fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String + ) { + val coursePreview = + _uiState.value.downloadCoursePreviews.find { it.id == courseId } ?: return + router.navigateToCourseOutline( + fm = fm, + courseId = coursePreview.id, + courseTitle = coursePreview.name, + ) + } + fun logEvent(event: DownloadsAnalyticsEvent) { analytics.logEvent( event = event.eventName, @@ -335,6 +360,7 @@ class DownloadsViewModel( interface DownloadsViewActions { object OpenSettings : DownloadsViewActions object SwipeRefresh : DownloadsViewActions + data class OpenCourse(val courseId: String) : DownloadsViewActions data class DownloadCourse(val courseId: String) : DownloadsViewActions data class CancelDownloading(val courseId: String) : DownloadsViewActions data class RemoveDownloads(val courseId: String) : DownloadsViewActions diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 5d59e5d22..2e0b818e3 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -14,10 +14,8 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Before @@ -45,6 +43,7 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.downloads.domain.interactor.DownloadInteractor import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.downloads.presentation.download.DownloadsViewModel @@ -75,6 +74,8 @@ class DownloadsViewModelTest { private val downloadDao = mockk(relaxed = true) private val workerController = mockk(relaxed = true) private val downloadHelper = mockk(relaxed = true) + private val router = mockk(relaxed = true) + private val discoveryNotifier = mockk(relaxed = true) private val noInternet = "No connection" private val unknownError = "Unknown error" @@ -207,11 +208,6 @@ class DownloadsViewModelTest { ) } - @After - fun tearDown() { - Dispatchers.resetMain() - } - @Test fun `onSettingsClick should navigate to settings`() = runTest { val viewModel = DownloadsViewModel( @@ -223,6 +219,8 @@ class DownloadsViewModelTest { fileUtil, config, analytics, + discoveryNotifier, + router, preferencesManager, coreAnalytics, downloadDao, @@ -247,6 +245,8 @@ class DownloadsViewModelTest { fileUtil, config, analytics, + discoveryNotifier, + router, preferencesManager, coreAnalytics, downloadDao, @@ -280,6 +280,8 @@ class DownloadsViewModelTest { fileUtil, config, analytics, + discoveryNotifier, + router, preferencesManager, coreAnalytics, downloadDao, @@ -316,6 +318,8 @@ class DownloadsViewModelTest { fileUtil, config, analytics, + discoveryNotifier, + router, preferencesManager, coreAnalytics, downloadDao, @@ -353,6 +357,8 @@ class DownloadsViewModelTest { fileUtil, config, analytics, + discoveryNotifier, + router, preferencesManager, coreAnalytics, downloadDao, From b03927c6762eeebbea8c523376d6931fa28bc8f0 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Wed, 12 Mar 2025 12:33:50 +0200 Subject: [PATCH 11/29] fix: changes according PR review --- .../main/java/org/openedx/app/MainFragment.kt | 50 +- app/src/main/res/values/main_manu_tab_ids.xml | 2 +- build.gradle | 1 + core/build.gradle | 3 +- .../java/org/openedx/core/ui/ComposeCommon.kt | 34 ++ .../learn/presentation/LearnFragment.kt | 43 +- downloads/build.gradle | 2 +- downloads/proguard-rules.pro | 2 +- downloads/src/main/AndroidManifest.xml | 2 +- .../data/repository/DownloadRepository.kt | 2 +- .../download/DownloadsFragment.kt | 516 ----------------- .../presentation/download/DownloadsScreen.kt | 518 ++++++++++++++++++ .../download/DownloadsViewModel.kt | 5 +- 13 files changed, 605 insertions(+), 575 deletions(-) create mode 100644 downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 7fa948af1..d7978b9f6 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -40,10 +40,15 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } - @Suppress("LongMethod", "CyclomaticComplexMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + handleArguments() + setupBottomNavigation() + setupViewPager() + observeViewModel() + } + private fun handleArguments() { requireArguments().apply { getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> val infoType = getString(ARG_INFO_TYPE) @@ -56,8 +61,24 @@ class MainFragment : Fragment(R.layout.fragment_main) { putString(ARG_INFO_TYPE, "") } } + } + private fun setupBottomNavigation() { val openTabArg = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val initialMenuId = getInitialMenuId(openTabArg) + binding.bottomNavView.selectedItemId = initialMenuId + + val menu = binding.bottomNavView.menu + menu.clear() + + val tabList = createTabList(openTabArg) + addMenuItems(menu, tabList) + setupBottomNavListener(tabList) + + requireArguments().remove(ARG_OPEN_TAB) + } + + private fun createTabList(openTabArg: String): List> { val learnFragment = LearnFragment.newInstance( openTab = if (openTabArg == HomeTab.PROGRAMS.name) { LearnTab.PROGRAMS.name @@ -65,7 +86,8 @@ class MainFragment : Fragment(R.layout.fragment_main) { LearnTab.COURSES.name } ) - val tabList = mutableListOf>().apply { + + return mutableListOf>().apply { add(R.id.fragmentLearn to learnFragment) add(R.id.fragmentDiscover to viewModel.getDiscoveryFragment) if (viewModel.isDownloadsFragmentEnabled) { @@ -73,9 +95,9 @@ class MainFragment : Fragment(R.layout.fragment_main) { } add(R.id.fragmentProfile to ProfileFragment()) } + } - val menu = binding.bottomNavView.menu - menu.clear() + private fun addMenuItems(menu: Menu, tabList: List>) { val tabTitles = mapOf( R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), @@ -88,13 +110,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentDownloads to R.drawable.app_ic_download_cloud, R.id.fragmentProfile to R.drawable.app_ic_profile ) + for ((id, _) in tabList) { val menuItem = menu.add(Menu.NONE, id, Menu.NONE, tabTitles[id] ?: "") tabIcons[id]?.let { menuItem.setIcon(it) } } + } - initViewPager(tabList) - + private fun setupBottomNavListener(tabList: List>) { val menuIdToIndex = tabList.mapIndexed { index, pair -> pair.first to index }.toMap() binding.bottomNavView.setOnItemSelectedListener { menuItem -> @@ -109,7 +132,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { } true } + } + + private fun setupViewPager() { + val tabList = createTabList(requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) + initViewPager(tabList) + } + private fun observeViewModel() { viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) } @@ -121,8 +151,10 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } } + } - val initialMenuId = when (openTabArg) { + private fun getInitialMenuId(openTabArg: String): Int { + return when (openTabArg) { HomeTab.LEARN.name, HomeTab.PROGRAMS.name -> R.id.fragmentLearn HomeTab.DISCOVER.name -> R.id.fragmentDiscover HomeTab.DOWNLOADS.name -> if (viewModel.isDownloadsFragmentEnabled) { @@ -130,13 +162,9 @@ class MainFragment : Fragment(R.layout.fragment_main) { } else { R.id.fragmentLearn } - HomeTab.PROFILE.name -> R.id.fragmentProfile else -> R.id.fragmentLearn } - binding.bottomNavView.selectedItemId = initialMenuId - - requireArguments().remove(ARG_OPEN_TAB) } private fun initViewPager(tabList: List>) { 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 65a44f9d8..f769b5bde 100644 --- a/app/src/main/res/values/main_manu_tab_ids.xml +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -4,4 +4,4 @@ - \ No newline at end of file + diff --git a/build.gradle b/build.gradle index 390d02699..f7fb3cf91 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,7 @@ ext { zip_version = '2.6.3' //testing + compose_ui_tooling = '1.7.8' mockk_version = '1.13.12' android_arch_version = '2.2.0' junit_version = '4.13.2' diff --git a/core/build.gradle b/core/build.gradle index f7b5d1cec..db0ce4bb1 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -119,7 +119,8 @@ dependencies { // OpenEdx libs api("com.github.openedx:openedx-app-foundation-android:1.0.0") - debugApi "androidx.compose.ui:ui-tooling:1.7.8" + // Preview + debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' 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 b9280a8b1..9961c2887 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -222,6 +222,40 @@ 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, 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 c6843a5f8..dd5c0eb34 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -5,7 +5,6 @@ import android.view.View import androidx.compose.animation.core.animateFloatAsState 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.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -15,12 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -44,6 +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.crop import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -55,7 +53,6 @@ import org.openedx.dashboard.databinding.FragmentLearnBinding import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue import org.openedx.learn.LearnType -import org.openedx.core.R as CoreR class LearnFragment : Fragment(R.layout.fragment_learn) { @@ -140,7 +137,7 @@ private fun Header( .then(contentWidth), horizontalAlignment = Alignment.CenterHorizontally ) { - Title( + MainToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -158,40 +155,6 @@ private fun Header( } } -@Composable -private fun Title( - 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 = CoreR.string.core_accessibility_settings) - ) - } - } -} - @Composable private fun LearnDropdownMenu( modifier: Modifier = Modifier, @@ -277,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - Title( + MainToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = {} ) diff --git a/downloads/build.gradle b/downloads/build.gradle index 237ab0f40..df169ecd9 100644 --- a/downloads/build.gradle +++ b/downloads/build.gradle @@ -62,4 +62,4 @@ dependencies { testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" -} \ No newline at end of file +} diff --git a/downloads/proguard-rules.pro b/downloads/proguard-rules.pro index dccbe504f..cdb308aa0 100644 --- a/downloads/proguard-rules.pro +++ b/downloads/proguard-rules.pro @@ -4,4 +4,4 @@ # shrinking, optimization, and obfuscation for the entire application, including this library. -dontshrink -dontoptimize --dontobfuscate \ No newline at end of file +-dontobfuscate diff --git a/downloads/src/main/AndroidManifest.xml b/downloads/src/main/AndroidManifest.xml index 44008a433..e10007615 100644 --- a/downloads/src/main/AndroidManifest.xml +++ b/downloads/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - \ No newline at end of file + diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt index 58689e3e5..adf10aa59 100644 --- a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -23,9 +23,9 @@ class DownloadRepository( val username = corePreferences.user?.username ?: "" val response = api.getDownloadCoursesPreview(username) val downloadCoursesPreview = response.map { it.mapToDomain() } + emit(downloadCoursesPreview) val downloadCoursesPreviewEntity = response.map { it.mapToRoomEntity() } dao.insertDownloadCoursePreview(downloadCoursesPreviewEntity) - emit(downloadCoursesPreview) } fun getDownloadModels() = dao.getAllDataFlow().map { list -> diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt index f1e59270e..1dc4d1be9 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -3,98 +3,13 @@ package org.openedx.downloads.presentation.download import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -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.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Card -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider -import androidx.compose.material.DropdownMenu -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.CloudDone -import androidx.compose.material.icons.filled.MoreHoriz -import androidx.compose.material.icons.outlined.CloudDownload -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale 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 -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -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 coil.compose.AsyncImage -import coil.request.ImageRequest import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.domain.model.DownloadCoursePreview -import org.openedx.core.module.db.DownloadModel -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.OfflineModeDialog -import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.OpenEdXDropdownMenuItem -import org.openedx.core.ui.Toolbar -import org.openedx.core.ui.crop -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.downloads.R -import org.openedx.foundation.extension.toFileSize -import org.openedx.foundation.extension.toImageLink -import org.openedx.foundation.presentation.UIMessage -import org.openedx.foundation.presentation.rememberWindowSize -import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.core.R as coreR class DownloadsFragment : Fragment() { @@ -161,434 +76,3 @@ class DownloadsFragment : Fragment() { } } } - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun DownloadsScreen( - uiState: DownloadsUIState, - uiMessage: UIMessage?, - apiHostUrl: String, - hasInternetConnection: Boolean, - onAction: (DownloadsViewActions) -> 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(DownloadsViewActions.SwipeRefresh) } - ) - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - - Scaffold( - scaffoldState = scaffoldState, - modifier = Modifier - .fillMaxSize(), - backgroundColor = MaterialTheme.appColors.background, - topBar = { - Toolbar( - modifier = Modifier - .statusBarsInset() - .displayCutoutForLandscape(), - label = stringResource(id = R.string.downloads), - canShowSettingsIcon = true, - onSettingsClick = { - onAction(DownloadsViewActions.OpenSettings) - } - ) - }, - content = { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .pullRefresh(pullRefreshState) - ) { - if (uiState.isLoading) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } else if (uiState.downloadCoursePreviews.isEmpty()) { - EmptyState( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - ) - } else { - Box( - modifier = Modifier - .fillMaxSize() - .displayCutoutForLandscape() - .padding(paddingValues) - .padding(horizontal = 16.dp), - contentAlignment = Alignment.TopCenter - ) { - LazyColumn( - modifier = contentWidth, - contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - items(uiState.downloadCoursePreviews) { item -> - val downloadModels = - uiState.downloadModels.filter { it.courseId == item.id } - val downloadState = uiState.courseDownloadState[item.id] - ?: DownloadedState.NOT_DOWNLOADED - CourseItem( - downloadCoursePreview = item, - downloadModels = downloadModels, - downloadedState = downloadState, - apiHostUrl = apiHostUrl, - onCourseClick = { - onAction(DownloadsViewActions.OpenCourse(item.id)) - }, - onDownloadClick = { - onAction(DownloadsViewActions.DownloadCourse(item.id)) - }, - onCancelClick = { - onAction(DownloadsViewActions.CancelDownloading(item.id)) - }, - onRemoveClick = { - onAction(DownloadsViewActions.RemoveDownloads(item.id)) - } - ) - } - } - } - } - - 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(DownloadsViewActions.SwipeRefresh) - } - ) - } - } - } - ) -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun CourseItem( - modifier: Modifier = Modifier, - downloadCoursePreview: DownloadCoursePreview, - downloadModels: List, - downloadedState: DownloadedState, - apiHostUrl: String, - onCourseClick: () -> Unit, - onDownloadClick: () -> Unit, - onRemoveClick: () -> Unit, - onCancelClick: () -> Unit -) { - var isDropdownExpanded by remember { mutableStateOf(false) } - val downloadedSize = downloadModels - .filter { it.downloadedState == DownloadedState.DOWNLOADED } - .sumOf { it.size } - val availableSize = downloadCoursePreview.totalSize - downloadedSize - val availableSizeString = availableSize.toFileSize(space = false) - val progress: Float = try { - downloadedSize.toFloat() / availableSize.toFloat() - } catch (_: ArithmeticException) { - 0f - } - Card( - modifier = modifier - .fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.courseImageShape, - elevation = 4.dp, - onClick = onCourseClick - ) { - Box { - Column( - modifier = Modifier.animateContentSize() - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .height(120.dp) - ) - Column( - modifier = Modifier - .padding(horizontal = 12.dp) - .padding(top = 8.dp, bottom = 12.dp), - ) { - Text( - text = downloadCoursePreview.name, - style = MaterialTheme.appTypography.titleLarge, - color = MaterialTheme.appColors.textDark, - overflow = TextOverflow.Ellipsis, - minLines = 1, - maxLines = 2 - ) - Spacer(modifier = Modifier.height(8.dp)) - if (downloadedState != DownloadedState.DOWNLOADED && downloadedSize != 0L) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(CircleShape), - progress = progress, - color = MaterialTheme.appColors.successGreen, - backgroundColor = MaterialTheme.appColors.divider - ) - } - if (downloadedSize != 0L) { - Spacer(modifier = Modifier.height(4.dp)) - IconText( - icon = Icons.Filled.CloudDone, - color = MaterialTheme.appColors.successGreen, - text = stringResource( - R.string.downloaded_downloaded_size, - downloadedSize.toFileSize(space = false) - ) - ) - } - if (downloadedState != DownloadedState.DOWNLOADED) { - Spacer(modifier = Modifier.height(4.dp)) - IconText( - icon = Icons.Outlined.CloudDownload, - color = MaterialTheme.appColors.textPrimaryVariant, - text = stringResource( - R.string.downloaded_available_size, - availableSizeString - ) - ) - } - Spacer(modifier = Modifier.height(8.dp)) - if (downloadedState.isWaitingOrDownloading) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator( - modifier = Modifier.size(36.dp), - backgroundColor = Color.LightGray, - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primary - ) - IconButton( - modifier = Modifier - .size(28.dp) - .padding(2.dp), - onClick = onCancelClick - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource( - id = R.string.downloads_accessibility_stop_downloading_course - ), - tint = MaterialTheme.appColors.error - ) - } - } - Spacer(modifier = Modifier.width(8.dp)) - val text = if (downloadedState == LOADING_COURSE_STRUCTURE) { - stringResource(R.string.downloads_loading_course_structure) - } else { - stringResource(coreR.string.core_downloading) - } - Text( - text = text, - style = MaterialTheme.appTypography.titleSmall, - color = MaterialTheme.appColors.textPrimary - ) - } - } else if (downloadedState == DownloadedState.NOT_DOWNLOADED) { - OpenEdXButton( - onClick = { - onDownloadClick() - }, - content = { - IconText( - text = stringResource(R.string.downloads_download_course), - icon = Icons.Outlined.CloudDownload, - color = MaterialTheme.appColors.primaryButtonText, - textStyle = MaterialTheme.appTypography.labelLarge - ) - } - ) - } - } - } - - Column( - modifier = Modifier - .align(Alignment.TopEnd), - ) { - if (downloadedSize != 0L || downloadedState.isWaitingOrDownloading) { - MoreButton( - onClick = { - isDropdownExpanded = true - } - ) - } - DropdownMenu( - modifier = Modifier - .crop(vertical = 8.dp) - .defaultMinSize(minWidth = 269.dp) - .background(MaterialTheme.appColors.background), - expanded = isDropdownExpanded, - onDismissRequest = { isDropdownExpanded = false }, - ) { - Column { - if (downloadedSize != 0L) { - OpenEdXDropdownMenuItem( - text = stringResource(R.string.downloads_remove_course_downloads), - onClick = { - isDropdownExpanded = false - onRemoveClick() - } - ) - Divider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.appColors.divider - ) - } - if (downloadedState.isWaitingOrDownloading) { - OpenEdXDropdownMenuItem( - text = stringResource(R.string.downloads_cancel_download), - onClick = { - isDropdownExpanded = false - onCancelClick() - } - ) - } - } - } - } - } - } -} - -@Composable -private fun MoreButton( - modifier: Modifier = Modifier, - onClick: () -> Unit -) { - IconButton( - modifier = modifier, - onClick = onClick - ) { - Icon( - modifier = Modifier - .size(30.dp) - .background( - color = MaterialTheme.appColors.onPrimary.copy(alpha = 0.5f), - shape = CircleShape - ) - .padding(4.dp), - imageVector = Icons.Default.MoreHoriz, - contentDescription = null, - tint = MaterialTheme.appColors.onSurface - ) - } -} - -@Composable -private fun EmptyState( - modifier: Modifier = Modifier -) { - Box( - modifier = modifier, - 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.downloads_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.downloads_empty_state_description), - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.labelMedium, - textAlign = TextAlign.Center - ) - } - } -} - -@Preview -@Composable -private fun DatesScreenPreview() { - OpenEdXTheme { - DownloadsScreen( - uiState = DownloadsUIState(isLoading = false), - uiMessage = null, - apiHostUrl = "", - hasInternetConnection = true, - onAction = {} - ) - } -} - -@Preview -@Composable -private fun CourseItemPreview() { - OpenEdXTheme { - CourseItem( - downloadCoursePreview = DownloadCoursePreview("", "name", "", 100), - downloadModels = emptyList(), - apiHostUrl = "", - downloadedState = DownloadedState.NOT_DOWNLOADED, - onCourseClick = {}, - onDownloadClick = {}, - onCancelClick = {}, - onRemoveClick = {}, - ) - } -} 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 new file mode 100644 index 000000000..241be907d --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -0,0 +1,518 @@ +package org.openedx.downloads.presentation.download + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.db.DownloadModel +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.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXDropdownMenuItem +import org.openedx.core.ui.crop +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.downloads.R +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DownloadsScreen( + uiState: DownloadsUIState, + uiMessage: UIMessage?, + apiHostUrl: String, + hasInternetConnection: Boolean, + onAction: (DownloadsViewActions) -> 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(DownloadsViewActions.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainToolbar( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.downloads), + onSettingsClick = { + onAction(DownloadsViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else if (uiState.downloadCoursePreviews.isEmpty()) { + EmptyState( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .displayCutoutForLandscape() + .padding(paddingValues) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + LazyColumn( + modifier = contentWidth, + contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } + } + } + } + + 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(DownloadsViewActions.SwipeRefresh) + } + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CourseItem( + modifier: Modifier = Modifier, + downloadCoursePreview: DownloadCoursePreview, + downloadModels: List, + downloadedState: DownloadedState, + apiHostUrl: String, + onCourseClick: () -> Unit, + onDownloadClick: () -> Unit, + onRemoveClick: () -> Unit, + onCancelClick: () -> Unit +) { + var isDropdownExpanded by remember { mutableStateOf(false) } + val downloadedSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + val availableSize = downloadCoursePreview.totalSize - downloadedSize + val availableSizeString = availableSize.toFileSize(space = false) + val progress: Float = try { + downloadedSize.toFloat() / availableSize.toFloat() + } catch (_: ArithmeticException) { + 0f + } + Card( + modifier = modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + onClick = onCourseClick + ) { + Box { + Column( + modifier = Modifier.animateContentSize() + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + ) + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 12.dp), + ) { + Text( + text = downloadCoursePreview.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState != DownloadedState.DOWNLOADED && downloadedSize != 0L) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + progress = progress, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.divider + ) + } + if (downloadedSize != 0L) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Filled.CloudDone, + color = MaterialTheme.appColors.successGreen, + text = stringResource( + R.string.downloaded_downloaded_size, + downloadedSize.toFileSize(space = false) + ) + ) + } + if (downloadedState != DownloadedState.DOWNLOADED) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textPrimaryVariant, + text = stringResource( + R.string.downloaded_available_size, + availableSizeString + ) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState.isWaitingOrDownloading) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + IconButton( + modifier = Modifier + .size(28.dp) + .padding(2.dp), + onClick = onCancelClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource( + id = R.string.downloads_accessibility_stop_downloading_course + ), + tint = MaterialTheme.appColors.error + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + val text = if (downloadedState == LOADING_COURSE_STRUCTURE) { + stringResource(R.string.downloads_loading_course_structure) + } else { + stringResource(org.openedx.core.R.string.core_downloading) + } + Text( + text = text, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary + ) + } + } else if (downloadedState == DownloadedState.NOT_DOWNLOADED) { + OpenEdXButton( + onClick = { + onDownloadClick() + }, + content = { + IconText( + text = stringResource(R.string.downloads_download_course), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.TopEnd), + ) { + if (downloadedSize != 0L || downloadedState.isWaitingOrDownloading) { + MoreButton( + onClick = { + isDropdownExpanded = true + } + ) + } + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .defaultMinSize(minWidth = 269.dp) + .background(MaterialTheme.appColors.background), + expanded = isDropdownExpanded, + onDismissRequest = { isDropdownExpanded = false }, + ) { + Column { + if (downloadedSize != 0L) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_remove_course_downloads), + onClick = { + isDropdownExpanded = false + onRemoveClick() + } + ) + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + if (downloadedState.isWaitingOrDownloading) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_cancel_download), + onClick = { + isDropdownExpanded = false + onCancelClick() + } + ) + } + } + } + } + } + } +} + +@Composable +private fun MoreButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IconButton( + modifier = modifier, + onClick = onClick + ) { + Icon( + modifier = Modifier + .size(30.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(alpha = 0.5f), + shape = CircleShape + ) + .padding(4.dp), + imageVector = Icons.Default.MoreHoriz, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + 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.downloads_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.downloads_empty_state_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun DownloadsScreenPreview() { + OpenEdXTheme { + DownloadsScreen( + uiState = DownloadsUIState(isLoading = false), + uiMessage = null, + apiHostUrl = "", + hasInternetConnection = true, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + downloadCoursePreview = DownloadCoursePreview("", "name", "", 100), + downloadModels = emptyList(), + apiHostUrl = "", + downloadedState = DownloadedState.NOT_DOWNLOADED, + onCourseClick = {}, + onDownloadClick = {}, + onCancelClick = {}, + onRemoveClick = {}, + ) + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index e9d2d8cd9..aea4b6bae 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -263,8 +263,9 @@ class DownloadsViewModel( fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) viewModelScope.launch { - val downloadModels = - interactor.getDownloadModels().first().filter { it.courseId == courseId } + val downloadModels = interactor.getDownloadModels().first().filter { + it.courseId == courseId + } val totalSize = downloadModels.sumOf { it.size } val title = _uiState.value.downloadCoursePreviews.find { it.id == courseId }?.name ?: "" val downloadDialogItem = DownloadDialogItem( From 32897abaf81e704ffaf9418175932e3c7d3d4461 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 13 Mar 2025 12:06:11 +0200 Subject: [PATCH 12/29] feat: show course item on dialog --- .../downloaddialog/DownloadDialogManager.kt | 76 +++++--- .../outline/CourseOutlineViewModelTest.kt | 4 +- .../videos/CourseVideoViewModelTest.kt | 166 ++++++++++-------- .../download/DownloadsViewModel.kt | 2 + .../downloads/DownloadsViewModelTest.kt | 2 +- 5 files changed, 148 insertions(+), 102 deletions(-) diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index b1ce29986..7bb5ad8f5 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -9,6 +9,7 @@ import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadModel import org.openedx.core.system.StorageManager @@ -109,6 +110,8 @@ class DownloadDialogManager( subSectionsBlocks: List, courseId: String, isBlocksDownloaded: Boolean, + showCourseItem: Boolean = false, + courseName: String = "", onlyVideoBlocks: Boolean = false, fragmentManager: FragmentManager, removeDownloadModels: (blockId: String, courseId: String) -> Unit, @@ -121,6 +124,8 @@ class DownloadDialogManager( courseId = courseId, fragmentManager = fragmentManager, isBlocksDownloaded = isBlocksDownloaded, + showCourseItem = showCourseItem, + courseName = courseName, onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, saveDownloadModels = saveDownloadModels, @@ -217,6 +222,8 @@ class DownloadDialogManager( courseId: String, fragmentManager: FragmentManager, isBlocksDownloaded: Boolean, + showCourseItem: Boolean, + courseName: String, onlyVideoBlocks: Boolean, removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, @@ -226,25 +233,31 @@ class DownloadDialogManager( coroutineScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) val downloadModelIds = interactor.getAllDownloadModels().map { it.id } - - val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionBlock -> - val verticalBlocks = - courseStructure.blockData.filter { it.id in subSectionBlock.descendants } - val blocks = verticalBlocks.flatMap { verticalBlock -> - courseStructure.blockData.filter { - it.id in verticalBlock.descendants && - (isBlocksDownloaded == (it.id in downloadModelIds)) && - (!onlyVideoBlocks || it.type == BlockType.VIDEO) - } + val downloadDialogItems = if (showCourseItem) { + val totalSize = subSectionsBlocks.sumOf { subSection -> + calculateSubSectionSize( + subSection = subSection, + courseStructure = courseStructure, + downloadModelIds = downloadModelIds, + onlyVideoBlocks = onlyVideoBlocks, + isBlocksDownloaded = isBlocksDownloaded + ) } - val size = blocks.sumOf { it.getFileSize() } - if (size > 0) { - DownloadDialogItem( - title = subSectionBlock.displayName, - size = size + listOf(DownloadDialogItem(title = courseName, size = totalSize)) + } else { + subSectionsBlocks.mapNotNull { subSection -> + val size = calculateSubSectionSize( + subSection = subSection, + courseStructure = courseStructure, + downloadModelIds = downloadModelIds, + onlyVideoBlocks = onlyVideoBlocks, + isBlocksDownloaded = isBlocksDownloaded ) - } else { - null + if (size > 0) { + DownloadDialogItem(title = subSection.displayName, size = size) + } else { + null + } } } @@ -256,18 +269,33 @@ class DownloadDialogManager( sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, removeDownloadModels = { - subSectionsBlocks.forEach { - removeDownloadModels( - it.id, - courseId - ) - } + subSectionsBlocks.forEach { removeDownloadModels(it.id, courseId) } + }, + saveDownloadModels = { + subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, - saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, onDismissClick = onDismissClick, onConfirmClick = onConfirmClick, ) ) } } + + private fun calculateSubSectionSize( + subSection: Block, + courseStructure: CourseStructure, + downloadModelIds: List, + isBlocksDownloaded: Boolean, + onlyVideoBlocks: Boolean + ): Long { + val verticalBlocks = courseStructure.blockData.filter { it.id in subSection.descendants } + val blocks = verticalBlocks.flatMap { verticalBlock -> + courseStructure.blockData.filter { + it.id in verticalBlock.descendants && + (isBlocksDownloaded == (it.id in downloadModelIds)) && + (!onlyVideoBlocks || it.type == BlockType.VIDEO) + } + } + return blocks.sumOf { it.getFileSize() } + } } diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index 1aefc1fdb..f4e21f843 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -264,7 +264,9 @@ class CourseOutlineViewModelTest { any(), any(), any(), - any() + any(), + any(), + any(), ) } returns Unit coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index e9517bb1c..ae34756a5 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -198,7 +198,19 @@ class CourseVideoViewModelTest { every { config.getApiHostURL() } returns "http://localhost:8000" every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) every { preferencesManager.isRelativeDatesEnabled } returns true - every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit } @After @@ -373,88 +385,90 @@ class CourseVideoViewModelTest { } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } - every { coreAnalytics.logEvent(any(), any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( + "", + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + videoNotifier, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns true + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } + every { coreAnalytics.logEvent(any(), any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "", "") - advanceUntilIdle() + viewModel.saveDownloadModels("", "", "") + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } - coEvery { workerController.saveModels(any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, without connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( + "", + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + videoNotifier, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + every { networkConnection.isOnline() } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { workerController.saveModels(any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "", "") + viewModel.saveDownloadModels("", "", "") - advanceUntilIdle() + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index aea4b6bae..7a1326209 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -315,6 +315,8 @@ class DownloadsViewModel( subSectionsBlocks = notDownloadedSubSectionBlocks, courseId = courseId, isBlocksDownloaded = false, + showCourseItem = true, + courseName = courseStructure.name, fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, saveDownloadModels = { blockId -> diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 2e0b818e3..fb5e84d7a 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -263,7 +263,7 @@ class DownloadsViewModelTest { coVerify(exactly = 1) { downloadDialogManager.showPopup( - any(), any(), any(), any(), any(), any(), any(), any(), any() + any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any() ) } } From 733bfd16a0aef1922c402b87bbd46242f342b2ab Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 13 Mar 2025 18:41:01 +0200 Subject: [PATCH 13/29] refactor: improved code for better readability, optimized downloading logic --- .../downloaddialog/DownloadDialogManager.kt | 134 ++++++--- .../download/DownloadsViewModel.kt | 281 ++++++++---------- .../downloads/DownloadsViewModelTest.kt | 15 +- 3 files changed, 220 insertions(+), 210 deletions(-) diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 7bb5ad8f5..d98d9d43a 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -9,7 +9,7 @@ import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadModel import org.openedx.core.system.StorageManager @@ -110,8 +110,6 @@ class DownloadDialogManager( subSectionsBlocks: List, courseId: String, isBlocksDownloaded: Boolean, - showCourseItem: Boolean = false, - courseName: String = "", onlyVideoBlocks: Boolean = false, fragmentManager: FragmentManager, removeDownloadModels: (blockId: String, courseId: String) -> Unit, @@ -124,8 +122,6 @@ class DownloadDialogManager( courseId = courseId, fragmentManager = fragmentManager, isBlocksDownloaded = isBlocksDownloaded, - showCourseItem = showCourseItem, - courseName = courseName, onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, saveDownloadModels = saveDownloadModels, @@ -134,6 +130,26 @@ class DownloadDialogManager( ) } + fun showPopup( + coursePreview: DownloadCoursePreview, + isBlocksDownloaded: Boolean, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + createCourseDownloadItems( + coursePreview = coursePreview, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick + ) + } + fun showRemoveDownloadModelPopup( downloadDialogItem: DownloadDialogItem, fragmentManager: FragmentManager, @@ -222,8 +238,6 @@ class DownloadDialogManager( courseId: String, fragmentManager: FragmentManager, isBlocksDownloaded: Boolean, - showCourseItem: Boolean, - courseName: String, onlyVideoBlocks: Boolean, removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, @@ -233,31 +247,25 @@ class DownloadDialogManager( coroutineScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) val downloadModelIds = interactor.getAllDownloadModels().map { it.id } - val downloadDialogItems = if (showCourseItem) { - val totalSize = subSectionsBlocks.sumOf { subSection -> - calculateSubSectionSize( - subSection = subSection, - courseStructure = courseStructure, - downloadModelIds = downloadModelIds, - onlyVideoBlocks = onlyVideoBlocks, - isBlocksDownloaded = isBlocksDownloaded - ) + + val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionBlock -> + val verticalBlocks = + courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val blocks = verticalBlocks.flatMap { verticalBlock -> + courseStructure.blockData.filter { + it.id in verticalBlock.descendants && + (isBlocksDownloaded == (it.id in downloadModelIds)) && + (!onlyVideoBlocks || it.type == BlockType.VIDEO) + } } - listOf(DownloadDialogItem(title = courseName, size = totalSize)) - } else { - subSectionsBlocks.mapNotNull { subSection -> - val size = calculateSubSectionSize( - subSection = subSection, - courseStructure = courseStructure, - downloadModelIds = downloadModelIds, - onlyVideoBlocks = onlyVideoBlocks, - isBlocksDownloaded = isBlocksDownloaded + val size = blocks.sumOf { it.getFileSize() } + if (size > 0) { + DownloadDialogItem( + title = subSectionBlock.displayName, + size = size ) - if (size > 0) { - DownloadDialogItem(title = subSection.displayName, size = size) - } else { - null - } + } else { + null } } @@ -269,11 +277,14 @@ class DownloadDialogManager( sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, removeDownloadModels = { - subSectionsBlocks.forEach { removeDownloadModels(it.id, courseId) } - }, - saveDownloadModels = { - subSectionsBlocks.forEach { saveDownloadModels(it.id) } + subSectionsBlocks.forEach { + removeDownloadModels( + it.id, + courseId + ) + } }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, onDismissClick = onDismissClick, onConfirmClick = onConfirmClick, ) @@ -281,21 +292,48 @@ class DownloadDialogManager( } } - private fun calculateSubSectionSize( - subSection: Block, - courseStructure: CourseStructure, - downloadModelIds: List, + private fun createCourseDownloadItems( + coursePreview: DownloadCoursePreview, + fragmentManager: FragmentManager, isBlocksDownloaded: Boolean, - onlyVideoBlocks: Boolean - ): Long { - val verticalBlocks = courseStructure.blockData.filter { it.id in subSection.descendants } - val blocks = verticalBlocks.flatMap { verticalBlock -> - courseStructure.blockData.filter { - it.id in verticalBlock.descendants && - (isBlocksDownloaded == (it.id in downloadModelIds)) && - (!onlyVideoBlocks || it.type == BlockType.VIDEO) - } + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + coroutineScope.launch { + val downloadDialogItems = listOf( + DownloadDialogItem( + title = coursePreview.name, + size = coursePreview.totalSize + ) + ) + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isBlocksDownloaded, + isDownloadFailed = false, + sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = { + coroutineScope.launch { + val downloadModels = interactor.getAllDownloadModels().filter { + it.courseId == coursePreview.id + } + downloadModels.forEach { + removeDownloadModels( + it.id, + coursePreview.id + ) + } + } + }, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, + ) + ) } - return blocks.sumOf { it.getFileSize() } } } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index 7a1326209..7145336ad 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -70,31 +69,35 @@ class DownloadsViewModel( val apiHostUrl get() = config.getApiHostURL() private val _uiState = MutableStateFlow(DownloadsUIState()) - val uiState: StateFlow - get() = _uiState.asStateFlow() + val uiState: StateFlow = _uiState.asStateFlow() private val _uiMessage = MutableSharedFlow() - val uiMessage: SharedFlow - get() = _uiMessage.asSharedFlow() + val uiMessage: SharedFlow = _uiMessage.asSharedFlow() - private val blockIdsByCourseId = mutableMapOf>() + private val courseBlockIds = mutableMapOf>() - val hasInternetConnection: Boolean - get() = networkConnection.isOnline() + val hasInternetConnection: Boolean get() = networkConnection.isOnline() private var downloadJobs = mutableMapOf() init { - fetchDownloads(false) + fetchDownloads(refresh = false) + observeCourseDashboardUpdates() + observeDownloadingModels() + observeDownloadModelsStatus() + } + private fun observeCourseDashboardUpdates() { viewModelScope.launch { - discoveryNotifier.notifier.collect { - if (it is CourseDashboardUpdate) { - fetchDownloads(true) + discoveryNotifier.notifier.collect { notifier -> + if (notifier is CourseDashboardUpdate) { + fetchDownloads(refresh = true) } } } + } + private fun observeDownloadingModels() { viewModelScope.launch { downloadingModelsFlow.collect { downloadModels -> _uiState.update { state -> @@ -102,30 +105,30 @@ class DownloadsViewModel( } } } + } + private fun observeDownloadModelsStatus() { viewModelScope.launch { downloadModelsStatusFlow.collect { statusMap -> - val downloadingCourseState = blockIdsByCourseId - .mapValues { (courseId, blockIds) -> - val currentCourseState = uiState.value.courseDownloadState[courseId] - val blockStates = blockIds.mapNotNull { statusMap[it] } - val courseDownloadState = if (blockStates.isEmpty()) { - DownloadedState.NOT_DOWNLOADED - } else { - determineCourseState(blockStates) - } - val isLoadingCourseStructure = - currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && - courseDownloadState == DownloadedState.NOT_DOWNLOADED - if (isLoadingCourseStructure) { - DownloadedState.LOADING_COURSE_STRUCTURE - } else { - courseDownloadState - } + val updatedCourseStates = courseBlockIds.mapValues { (courseId, blockIds) -> + val currentCourseState = uiState.value.courseDownloadState[courseId] + val blockStates = blockIds.mapNotNull { statusMap[it] } + val computedState = if (blockStates.isEmpty()) { + DownloadedState.NOT_DOWNLOADED + } else { + determineCourseState(blockStates) } + if (currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && + computedState == DownloadedState.NOT_DOWNLOADED + ) { + DownloadedState.LOADING_COURSE_STRUCTURE + } else { + computedState + } + } _uiState.update { state -> - state.copy(courseDownloadState = downloadingCourseState) + state.copy(courseDownloadState = updatedCourseStates) } } } @@ -142,66 +145,54 @@ class DownloadsViewModel( private fun fetchDownloads(refresh: Boolean) { viewModelScope.launch(Dispatchers.IO) { - _uiState.update { state -> - state.copy( - isLoading = !refresh, - isRefreshing = refresh - ) - } + updateLoadingState(isLoading = !refresh, isRefreshing = refresh) interactor.getDownloadCoursesPreview(refresh) - .onCompletion { - _uiState.update { state -> - state.copy( - isLoading = false, - isRefreshing = false - ) - } - } + .onCompletion { resetLoadingState() } .catch { e -> - 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) - ) - ) - } + emitErrorMessage(e) } .collect { downloadCoursePreviews -> - downloadCoursePreviews.map { - try { - initBlocks(it.id, true) - } catch (e: Exception) { - e.printStackTrace() - } - } - val subSectionsBlocks = - allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } - subSectionsBlocks.map { subSection -> - addDownloadableChildrenForSequentialBlock(subSection) + downloadCoursePreviews.forEach { preview -> + runCatching { initializeCourseBlocks(preview.id, useCache = true) } + .onFailure { it.printStackTrace() } } + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { addDownloadableChildrenForSequentialBlock(it) } initDownloadModelsStatus() _uiState.update { state -> - state.copy( - downloadCoursePreviews = downloadCoursePreviews, - ) + state.copy(downloadCoursePreviews = downloadCoursePreviews) } } } } - fun refreshData() { + private fun updateLoadingState(isLoading: Boolean, isRefreshing: Boolean) { _uiState.update { state -> - state.copy( - isRefreshing = true - ) + state.copy(isLoading = isLoading, isRefreshing = isRefreshing) + } + } + + private fun resetLoadingState() { + _uiState.update { state -> + state.copy(isLoading = false, isRefreshing = false) + } + } + + private suspend fun emitErrorMessage(e: Throwable) { + val text = if (e.isInternetError()) { + R.string.core_error_no_connection + } else { + R.string.core_error_unknown_error } - fetchDownloads(true) + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(text)) + ) + } + + fun refreshData() { + _uiState.update { it.copy(isRefreshing = true) } + fetchDownloads(refresh = true) } fun onSettingsClick(fragmentManager: FragmentManager) { @@ -210,36 +201,13 @@ class DownloadsViewModel( fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COURSE_CLICKED) - downloadJobs[courseId] = viewModelScope.launch { - try { - _uiState.update { state -> - state.copy( - courseDownloadState = state.courseDownloadState.toMap() + - (courseId to DownloadedState.LOADING_COURSE_STRUCTURE) - ) - } - downloadAllBlocks(fragmentManager, courseId) - } catch (e: Exception) { - logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) - _uiState.update { state -> - state.copy( - courseDownloadState = state.courseDownloadState.toMap() + - (courseId to DownloadedState.NOT_DOWNLOADED) - ) - } - 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) - ) - ) - } + try { + showDownloadPopup(fragmentManager, courseId) + } catch (e: Exception) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + viewModelScope.launch { + emitErrorMessage(e) } } } @@ -247,12 +215,6 @@ class DownloadsViewModel( fun cancelDownloading(courseId: String) { logEvent(DownloadsAnalyticsEvent.CANCEL_DOWNLOAD_CLICKED) viewModelScope.launch { - _uiState.update { state -> - state.copy( - courseDownloadState = state.courseDownloadState.toMap() + - (courseId to DownloadedState.NOT_DOWNLOADED) - ) - } downloadJobs[courseId]?.cancel() interactor.getAllDownloadModels() .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } @@ -263,11 +225,11 @@ class DownloadsViewModel( fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) viewModelScope.launch { - val downloadModels = interactor.getDownloadModels().first().filter { - it.courseId == courseId - } + val downloadModels = + interactor.getDownloadModels().first().filter { it.courseId == courseId } val totalSize = downloadModels.sumOf { it.size } - val title = _uiState.value.downloadCoursePreviews.find { it.id == courseId }?.name ?: "" + val title = + _uiState.value.downloadCoursePreviews.find { it.id == courseId }?.name.orEmpty() val downloadDialogItem = DownloadDialogItem( title = title, size = totalSize, @@ -284,52 +246,34 @@ class DownloadsViewModel( } } - private suspend fun initBlocks(courseId: String, cached: Boolean): CourseStructure { - val courseStructure = if (cached) { + private suspend fun initializeCourseBlocks( + courseId: String, + useCache: Boolean + ): CourseStructure { + val courseStructure = if (useCache) { interactor.getCourseStructureFromCache(courseId) } else { interactor.getCourseStructure(courseId) } - blockIdsByCourseId[courseStructure.id] = courseStructure.blockData.map { it.id } + courseBlockIds[courseStructure.id] = courseStructure.blockData.map { it.id } addBlocks(courseStructure.blockData) return courseStructure } - private suspend fun downloadAllBlocks(fragmentManager: FragmentManager, courseId: String) { - val courseStructure = initBlocks(courseId, false) - val downloadModels = interactor.getDownloadModels() - .map { list -> list.filter { it.courseId in courseId } } - .first() - val subSectionsBlocks = allBlocks.values.filter { it.type == BlockType.SEQUENTIAL } - val notDownloadedSubSectionBlocks = subSectionsBlocks.mapNotNull { subSection -> - addDownloadableChildrenForSequentialBlock(subSection) - val verticalBlocks = allBlocks.values.filter { it.id in subSection.descendants } - val notDownloadedBlocks = courseStructure.blockData.filter { block -> - block.id in verticalBlocks.flatMap { it.descendants } && - block.isDownloadable && - downloadModels.none { it.id == block.id } - } - if (notDownloadedBlocks.isNotEmpty()) subSection else null - } + private fun showDownloadPopup(fragmentManager: FragmentManager, courseId: String) { + val coursePreview = + _uiState.value.downloadCoursePreviews.find { it.id == courseId } ?: return downloadDialogManager.showPopup( - subSectionsBlocks = notDownloadedSubSectionBlocks, - courseId = courseId, + coursePreview = coursePreview, isBlocksDownloaded = false, - showCourseItem = true, - courseName = courseStructure.name, fragmentManager = fragmentManager, removeDownloadModels = ::removeDownloadModels, - saveDownloadModels = { blockId -> - saveDownloadModels(fileUtil.getExternalAppDir().path, courseId, blockId) + saveDownloadModels = { + initiateSaveDownloadModels(courseId) }, onDismissClick = { logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) - _uiState.update { state -> - state.copy( - courseDownloadState = state.courseDownloadState.toMap() + - (courseId to DownloadedState.NOT_DOWNLOADED) - ) - } + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) }, onConfirmClick = { logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CONFIRMED) @@ -337,10 +281,29 @@ class DownloadsViewModel( ) } - fun navigateToCourseOutline( - fm: FragmentManager, - courseId: String - ) { + private fun initiateSaveDownloadModels(courseId: String) { + downloadJobs[courseId] = viewModelScope.launch { + try { + updateCourseState(courseId, DownloadedState.LOADING_COURSE_STRUCTURE) + val courseStructure = initializeCourseBlocks(courseId, useCache = false) + courseStructure.blockData + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { sequentialBlock -> + addDownloadableChildrenForSequentialBlock(sequentialBlock) + super.saveDownloadModels( + fileUtil.getExternalAppDir().path, + courseId, + sequentialBlock.id + ) + } + } catch (e: Exception) { + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + } + + fun navigateToCourseOutline(fm: FragmentManager, courseId: String) { val coursePreview = _uiState.value.downloadCoursePreviews.find { it.id == courseId } ?: return router.navigateToCourseOutline( @@ -350,14 +313,22 @@ class DownloadsViewModel( ) } - fun logEvent(event: DownloadsAnalyticsEvent) { + private fun logEvent(event: DownloadsAnalyticsEvent) { analytics.logEvent( event = event.eventName, - params = buildMap { - put(DownloadsAnalyticsKey.NAME.key, event.biValue) - } + params = mapOf(DownloadsAnalyticsKey.NAME.key to event.biValue) ) } + + private fun updateCourseState(courseId: String, state: DownloadedState) { + _uiState.update { currentState -> + currentState.copy( + courseDownloadState = currentState.courseDownloadState.toMutableMap().apply { + put(courseId, state) + } + ) + } + } } interface DownloadsViewActions { diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index fb5e84d7a..b6e2a268a 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -81,7 +81,7 @@ class DownloadsViewModelTest { private val unknownError = "Unknown error" private val downloadCoursePreview = - DownloadCoursePreview(id = "", name = "", image = "", totalSize = 0) + DownloadCoursePreview(id = "course1", name = "", image = "", totalSize = 0) private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, @@ -263,7 +263,13 @@ class DownloadsViewModelTest { coVerify(exactly = 1) { downloadDialogManager.showPopup( - any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any() + coursePreview = any(), + isBlocksDownloaded = any(), + fragmentManager = any(), + removeDownloadModels = any(), + saveDownloadModels = any(), + onDismissClick = any(), + onConfirmClick = any() ) } } @@ -297,11 +303,6 @@ class DownloadsViewModelTest { viewModel.cancelDownloading("course1") advanceUntilIdle() - assertEquals( - DownloadedState.NOT_DOWNLOADED, - viewModel.uiState.value.courseDownloadState["course1"] - ) - coVerify { interactor.getAllDownloadModels() } } From efa6d856a0774551c018c88c024bd203492718fa Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 13 Mar 2025 18:59:57 +0200 Subject: [PATCH 14/29] fix: dialog icon --- .../dialog/downloaddialog/DownloadConfirmDialogFragment.kt | 4 ++-- .../dialog/downloaddialog/DownloadErrorDialogFragment.kt | 4 ++-- .../downloaddialog/DownloadStorageErrorDialogFragment.kt | 4 ++-- course/src/main/res/drawable/core_ic_error.xml | 5 ----- 4 files changed, 6 insertions(+), 11 deletions(-) delete mode 100644 course/src/main/res/drawable/core_ic_error.xml diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index a8e8321d7..61b393c9b 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -1,7 +1,6 @@ package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -31,6 +30,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.openedx.core.R @@ -57,7 +57,7 @@ class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index 202e8f540..ec796cce9 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -1,7 +1,6 @@ package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -27,6 +26,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.openedx.core.R @@ -50,7 +50,7 @@ class DownloadErrorDialogFragment : DialogFragment(), DownloadDialog { container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index 94ccee80f..d74c56c50 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -1,7 +1,6 @@ package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -39,6 +38,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import org.openedx.core.R @@ -65,7 +65,7 @@ class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { diff --git a/course/src/main/res/drawable/core_ic_error.xml b/course/src/main/res/drawable/core_ic_error.xml deleted file mode 100644 index 391e8b4c5..000000000 --- a/course/src/main/res/drawable/core_ic_error.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - From 55876595427f3c1791293df6ca1dd3e191e31650 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Mar 2025 12:16:31 +0200 Subject: [PATCH 15/29] fix: remove course size --- .../download/DownloadsViewModel.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index 7145336ad..a30d18751 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -22,6 +22,7 @@ import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadedState @@ -225,11 +226,11 @@ class DownloadsViewModel( fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) viewModelScope.launch { - val downloadModels = - interactor.getDownloadModels().first().filter { it.courseId == courseId } + val downloadModels = interactor.getDownloadModels().first().filter { + it.courseId == courseId && it.downloadedState == DownloadedState.DOWNLOADED + } val totalSize = downloadModels.sumOf { it.size } - val title = - _uiState.value.downloadCoursePreviews.find { it.id == courseId }?.name.orEmpty() + val title = getCoursePreview(courseId)?.name.orEmpty() val downloadDialogItem = DownloadDialogItem( title = title, size = totalSize, @@ -261,8 +262,7 @@ class DownloadsViewModel( } private fun showDownloadPopup(fragmentManager: FragmentManager, courseId: String) { - val coursePreview = - _uiState.value.downloadCoursePreviews.find { it.id == courseId } ?: return + val coursePreview = getCoursePreview(courseId) ?: return downloadDialogManager.showPopup( coursePreview = coursePreview, isBlocksDownloaded = false, @@ -304,8 +304,7 @@ class DownloadsViewModel( } fun navigateToCourseOutline(fm: FragmentManager, courseId: String) { - val coursePreview = - _uiState.value.downloadCoursePreviews.find { it.id == courseId } ?: return + val coursePreview = getCoursePreview(courseId) ?: return router.navigateToCourseOutline( fm = fm, courseId = coursePreview.id, @@ -329,6 +328,10 @@ class DownloadsViewModel( ) } } + + private fun getCoursePreview(courseId: String): DownloadCoursePreview? { + return _uiState.value.downloadCoursePreviews.find { it.id == courseId } + } } interface DownloadsViewActions { From 794efab7c93fea920f0ec668eefb99737ccc7b20 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Mar 2025 13:04:41 +0200 Subject: [PATCH 16/29] feat: landscape and tablet ui --- .../presentation/download/DownloadsScreen.kt | 112 +++++++++++++----- 1 file changed, 82 insertions(+), 30 deletions(-) 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 241be907d..b6c0e248f 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 @@ -1,5 +1,6 @@ package org.openedx.downloads.presentation.download +import android.content.res.Configuration.ORIENTATION_LANDSCAPE import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -17,6 +19,10 @@ 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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -52,6 +58,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -98,6 +105,7 @@ fun DownloadsScreen( ) { val scaffoldState = rememberScaffoldState() val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current val contentWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -158,34 +166,72 @@ fun DownloadsScreen( .padding(horizontal = 16.dp), contentAlignment = Alignment.TopCenter ) { - LazyColumn( - modifier = contentWidth, - contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - items(uiState.downloadCoursePreviews) { item -> - val downloadModels = - uiState.downloadModels.filter { it.courseId == item.id } - val downloadState = uiState.courseDownloadState[item.id] - ?: DownloadedState.NOT_DOWNLOADED - CourseItem( - downloadCoursePreview = item, - downloadModels = downloadModels, - downloadedState = downloadState, - apiHostUrl = apiHostUrl, - onCourseClick = { - onAction(DownloadsViewActions.OpenCourse(item.id)) - }, - onDownloadClick = { - onAction(DownloadsViewActions.DownloadCourse(item.id)) - }, - onCancelClick = { - onAction(DownloadsViewActions.CancelDownloading(item.id)) - }, - onRemoveClick = { - onAction(DownloadsViewActions.RemoveDownloads(item.id)) + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + LazyVerticalGrid( + modifier = contentWidth.fillMaxHeight(), + state = rememberLazyGridState(), + columns = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), + content = { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + modifier = Modifier.height(314.dp), + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) } - ) + } + ) + } else { + LazyColumn( + modifier = contentWidth, + contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } } } } @@ -231,6 +277,8 @@ private fun CourseItem( onRemoveClick: () -> Unit, onCancelClick: () -> Unit ) { + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current var isDropdownExpanded by remember { mutableStateOf(false) } val downloadedSize = downloadModels .filter { it.downloadedState == DownloadedState.DOWNLOADED } @@ -254,7 +302,14 @@ private fun CourseItem( Column( modifier = Modifier.animateContentSize() ) { + val imageModifier = + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + Modifier.weight(1f) + } else { + Modifier.height(120.dp) + } AsyncImage( + modifier = imageModifier.fillMaxWidth(), model = ImageRequest.Builder(LocalContext.current) .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) .error(org.openedx.core.R.drawable.core_no_image_course) @@ -262,9 +317,6 @@ private fun CourseItem( .build(), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .height(120.dp) ) Column( modifier = Modifier From 6333891ebc4dd4e585001a21728c1ef48b40c434 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Mar 2025 13:09:16 +0200 Subject: [PATCH 17/29] fix: remove course during downloading --- .../downloads/presentation/download/DownloadsViewModel.kt | 7 +++++-- .../java/org/openedx/downloads/DownloadsViewModelTest.kt | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index a30d18751..aeb72734c 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -227,9 +227,12 @@ class DownloadsViewModel( logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) viewModelScope.launch { val downloadModels = interactor.getDownloadModels().first().filter { - it.courseId == courseId && it.downloadedState == DownloadedState.DOWNLOADED + it.courseId == courseId } - val totalSize = downloadModels.sumOf { it.size } + val downloadedModels = downloadModels.filter { + it.downloadedState == DownloadedState.DOWNLOADED + } + val totalSize = downloadedModels.sumOf { it.size } val title = getCoursePreview(courseId)?.name.orEmpty() val downloadDialogItem = DownloadDialogItem( title = title, diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index b6e2a268a..6e8756c08 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -235,7 +235,7 @@ class DownloadsViewModelTest { } @Test - fun `downloadCourse should update courseDownloadState and show download dialog`() = runTest { + fun `downloadCourse should show download dialog`() = runTest { val viewModel = DownloadsViewModel( downloadsRouter, networkConnection, From 3ff18debb3d6b2e6aaf5c6c50a6f4fa3f8df0ef1 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 14 Mar 2025 17:30:34 +0200 Subject: [PATCH 18/29] fix: update download page after getting course structure on outline page --- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../core/system/notifier/CourseNotifier.kt | 1 + .../system/notifier/CourseStructureGot.kt | 5 +++++ .../container/CourseContainerViewModel.kt | 5 ++++- .../download/DownloadsViewModel.kt | 21 +++++++++++++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.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 8415b73e3..d00d0f1fe 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -525,6 +525,7 @@ val screenModule = module { fileUtil = get(), analytics = get(), discoveryNotifier = get(), + courseNotifier = get(), router = get() ) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index 527a7ce51..be653a3ed 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -12,6 +12,7 @@ class CourseNotifier { suspend fun send(event: CourseVideoPositionChanged) = channel.emit(event) suspend fun send(event: CourseStructureUpdated) = channel.emit(event) + suspend fun send(event: CourseStructureGot) = channel.emit(event) suspend fun send(event: CourseSubtitleLanguageChanged) = channel.emit(event) suspend fun send(event: CourseSectionChanged) = channel.emit(event) suspend fun send(event: CourseCompletionSet) = channel.emit(event) diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt new file mode 100644 index 000000000..d685519e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +class CourseStructureGot( + val courseId: String +) : CourseEvent diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 0e7288423..f3d2bd2c7 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -3,6 +3,7 @@ package org.openedx.course.presentation.container import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build +import androidx.core.graphics.createBitmap import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope @@ -35,6 +36,7 @@ import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseStructureGot import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.RefreshDates import org.openedx.core.system.notifier.RefreshDiscussions @@ -116,7 +118,7 @@ class CourseContainerViewModel( val calendarSyncUIState: StateFlow = _calendarSyncUIState.asStateFlow() - private var _courseImage = MutableStateFlow(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + private var _courseImage = MutableStateFlow(createBitmap(1, 1)) val courseImage: StateFlow = _courseImage.asStateFlow() val hasInternetConnection: Boolean @@ -187,6 +189,7 @@ class CourseContainerViewModel( courseStructure != null -> handleCourseStructureOnly(courseStructure) else -> _courseAccessStatus.value = CourseAccessError.UNKNOWN } + courseNotifier.send(CourseStructureGot(courseId)) } } } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index aeb72734c..ffeb2e1f4 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -36,6 +36,9 @@ import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureGot +import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.downloads.domain.interactor.DownloadInteractor import org.openedx.downloads.presentation.DownloadsRouter @@ -54,6 +57,7 @@ class DownloadsViewModel( private val config: Config, private val analytics: DownloadsAnalytics, private val discoveryNotifier: DiscoveryNotifier, + private val courseNotifier: CourseNotifier, private val router: DownloadsRouter, preferencesManager: CorePreferences, coreAnalytics: CoreAnalytics, @@ -86,6 +90,7 @@ class DownloadsViewModel( observeCourseDashboardUpdates() observeDownloadingModels() observeDownloadModelsStatus() + observeCourseStructureUpdates() } private fun observeCourseDashboardUpdates() { @@ -98,6 +103,22 @@ class DownloadsViewModel( } } + private fun observeCourseStructureUpdates() { + viewModelScope.launch { + courseNotifier.notifier.collect { notifier -> + when (notifier) { + is CourseStructureGot -> { + fetchDownloads(refresh = true) + } + + is CourseStructureUpdated -> { + fetchDownloads(refresh = true) + } + } + } + } + } + private fun observeDownloadingModels() { viewModelScope.launch { downloadingModelsFlow.collect { downloadModels -> From f6b5b92f47e4e2d16feeac5211f39093d09c90e1 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Mar 2025 14:58:46 +0200 Subject: [PATCH 19/29] fix: update downloading state if new blocks was added --- .../presentation/download/DownloadsScreen.kt | 6 +-- .../download/DownloadsViewModel.kt | 41 ++++++++++++------- .../downloads/DownloadsViewModelTest.kt | 7 ++++ 3 files changed, 36 insertions(+), 18 deletions(-) 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 b6c0e248f..07ead2f29 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 @@ -284,9 +284,9 @@ private fun CourseItem( .filter { it.downloadedState == DownloadedState.DOWNLOADED } .sumOf { it.size } val availableSize = downloadCoursePreview.totalSize - downloadedSize - val availableSizeString = availableSize.toFileSize(space = false) + val availableSizeString = availableSize.toFileSize(space = false, round = 1) val progress: Float = try { - downloadedSize.toFloat() / availableSize.toFloat() + downloadedSize.toFloat() / downloadCoursePreview.totalSize.toFloat() } catch (_: ArithmeticException) { 0f } @@ -350,7 +350,7 @@ private fun CourseItem( color = MaterialTheme.appColors.successGreen, text = stringResource( R.string.downloaded_downloaded_size, - downloadedSize.toFileSize(space = false) + downloadedSize.toFileSize(space = false, round = 1) ) ) } diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index ffeb2e1f4..2ffe46162 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -1,7 +1,5 @@ package org.openedx.downloads.presentation.download -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -138,7 +136,13 @@ class DownloadsViewModel( val computedState = if (blockStates.isEmpty()) { DownloadedState.NOT_DOWNLOADED } else { - determineCourseState(blockStates) + val downloadedSize = _uiState.value.downloadModels + .filter { it.courseId == courseId } + .sumOf { it.size } + val courseSize = _uiState.value.downloadCoursePreviews + .find { it.id == courseId }?.totalSize ?: 0 + val isSizeMatch: Boolean = downloadedSize.toDouble() / courseSize >= 0.95 + determineCourseState(blockStates, isSizeMatch) } if (currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && computedState == DownloadedState.NOT_DOWNLOADED @@ -156,9 +160,12 @@ class DownloadsViewModel( } } - private fun determineCourseState(blockStates: List): DownloadedState { + private fun determineCourseState( + blockStates: List, + isSizeMatch: Boolean + ): DownloadedState { return when { - blockStates.all { it == DownloadedState.DOWNLOADED } -> DownloadedState.DOWNLOADED + blockStates.all { it == DownloadedState.DOWNLOADED } && isSizeMatch -> DownloadedState.DOWNLOADED blockStates.all { it == DownloadedState.WAITING } -> DownloadedState.WAITING blockStates.any { it == DownloadedState.DOWNLOADING } -> DownloadedState.DOWNLOADING else -> DownloadedState.NOT_DOWNLOADED @@ -169,7 +176,9 @@ class DownloadsViewModel( viewModelScope.launch(Dispatchers.IO) { updateLoadingState(isLoading = !refresh, isRefreshing = refresh) interactor.getDownloadCoursesPreview(refresh) - .onCompletion { resetLoadingState() } + .onCompletion { + resetLoadingState() + } .catch { e -> emitErrorMessage(e) } @@ -183,7 +192,11 @@ class DownloadsViewModel( .forEach { addDownloadableChildrenForSequentialBlock(it) } initDownloadModelsStatus() _uiState.update { state -> - state.copy(downloadCoursePreviews = downloadCoursePreviews) + state.copy( + downloadCoursePreviews = downloadCoursePreviews, + isLoading = false, + isRefreshing = false + ) } } } @@ -195,12 +208,6 @@ class DownloadsViewModel( } } - private fun resetLoadingState() { - _uiState.update { state -> - state.copy(isLoading = false, isRefreshing = false) - } - } - private suspend fun emitErrorMessage(e: Throwable) { val text = if (e.isInternetError()) { R.string.core_error_no_connection @@ -213,7 +220,6 @@ class DownloadsViewModel( } fun refreshData() { - _uiState.update { it.copy(isRefreshing = true) } fetchDownloads(refresh = true) } @@ -258,7 +264,6 @@ class DownloadsViewModel( val downloadDialogItem = DownloadDialogItem( title = title, size = totalSize, - icon = Icons.AutoMirrored.Outlined.InsertDriveFile ) downloadDialogManager.showRemoveDownloadModelPopup( downloadDialogItem = downloadDialogItem, @@ -343,6 +348,12 @@ class DownloadsViewModel( ) } + private fun resetLoadingState() { + _uiState.update { state -> + state.copy(isLoading = false, isRefreshing = false) + } + } + private fun updateCourseState(courseId: String, state: DownloadedState) { _uiState.update { currentState -> currentState.copy( diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 6e8756c08..729b7a6cd 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -43,6 +43,7 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.downloads.domain.interactor.DownloadInteractor import org.openedx.downloads.presentation.DownloadsRouter @@ -76,6 +77,7 @@ class DownloadsViewModelTest { private val downloadHelper = mockk(relaxed = true) private val router = mockk(relaxed = true) private val discoveryNotifier = mockk(relaxed = true) + private val courseNotifier = mockk(relaxed = true) private val noInternet = "No connection" private val unknownError = "Unknown error" @@ -220,6 +222,7 @@ class DownloadsViewModelTest { config, analytics, discoveryNotifier, + courseNotifier, router, preferencesManager, coreAnalytics, @@ -246,6 +249,7 @@ class DownloadsViewModelTest { config, analytics, discoveryNotifier, + courseNotifier, router, preferencesManager, coreAnalytics, @@ -287,6 +291,7 @@ class DownloadsViewModelTest { config, analytics, discoveryNotifier, + courseNotifier, router, preferencesManager, coreAnalytics, @@ -320,6 +325,7 @@ class DownloadsViewModelTest { config, analytics, discoveryNotifier, + courseNotifier, router, preferencesManager, coreAnalytics, @@ -359,6 +365,7 @@ class DownloadsViewModelTest { config, analytics, discoveryNotifier, + courseNotifier, router, preferencesManager, coreAnalytics, From d0288bd050fe3669644293a659155af32bd3ecb2 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 17 Mar 2025 18:50:43 +0200 Subject: [PATCH 20/29] feat: added height limit and scroll to download dialog --- .../DownloadConfirmDialogFragment.kt | 14 ++++++++---- .../downloaddialog/DownloadDialogManager.kt | 22 +++++++++++++++++++ .../DownloadErrorDialogFragment.kt | 8 +++++-- .../DownloadStorageErrorDialogFragment.kt | 11 +++++++--- .../offline/CourseOfflineViewModel.kt | 1 - .../presentation/download/DownloadsScreen.kt | 4 ++-- .../download/DownloadsViewModel.kt | 7 +++++- 7 files changed, 54 insertions(+), 13 deletions(-) diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index 61b393c9b..5ab8db529 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -62,8 +63,10 @@ class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { setContent { OpenEdXTheme { val dialogType = - requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme - val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + requireArguments().parcelable(ARG_DIALOG_TYPE) + ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) + ?: return@OpenEdXTheme val sizeSumString = uiState.sizeSum.toFileSize(1, false) val dialogData = when (dialogType) { DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( @@ -150,7 +153,6 @@ private fun DownloadConfirmDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -173,7 +175,11 @@ private fun DownloadConfirmDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index d98d9d43a..83f23be11 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -1,5 +1,10 @@ package org.openedx.core.presentation.dialog.downloaddialog +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,6 +19,7 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadModel import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.foundation.presentation.rememberWindowSize interface DownloadDialogListener { fun onCancelClick() @@ -34,6 +40,22 @@ class DownloadDialogManager( companion object { const val MAX_CELLULAR_SIZE = 104857600 // 100MB const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size + + val listMaxSize: Dp + @Composable + get() { + val configuration = LocalConfiguration.current + val windowSize = rememberWindowSize() + return when { + configuration.orientation == Configuration.ORIENTATION_PORTRAIT || windowSize.isTablet -> { + 200.dp + } + + else -> { + 88.dp + } + } + } } private val uiState = MutableSharedFlow() diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index ec796cce9..f7bbe6ea5 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -133,7 +134,6 @@ private fun DownloadErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -156,7 +156,11 @@ private fun DownloadErrorDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it) } diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index d74c56c50..8c026bdf2 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -18,6 +18,7 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -69,7 +70,8 @@ class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { - val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) + ?: return@OpenEdXTheme val downloadDialogResource = DownloadDialogResource( title = stringResource(id = R.string.core_device_storage_full), description = stringResource(id = R.string.core_download_device_storage_full_dialog_description), @@ -119,7 +121,6 @@ private fun DownloadStorageErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -142,7 +143,11 @@ private fun DownloadStorageErrorDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it.copy(size = it.size * DOWNLOAD_SIZE_FACTOR)) } diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 13cf24640..0786a5c4b 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -131,7 +131,6 @@ class CourseOfflineViewModel( val downloadDialogItem = DownloadDialogItem( title = courseTitle, size = totalSize, - icon = Icons.AutoMirrored.Outlined.InsertDriveFile ) downloadDialogManager.showRemoveDownloadModelPopup( downloadDialogItem = downloadDialogItem, 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 07ead2f29..fafa04f94 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 @@ -173,7 +173,7 @@ fun DownloadsScreen( columns = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp), - contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), content = { items(uiState.downloadCoursePreviews) { item -> val downloadModels = @@ -205,7 +205,7 @@ fun DownloadsScreen( } else { LazyColumn( modifier = contentWidth, - contentPadding = PaddingValues(bottom = 20.dp, top = 12.dp), + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { items(uiState.downloadCoursePreviews) { item -> diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index 2ffe46162..7e0312a65 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -141,7 +141,8 @@ class DownloadsViewModel( .sumOf { it.size } val courseSize = _uiState.value.downloadCoursePreviews .find { it.id == courseId }?.totalSize ?: 0 - val isSizeMatch: Boolean = downloadedSize.toDouble() / courseSize >= 0.95 + val isSizeMatch: Boolean = + downloadedSize.toDouble() / courseSize >= SIZE_MATCH_THRESHOLD determineCourseState(blockStates, isSizeMatch) } if (currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && @@ -367,6 +368,10 @@ class DownloadsViewModel( private fun getCoursePreview(courseId: String): DownloadCoursePreview? { return _uiState.value.downloadCoursePreviews.find { it.id == courseId } } + + companion object { + const val SIZE_MATCH_THRESHOLD = 0.95 + } } interface DownloadsViewActions { From 742463cf82e86625aaacca77e4aef0e0993d9645 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 17:25:38 +0200 Subject: [PATCH 21/29] feat: rename config flag --- core/src/main/java/org/openedx/core/config/Config.kt | 4 ++-- default_config/dev/config.yaml | 2 +- default_config/prod/config.yaml | 2 +- default_config/stage/config.yaml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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 fd6e333f4..b8cf7116a 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -93,7 +93,7 @@ class Config(context: Context) { } fun getDownloadsConfig(): DownloadsConfig { - return getObjectOrNewInstance(DOWNLOADS, DownloadsConfig::class.java) + return getObjectOrNewInstance(APP_LEVEL_DOWNLOADS, DownloadsConfig::class.java) } fun getBranchConfig(): BranchConfig { @@ -183,7 +183,7 @@ class Config(context: Context) { private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" - private const val DOWNLOADS = "DOWNLOADS" + private const val APP_LEVEL_DOWNLOADS = "APP_LEVEL_DOWNLOADS" private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 8bd583150..adbd4f0c8 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DOWNLOADS: +APP_LEVEL_DOWNLOADS: ENABLED: true FIREBASE: diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 8bd583150..adbd4f0c8 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DOWNLOADS: +APP_LEVEL_DOWNLOADS: ENABLED: true FIREBASE: diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 8bd583150..adbd4f0c8 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,7 +31,7 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -DOWNLOADS: +APP_LEVEL_DOWNLOADS: ENABLED: true FIREBASE: From eb7597dc360cd319a1fedb89974069fb69e01f16 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Thu, 20 Mar 2025 19:14:29 +0200 Subject: [PATCH 22/29] feat: fix available course size in download dialog. Improved logic of getting downloads models from room --- .../org/openedx/core/module/db/DownloadDao.kt | 3 + .../data/repository/DownloadRepository.kt | 8 +-- .../domain/interactor/DownloadInteractor.kt | 5 +- .../download/DownloadsViewModel.kt | 71 ++++++++++--------- .../downloads/DownloadsViewModelTest.kt | 14 ++-- 5 files changed, 54 insertions(+), 47 deletions(-) diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index a082554c0..377a8a2d9 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -33,6 +33,9 @@ interface DownloadDao { @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + @Query("SELECT * FROM download_model WHERE courseId = :courseId") + suspend fun getDownloadModelsByCourseIds(courseId: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt index adf10aa59..109d4c355 100644 --- a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -1,7 +1,6 @@ package org.openedx.downloads.data.repository import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map import org.openedx.core.data.api.CourseApi import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.CourseDao @@ -28,10 +27,6 @@ class DownloadRepository( dao.insertDownloadCoursePreview(downloadCoursesPreviewEntity) } - fun getDownloadModels() = dao.getAllDataFlow().map { list -> - list.map { it.mapToDomain() } - } - suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { val cachedCourseStructure = courseDao.getCourseStructureById(courseId) if (cachedCourseStructure != null) { @@ -56,5 +51,6 @@ class DownloadRepository( } } - suspend fun getAllDownloadModels() = dao.readAllData().map { it.mapToDomain() } + suspend fun getDownloadModelsByCourseIds(courseId: String) = + dao.getDownloadModelsByCourseIds(courseId).map { it.mapToDomain() } } diff --git a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt index b67d411b3..6082e7751 100644 --- a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -7,9 +7,8 @@ class DownloadInteractor( ) { fun getDownloadCoursesPreview(refresh: Boolean) = repository.getDownloadCoursesPreview(refresh) - fun getDownloadModels() = repository.getDownloadModels() - - suspend fun getAllDownloadModels() = repository.getAllDownloadModels() + suspend fun getDownloadModelsByCourseIds(courseId: String) = + repository.getDownloadModelsByCourseIds(courseId) suspend fun getCourseStructureFromCache(courseId: String) = repository.getCourseStructureFromCache(courseId) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index 7e0312a65..b91f11fc6 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -209,15 +208,17 @@ class DownloadsViewModel( } } - private suspend fun emitErrorMessage(e: Throwable) { - val text = if (e.isInternetError()) { - R.string.core_error_no_connection - } else { - R.string.core_error_unknown_error + private fun emitErrorMessage(e: Throwable) { + viewModelScope.launch { + val text = if (e.isInternetError()) { + R.string.core_error_no_connection + } else { + R.string.core_error_unknown_error + } + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(text)) + ) } - _uiMessage.emit( - UIMessage.SnackBarMessage(resourceManager.getString(text)) - ) } fun refreshData() { @@ -235,9 +236,7 @@ class DownloadsViewModel( } catch (e: Exception) { logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) - viewModelScope.launch { - emitErrorMessage(e) - } + emitErrorMessage(e) } } @@ -245,8 +244,8 @@ class DownloadsViewModel( logEvent(DownloadsAnalyticsEvent.CANCEL_DOWNLOAD_CLICKED) viewModelScope.launch { downloadJobs[courseId]?.cancel() - interactor.getAllDownloadModels() - .filter { it.courseId == courseId && it.downloadedState.isWaitingOrDownloading } + interactor.getDownloadModelsByCourseIds(courseId) + .filter { it.downloadedState.isWaitingOrDownloading } .forEach { removeBlockDownloadModel(it.id) } } } @@ -254,9 +253,7 @@ class DownloadsViewModel( fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) viewModelScope.launch { - val downloadModels = interactor.getDownloadModels().first().filter { - it.courseId == courseId - } + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) val downloadedModels = downloadModels.filter { it.downloadedState == DownloadedState.DOWNLOADED } @@ -292,23 +289,29 @@ class DownloadsViewModel( } private fun showDownloadPopup(fragmentManager: FragmentManager, courseId: String) { - val coursePreview = getCoursePreview(courseId) ?: return - downloadDialogManager.showPopup( - coursePreview = coursePreview, - isBlocksDownloaded = false, - fragmentManager = fragmentManager, - removeDownloadModels = ::removeDownloadModels, - saveDownloadModels = { - initiateSaveDownloadModels(courseId) - }, - onDismissClick = { - logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) - updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) - }, - onConfirmClick = { - logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CONFIRMED) - } - ) + viewModelScope.launch { + val coursePreview = getCoursePreview(courseId) ?: return@launch + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + val downloadedModelsSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + downloadDialogManager.showPopup( + coursePreview = coursePreview.copy(totalSize = coursePreview.totalSize - downloadedModelsSize), + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { + initiateSaveDownloadModels(courseId) + }, + onDismissClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + }, + onConfirmClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CONFIRMED) + } + ) + } } private fun initiateSaveDownloadModels(courseId: String) { diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 729b7a6cd..bddd6822a 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -8,6 +8,7 @@ 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.flow.flow @@ -186,6 +187,7 @@ class DownloadsViewModelTest { progress = null ) + @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -199,8 +201,7 @@ class DownloadsViewModelTest { } coEvery { interactor.getCourseStructureFromCache("course1") } returns courseStructure coEvery { interactor.getCourseStructure("course1") } returns courseStructure - coEvery { interactor.getDownloadModels() } returns flowOf(emptyList()) - coEvery { interactor.getAllDownloadModels() } returns emptyList() + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns emptyList() coEvery { downloadDao.getAllDataFlow() } returns flowOf( listOf( DownloadModelEntity.createFrom( @@ -210,6 +211,7 @@ class DownloadsViewModelTest { ) } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `onSettingsClick should navigate to settings`() = runTest { val viewModel = DownloadsViewModel( @@ -237,6 +239,7 @@ class DownloadsViewModelTest { verify(exactly = 1) { downloadsRouter.navigateToSettings(fragmentManager) } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `downloadCourse should show download dialog`() = runTest { val viewModel = DownloadsViewModel( @@ -278,6 +281,7 @@ class DownloadsViewModelTest { } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `cancelDownloading should update courseDownloadState to NOT_DOWNLOADED and cancel download job`() = runTest { @@ -308,12 +312,13 @@ class DownloadsViewModelTest { viewModel.cancelDownloading("course1") advanceUntilIdle() - coVerify { interactor.getAllDownloadModels() } + coVerify { interactor.getDownloadModelsByCourseIds(any()) } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `removeDownloads should show remove popup with correct parameters`() = runTest { - coEvery { interactor.getDownloadModels() } returns flowOf(listOf(downloadModel)) + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns listOf(downloadModel) val viewModel = DownloadsViewModel( downloadsRouter, @@ -350,6 +355,7 @@ class DownloadsViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `refreshData no internet error should emit snack bar message`() = runTest { every { networkConnection.isOnline() } returns true From b02935957d70ccf4468f6b6981b5619cb6599171 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 21 Mar 2025 13:12:15 +0200 Subject: [PATCH 23/29] feat: added school icon to DownloadDialogItem --- .../dialog/downloaddialog/DownloadDialogManager.kt | 5 ++++- .../downloads/presentation/download/DownloadsViewModel.kt | 3 +++ .../java/org/openedx/downloads/DownloadsViewModelTest.kt | 2 -- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 83f23be11..cc9959c79 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -1,6 +1,8 @@ package org.openedx.core.presentation.dialog.downloaddialog import android.content.res.Configuration +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.Dp @@ -327,7 +329,8 @@ class DownloadDialogManager( val downloadDialogItems = listOf( DownloadDialogItem( title = coursePreview.name, - size = coursePreview.totalSize + size = coursePreview.totalSize, + icon = Icons.Default.School ) ) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index b91f11fc6..e6c9cd2a6 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -1,5 +1,7 @@ package org.openedx.downloads.presentation.download +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -262,6 +264,7 @@ class DownloadsViewModel( val downloadDialogItem = DownloadDialogItem( title = title, size = totalSize, + icon = Icons.Default.School ) downloadDialogManager.showRemoveDownloadModelPopup( downloadDialogItem = downloadDialogItem, diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index bddd6822a..667639534 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -260,8 +260,6 @@ class DownloadsViewModelTest { workerController, downloadHelper ) - advanceUntilIdle() - val fragmentManager = mockk(relaxed = true) viewModel.downloadCourse(fragmentManager, "course1") advanceUntilIdle() From 37eadb8a938ea5ba008ec3a7b4683dc963e171b5 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 24 Mar 2025 15:07:44 +0200 Subject: [PATCH 24/29] fix: using real unarchived size instead bloc size --- .../offline/CourseOfflineViewModel.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 0786a5c4b..8f3637b24 100644 --- a/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt @@ -126,7 +126,8 @@ class CourseOfflineViewModel( fun deleteAll(fragmentManager: FragmentManager) { viewModelScope.launch { - val downloadModels = courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } + val downloadModels = + courseInteractor.getAllDownloadModels().filter { it.courseId == courseId } val totalSize = downloadModels.sumOf { it.size } val downloadDialogItem = DownloadDialogItem( title = courseTitle, @@ -169,7 +170,8 @@ class CourseOfflineViewModel( val completedDownloads = downloadModels.filter { it.downloadedState.isDownloaded && it.courseId == courseId } val completedDownloadIds = completedDownloads.map { it.id } - val downloadedBlocks = courseStructure.blockData.filter { it.id in completedDownloadIds } + val downloadedBlocks = + courseStructure.blockData.filter { it.id in completedDownloadIds } updateUIState( totalDownloadableSize, @@ -190,14 +192,19 @@ class CourseOfflineViewModel( val largestDownloads = completedDownloads .sortedByDescending { it.size } .take(n = 5) - + val progressBarValue = downloadedSize.toFloat() / totalDownloadableSize.toFloat() + val readyToDownloadSize = if (progressBarValue >= 1) { + 0 + } else { + totalDownloadableSize - realDownloadedSize + } _uiState.update { it.copy( isHaveDownloadableBlocks = true, largestDownloads = largestDownloads, - readyToDownloadSize = (totalDownloadableSize - downloadedSize).toFileSize(1, false), + readyToDownloadSize = readyToDownloadSize.toFileSize(1, false), downloadedSize = realDownloadedSize.toFileSize(1, false), - progressBarValue = downloadedSize.toFloat() / totalDownloadableSize.toFloat() + progressBarValue = progressBarValue ) } } From a0fbad8497f683b9487ff72bd4f8f4acc706b9ab Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Tue, 25 Mar 2025 14:05:36 +0200 Subject: [PATCH 25/29] fix: junit tests --- .../downloads/presentation/download/DownloadsViewModel.kt | 6 +----- .../java/org/openedx/downloads/DownloadsViewModelTest.kt | 7 ++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt index e6c9cd2a6..bfa1037ef 100644 --- a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -106,11 +106,7 @@ class DownloadsViewModel( viewModelScope.launch { courseNotifier.notifier.collect { notifier -> when (notifier) { - is CourseStructureGot -> { - fetchDownloads(refresh = true) - } - - is CourseStructureUpdated -> { + is CourseStructureGot, is CourseStructureUpdated -> { fetchDownloads(refresh = true) } } diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index 667639534..af12a7fc7 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -84,7 +84,12 @@ class DownloadsViewModelTest { private val unknownError = "Unknown error" private val downloadCoursePreview = - DownloadCoursePreview(id = "course1", name = "", image = "", totalSize = 0) + DownloadCoursePreview( + id = "course1", + name = "", + image = "", + totalSize = DownloadDialogManager.MAX_CELLULAR_SIZE.toLong() + ) private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, From 6dd621f7ae77c7027dd3203e2737a78278970f30 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Fri, 28 Mar 2025 12:11:13 +0200 Subject: [PATCH 26/29] feat: navigation icons update --- .../main/java/org/openedx/app/MainFragment.kt | 12 ++--- app/src/main/res/drawable/app_ic_book.xml | 45 ------------------- .../{app_ic_rows.xml => app_ic_book_fill.xml} | 0 .../main/res/drawable/app_ic_book_outline.xml | 26 +++++++++++ .../res/drawable/app_ic_discover_selector.xml | 5 +++ ...oud.xml => app_ic_download_cloud_fill.xml} | 0 .../app_ic_download_cloud_outline.xml | 9 ++++ .../drawable/app_ic_downloads_selector.xml | 5 +++ app/src/main/res/drawable/app_ic_home.xml | 38 ---------------- .../res/drawable/app_ic_learn_selector.xml | 5 +++ app/src/main/res/drawable/app_ic_profile.xml | 31 ------------- .../main/res/drawable/app_ic_profile_fill.xml | 9 ++++ .../res/drawable/app_ic_profile_outline.xml | 12 +++++ .../res/drawable/app_ic_profile_selector.xml | 5 +++ .../main/res/drawable/app_ic_search_fill.xml | 9 ++++ .../res/drawable/app_ic_search_outline.xml | 9 ++++ 16 files changed, 100 insertions(+), 120 deletions(-) delete mode 100644 app/src/main/res/drawable/app_ic_book.xml rename app/src/main/res/drawable/{app_ic_rows.xml => app_ic_book_fill.xml} (100%) create mode 100644 app/src/main/res/drawable/app_ic_book_outline.xml create mode 100644 app/src/main/res/drawable/app_ic_discover_selector.xml rename app/src/main/res/drawable/{app_ic_download_cloud.xml => app_ic_download_cloud_fill.xml} (100%) create mode 100644 app/src/main/res/drawable/app_ic_download_cloud_outline.xml create mode 100644 app/src/main/res/drawable/app_ic_downloads_selector.xml delete mode 100644 app/src/main/res/drawable/app_ic_home.xml create mode 100644 app/src/main/res/drawable/app_ic_learn_selector.xml delete mode 100644 app/src/main/res/drawable/app_ic_profile.xml create mode 100644 app/src/main/res/drawable/app_ic_profile_fill.xml create mode 100644 app/src/main/res/drawable/app_ic_profile_outline.xml create mode 100644 app/src/main/res/drawable/app_ic_profile_selector.xml create mode 100644 app/src/main/res/drawable/app_ic_search_fill.xml create mode 100644 app/src/main/res/drawable/app_ic_search_outline.xml diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index d7978b9f6..c2b5041c7 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -104,16 +104,16 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), ) - val tabIcons = mapOf( - R.id.fragmentLearn to R.drawable.app_ic_rows, - R.id.fragmentDiscover to R.drawable.app_ic_home, - R.id.fragmentDownloads to R.drawable.app_ic_download_cloud, - R.id.fragmentProfile to R.drawable.app_ic_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.fragmentProfile to R.drawable.app_ic_profile_selector ) for ((id, _) in tabList) { val menuItem = menu.add(Menu.NONE, id, Menu.NONE, tabTitles[id] ?: "") - tabIcons[id]?.let { menuItem.setIcon(it) } + tabIconSelectors[id]?.let { menuItem.setIcon(it) } } } diff --git a/app/src/main/res/drawable/app_ic_book.xml b/app/src/main/res/drawable/app_ic_book.xml deleted file mode 100644 index 4245846af..000000000 --- a/app/src/main/res/drawable/app_ic_book.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_book_fill.xml similarity index 100% rename from app/src/main/res/drawable/app_ic_rows.xml rename to app/src/main/res/drawable/app_ic_book_fill.xml diff --git a/app/src/main/res/drawable/app_ic_book_outline.xml b/app/src/main/res/drawable/app_ic_book_outline.xml new file mode 100644 index 000000000..58021d21f --- /dev/null +++ b/app/src/main/res/drawable/app_ic_book_outline.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/app_ic_discover_selector.xml b/app/src/main/res/drawable/app_ic_discover_selector.xml new file mode 100644 index 000000000..9d2d2a951 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_discover_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_download_cloud.xml b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml similarity index 100% rename from app/src/main/res/drawable/app_ic_download_cloud.xml rename to app/src/main/res/drawable/app_ic_download_cloud_fill.xml diff --git a/app/src/main/res/drawable/app_ic_download_cloud_outline.xml b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml new file mode 100644 index 000000000..193cc1a6a --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_downloads_selector.xml b/app/src/main/res/drawable/app_ic_downloads_selector.xml new file mode 100644 index 000000000..a24c486d5 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_downloads_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_home.xml b/app/src/main/res/drawable/app_ic_home.xml deleted file mode 100644 index b703f9f28..000000000 --- a/app/src/main/res/drawable/app_ic_home.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_learn_selector.xml b/app/src/main/res/drawable/app_ic_learn_selector.xml new file mode 100644 index 000000000..d3077a298 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_learn_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_profile.xml b/app/src/main/res/drawable/app_ic_profile.xml deleted file mode 100644 index 1b241a689..000000000 --- a/app/src/main/res/drawable/app_ic_profile.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_profile_fill.xml b/app/src/main/res/drawable/app_ic_profile_fill.xml new file mode 100644 index 000000000..c4ed432a2 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_profile_outline.xml b/app/src/main/res/drawable/app_ic_profile_outline.xml new file mode 100644 index 000000000..07226fc2b --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_outline.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/app_ic_profile_selector.xml b/app/src/main/res/drawable/app_ic_profile_selector.xml new file mode 100644 index 000000000..83708d080 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_search_fill.xml b/app/src/main/res/drawable/app_ic_search_fill.xml new file mode 100644 index 000000000..6635fc8b1 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_search_outline.xml b/app/src/main/res/drawable/app_ic_search_outline.xml new file mode 100644 index 000000000..4372bd085 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_outline.xml @@ -0,0 +1,9 @@ + + + From d9cf4619190d49456d76eeb37d2ed270f68b783b Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 31 Mar 2025 17:04:31 +0300 Subject: [PATCH 27/29] fix: changes according PR review --- .../downloads/data/repository/DownloadRepository.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt index 109d4c355..3a23f8118 100644 --- a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -39,10 +39,10 @@ class DownloadRepository( suspend fun getCourseStructure(courseId: String): CourseStructure { try { val response = api.getCourseStructure( - "stale-if-error=0", - "v4", - corePreferences.user?.username, - courseId + cacheControlHeaderParam = "stale-if-error=0", + blocksApiVersion = "v4", + username = corePreferences.user?.username, + courseId = courseId ) courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) return response.mapToDomain() From e240b8ad6c6b0c073985125fda0b73cdea2b0014 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 31 Mar 2025 17:58:51 +0300 Subject: [PATCH 28/29] feat: put config to experimental value --- .../{DownloadsConfig.kt => AppLevelDownloadsConfig.kt} | 2 +- core/src/main/java/org/openedx/core/config/Config.kt | 10 +++++++--- .../openedx/core/config/ExperimentalFeaturesConfig.kt | 8 ++++++++ default_config/dev/config.yaml | 7 ++++--- default_config/prod/config.yaml | 7 ++++--- default_config/stage/config.yaml | 7 ++++--- 6 files changed, 28 insertions(+), 13 deletions(-) rename core/src/main/java/org/openedx/core/config/{DownloadsConfig.kt => AppLevelDownloadsConfig.kt} (80%) create mode 100644 core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt diff --git a/core/src/main/java/org/openedx/core/config/DownloadsConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt similarity index 80% rename from core/src/main/java/org/openedx/core/config/DownloadsConfig.kt rename to core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt index 94374bc11..577f297c6 100644 --- a/core/src/main/java/org/openedx/core/config/DownloadsConfig.kt +++ b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt @@ -2,7 +2,7 @@ package org.openedx.core.config import com.google.gson.annotations.SerializedName -data class DownloadsConfig( +data class AppLevelDownloadsConfig( @SerializedName("ENABLED") val isEnabled: Boolean = true, ) diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index b8cf7116a..d26741699 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -92,8 +92,8 @@ class Config(context: Context) { return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) } - fun getDownloadsConfig(): DownloadsConfig { - return getObjectOrNewInstance(APP_LEVEL_DOWNLOADS, DownloadsConfig::class.java) + fun getDownloadsConfig(): AppLevelDownloadsConfig { + return getExperimentalFeaturesConfig().appLevelDownloadsConfig } fun getBranchConfig(): BranchConfig { @@ -124,6 +124,10 @@ class Config(context: Context) { return getBoolean(BROWSER_REGISTRATION, false) } + private fun getExperimentalFeaturesConfig(): ExperimentalFeaturesConfig { + return getObjectOrNewInstance(EXPERIMENTAL_FEATURES, ExperimentalFeaturesConfig::class.java) + } + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { @@ -183,7 +187,7 @@ class Config(context: Context) { private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" - private const val APP_LEVEL_DOWNLOADS = "APP_LEVEL_DOWNLOADS" + private const val EXPERIMENTAL_FEATURES = "EXPERIMENTAL_FEATURES" private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" diff --git a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt new file mode 100644 index 000000000..74624178c --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class ExperimentalFeaturesConfig( + @SerializedName("APP_LEVEL_DOWNLOADS") + val appLevelDownloadsConfig: AppLevelDownloadsConfig, +) diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index adbd4f0c8..a7f265a45 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -31,9 +31,6 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -APP_LEVEL_DOWNLOADS: - ENABLED: true - FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false @@ -67,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index adbd4f0c8..a7f265a45 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -31,9 +31,6 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -APP_LEVEL_DOWNLOADS: - ENABLED: true - FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false @@ -67,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index adbd4f0c8..a7f265a45 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -31,9 +31,6 @@ PROGRAM: DASHBOARD: TYPE: 'gallery' -APP_LEVEL_DOWNLOADS: - ENABLED: true - FIREBASE: ENABLED: false CLOUD_MESSAGING_ENABLED: false @@ -67,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" From e665d9ce6781b5a5c476c2b666eb2f1701312948 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk Date: Mon, 31 Mar 2025 18:20:09 +0300 Subject: [PATCH 29/29] fix: junit test fix --- .../test/java/org/openedx/downloads/DownloadsViewModelTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt index af12a7fc7..e9476fbcb 100644 --- a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -389,7 +389,6 @@ class DownloadsViewModelTest { advanceUntilIdle() assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) - // Also verify that the refreshing flag is cleared. assertFalse(viewModel.uiState.value.isRefreshing) } }