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/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/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/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..c2b5041c7 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 @@ -17,6 +18,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.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment @@ -40,29 +42,104 @@ class MainFragment : Fragment(R.layout.fragment_main) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + handleArguments() + setupBottomNavigation() + setupViewPager() + observeViewModel() + } - initViewPager() - - binding.bottomNavView.setOnItemSelectedListener { - when (it.itemId) { - R.id.fragmentLearn -> { - viewModel.logLearnTabClickedEvent() - binding.viewPager.setCurrentItem(0, false) + private fun handleArguments() { + 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, "") + } + } + } - R.id.fragmentDiscover -> { - viewModel.logDiscoveryTabClickedEvent() - binding.viewPager.setCurrentItem(1, false) - } + private fun setupBottomNavigation() { + val openTabArg = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val initialMenuId = getInitialMenuId(openTabArg) + binding.bottomNavView.selectedItemId = initialMenuId - R.id.fragmentProfile -> { - viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } + 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 + } else { + LearnTab.COURSES.name + } + ) + + return 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()) + } + } + + 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), + R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), + R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), + ) + val tabIconSelectors = mapOf( + R.id.fragmentLearn to R.drawable.app_ic_learn_selector, + R.id.fragmentDiscover to R.drawable.app_ic_discover_selector, + R.id.fragmentDownloads to R.drawable.app_ic_downloads_selector, + R.id.fragmentProfile to R.drawable.app_ic_profile_selector + ) + + for ((id, _) in tabList) { + val menuItem = menu.add(Menu.NONE, id, Menu.NONE, tabTitles[id] ?: "") + tabIconSelectors[id]?.let { menuItem.setIcon(it) } + } + } + + private fun setupBottomNavListener(tabList: List>) { + val menuIdToIndex = tabList.mapIndexed { index, pair -> pair.first to index }.toMap() + + 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 } + } + 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) } @@ -74,55 +151,30 @@ 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.PROFILE.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentProfile - } + 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) { + R.id.fragmentDownloads + } else { + R.id.fragmentLearn } - requireArguments().remove(ARG_OPEN_TAB) + HomeTab.PROFILE.name -> R.id.fragmentProfile + else -> R.id.fragmentLearn } } - @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(ProfileFragment()) + tabList.forEach { (_, fragment) -> + addFragment(fragment) + } } binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false 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..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,8 +40,10 @@ 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 import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter @@ -58,7 +60,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 @@ -68,6 +69,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 +129,7 @@ val appModule = module { single { get() } single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } + single { get() } single { NetworkConnection(get()) } @@ -205,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 6b7692f99..d00d0f1fe 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,9 @@ 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.download.DownloadsViewModel import org.openedx.foundation.presentation.WindowSize import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository @@ -190,7 +193,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()) } @@ -220,6 +232,7 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -482,4 +495,38 @@ val screenModule = module { get(), ) } + + single { + DownloadRepository( + api = get(), + corePreferences = get(), + dao = get(), + courseDao = get() + ) + } + single { + DownloadInteractor( + repository = get() + ) + } + viewModel { + DownloadsViewModel( + downloadsRouter = get(), + networkConnection = get(), + interactor = get(), + resourceManager = get(), + config = get(), + preferencesManager = get(), + coreAnalytics = get(), + downloadDao = get(), + workerController = get(), + downloadHelper = get(), + downloadDialogManager = get(), + fileUtil = get(), + analytics = get(), + discoveryNotifier = get(), + courseNotifier = get(), + router = 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..bfdcee43f 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,7 @@ const val DATABASE_NAME = "OpenEdX_db" OfflineXBlockProgress::class, CourseCalendarEventEntity::class, CourseCalendarStateEntity::class, + DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class ], autoMigrations = [ 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/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_fill.xml b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml new file mode 100644 index 000000000..8e623dc60 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml @@ -0,0 +1,9 @@ + + + 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 @@ + + + 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 f97e849f7..000000000 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ /dev/null @@ -1,22 +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..f769b5bde --- /dev/null +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index baa1c2a89..bfffb806e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,4 +7,5 @@ Learn Programs Profile + Downloads 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 f1ae6be5e..db0ce4bb1 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -119,6 +119,9 @@ dependencies { // OpenEdx libs api("com.github.openedx:openedx-app-foundation-android:1.0.0") + // Preview + debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" + 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/config/AppLevelDownloadsConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt new file mode 100644 index 000000000..577f297c6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +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 f240b9531..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,6 +92,10 @@ class Config(context: Context) { return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) } + fun getDownloadsConfig(): AppLevelDownloadsConfig { + return getExperimentalFeaturesConfig().appLevelDownloadsConfig + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -120,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) { @@ -179,6 +187,7 @@ class Config(context: Context) { private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" + 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/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/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/CourseInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt new file mode 100644 index 000000000..ef5a8b7c5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.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 CourseInteractor { + suspend fun getCourseStructure( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure + + suspend fun getAllDownloadModels(): List +} 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/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/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/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/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index a07329e4d..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 @@ -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 @@ -32,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) @@ -46,4 +50,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/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 0fcf962a3..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 @@ -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,7 @@ abstract class BaseDownloadViewModel( updateParentStatus(parentId, children.size, downloadingCount, downloadedCount) } - downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } - _downloadingModelsFlow.emit(downloadingModelsList) + _downloadingModelsFlow.emit(models) } private fun updateChildrenStatus( @@ -116,6 +114,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 +128,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 +202,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 +244,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/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/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 85% 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..5ab8db529 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,7 +1,6 @@ -package org.openedx.course.presentation.download +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 @@ -11,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 @@ -31,8 +31,11 @@ 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 +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,51 +44,52 @@ 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() { +class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) 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( - 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, + 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 = coreR.drawable.core_ic_warning), + 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 ) ) @@ -98,6 +102,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { onConfirmClick = { uiState.saveDownloadModels() dismiss() + listener?.onConfirmClick() }, onRemoveClick = { uiState.removeDownloadModels() @@ -105,6 +110,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -112,7 +118,6 @@ class DownloadConfirmDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadConfirmDialogFragment" const val ARG_DIALOG_TYPE = "dialogType" const val ARG_UI_STATE = "uiState" @@ -148,7 +153,6 @@ private fun DownloadConfirmDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -171,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) } @@ -188,14 +196,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 @@ -216,7 +224,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 57% 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..cc9959c79 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,5 +1,12 @@ -package org.openedx.course.presentation.download +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 +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -7,12 +14,23 @@ 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.CourseInteractor import org.openedx.core.domain.model.Block +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 import org.openedx.core.system.connection.NetworkConnection -import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.presentation.rememberWindowSize + +interface DownloadDialogListener { + fun onCancelClick() + fun onConfirmClick() +} + +interface DownloadDialog { + var listener: DownloadDialogListener? +} class DownloadDialogManager( private val networkConnection: NetworkConnection, @@ -24,6 +42,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() @@ -76,7 +110,22 @@ class DownloadDialogManager( else -> null } - dialog?.show(state.fragmentManager, dialog::class.java.simpleName) ?: state.saveDownloadModels() + val dialogListener = object : DownloadDialogListener { + 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() + } } } } @@ -87,8 +136,10 @@ class DownloadDialogManager( isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean = false, fragmentManager: FragmentManager, - removeDownloadModels: (blockId: String) -> Unit, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, ) { createDownloadItems( subSectionsBlocks = subSectionsBlocks, @@ -97,7 +148,29 @@ class DownloadDialogManager( isBlocksDownloaded = isBlocksDownloaded, onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, - saveDownloadModels = saveDownloadModels + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick + ) + } + + 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 ) } @@ -143,14 +216,16 @@ 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 } - val totalSize = blocks.sumOf { getFileSize(it) } + val totalSize = blocks.sumOf { it.getFileSize() } if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionBlock) if (totalSize > 0) { @@ -188,15 +263,18 @@ class DownloadDialogManager( fragmentManager: FragmentManager, isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean, - removeDownloadModels: (blockId: String) -> Unit, + 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 && @@ -204,8 +282,15 @@ class DownloadDialogManager( (!onlyVideoBlocks || it.type == BlockType.VIDEO) } } - val size = blocks.sumOf { getFileSize(it) } - if (size > 0) DownloadDialogItem(title = subSectionBlock.displayName, size = size) else null + val size = blocks.sumOf { it.getFileSize() } + if (size > 0) { + DownloadDialogItem( + title = subSectionBlock.displayName, + size = size + ) + } else { + null + } } uiState.emit( @@ -215,18 +300,65 @@ class DownloadDialogManager( isDownloadFailed = false, sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, - removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, - saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } + removeDownloadModels = { + subSectionsBlocks.forEach { + removeDownloadModels( + it.id, + courseId + ) + } + }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, ) ) } } - private fun getFileSize(block: Block): Long { - return when { - block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0L - block.isxBlock -> block.offlineDownload?.fileSize ?: 0L - else -> 0L + private fun createCourseDownloadItems( + coursePreview: DownloadCoursePreview, + fragmentManager: FragmentManager, + isBlocksDownloaded: Boolean, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + coroutineScope.launch { + val downloadDialogItems = listOf( + DownloadDialogItem( + title = coursePreview.name, + size = coursePreview.totalSize, + icon = Icons.Default.School + ) + ) + + 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, + ) + ) } } } 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 71% 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..72288449b 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 @@ -13,5 +13,7 @@ data class DownloadDialogUIState( val isDownloadFailed: Boolean, val fragmentManager: @RawValue FragmentManager, val removeDownloadModels: () -> Unit, - val saveDownloadModels: () -> Unit + val saveDownloadModels: () -> Unit, + val onDismissClick: () -> Unit = {}, + val onConfirmClick: () -> Unit = {}, ) : Parcelable 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 85% 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..f7bbe6ea5 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,7 +1,6 @@ -package org.openedx.course.presentation.download +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 @@ -11,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 @@ -27,8 +27,11 @@ 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 +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,20 +39,19 @@ 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() { +class DownloadErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { @@ -58,21 +60,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), - description = stringResource(id = R.string.course_download_no_internet_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_no_internet_connection), + 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), - icon = painterResource(id = R.drawable.course_ic_error), + 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), - icon = painterResource(id = R.drawable.course_ic_error), + 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), ) } @@ -86,6 +88,7 @@ class DownloadErrorDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -93,7 +96,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 +124,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, @@ -132,7 +134,6 @@ private fun DownloadErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -155,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) } @@ -167,7 +172,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 +199,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 83% 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..8c026bdf2 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,7 +1,6 @@ -package org.openedx.course.presentation.download +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 @@ -19,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 @@ -39,40 +39,43 @@ 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 +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() { +class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) 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.course_device_storage_full), - description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + 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), ) DownloadStorageErrorDialogView( @@ -80,6 +83,7 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { downloadDialogResource = downloadDialogResource, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -87,7 +91,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 @@ -118,7 +121,6 @@ private fun DownloadStorageErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -141,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)) } @@ -158,7 +164,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 +220,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 +233,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) ) @@ -233,7 +249,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) ), @@ -258,7 +274,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/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/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index aaaa0711d..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, @@ -1404,6 +1438,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/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..99df5b3d4 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..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,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.CourseInteractor 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 -) { +) : CourseInteractor { 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/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/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/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..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 @@ -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( @@ -159,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, @@ -170,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 @@ -181,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 @@ -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) { @@ -238,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 ) @@ -264,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 @@ -384,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 @@ -418,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 ) @@ -434,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/offline/CourseOfflineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/offline/CourseOfflineViewModel.kt index 19d67f79b..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 @@ -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) } ) } @@ -127,12 +126,12 @@ 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, size = totalSize, - icon = Icons.AutoMirrored.Outlined.InsertDriveFile ) downloadDialogManager.showRemoveDownloadModelPopup( downloadDialogItem = downloadDialogItem, @@ -171,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, @@ -192,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 ) } } 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..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 = R.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, @@ -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.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 5e2c0b8fa..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 @@ -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,16 @@ 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 +312,12 @@ private fun CourseVideosUI( AlertDialog( title = { Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_title) + text = stringResource(id = coreR.string.core_download_big_files_confirmation_title) ) }, text = { Text( - text = stringResource(id = R.string.course_download_big_files_confirmation_text) + text = stringResource(id = coreR.string.core_download_big_files_confirmation_text) ) }, onDismissRequest = { @@ -344,14 +348,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.core_delete_confirmation } else { - R.string.course_delete_in_process_confirmation + coreR.string.core_delete_in_process_confirmation } AlertDialog( @@ -402,7 +407,7 @@ private fun CourseVideosUI( text = { Text( text = stringResource( - id = R.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 5fe50a0e6..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 @@ -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.core_not_available_offline) + description = + stringResource(id = coreR.string.core_explore_other_parts_when_reconnect) } NotAvailableUnitType.NOT_DOWNLOADED -> { - title = stringResource(id = courseR.string.course_not_downloaded) + title = stringResource(id = coreR.string.core_not_downloaded) description = - stringResource(id = courseR.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/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..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 @@ -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.core_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/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 @@ - - - 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..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 @@ -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 @@ -264,7 +264,9 @@ class CourseOutlineViewModelTest { any(), any(), any(), - any() + any(), + any(), + any(), ) } returns Unit coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } @@ -581,7 +583,7 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() verify(exactly = 1) { coreAnalytics.logEvent( @@ -633,7 +635,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..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 @@ -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 @@ -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 @@ -366,95 +378,97 @@ class CourseVideoViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) } @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/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/default_config/dev/config.yaml b/default_config/dev/config.yaml index 4d1d694ec..a7f265a45 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -64,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 4d1d694ec..a7f265a45 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -64,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 4d1d694ec..a7f265a45 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -64,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/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..df169ecd9 --- /dev/null +++ b/downloads/build.gradle @@ -0,0 +1,65 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" + id 'kotlin-parcelize' +} + +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" +} 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..cdb308aa0 --- /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 diff --git a/downloads/src/main/AndroidManifest.xml b/downloads/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e10007615 --- /dev/null +++ b/downloads/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + 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..3a23f8118 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -0,0 +1,56 @@ +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.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 { + 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() } + emit(downloadCoursesPreview) + val downloadCoursesPreviewEntity = response.map { it.mapToRoomEntity() } + dao.insertDownloadCoursePreview(downloadCoursesPreviewEntity) + } + + 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( + cacheControlHeaderParam = "stale-if-error=0", + blocksApiVersion = "v4", + username = corePreferences.user?.username, + courseId = courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + return response.mapToDomain() + } catch (_: Exception) { + return getCourseStructureFromCache(courseId) + } + } + + 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 new file mode 100644 index 000000000..6082e7751 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -0,0 +1,17 @@ +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) + + suspend fun getDownloadModelsByCourseIds(courseId: String) = + repository.getDownloadModelsByCourseIds(courseId) + + 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/DownloadsRouter.kt b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt new file mode 100644 index 000000000..0b6445f19 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt @@ -0,0 +1,14 @@ +package org.openedx.downloads.presentation + +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 new file mode 100644 index 000000000..1dc4d1be9 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -0,0 +1,78 @@ +package org.openedx.downloads.presentation.download + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.theme.OpenEdXTheme + +class 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, + apiHostUrl = viewModel.apiHostUrl, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DownloadsViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DownloadsViewActions.SwipeRefresh -> { + viewModel.refreshData() + } + + is DownloadsViewActions.OpenCourse -> { + viewModel.navigateToCourseOutline( + fm = requireActivity().supportFragmentManager, + courseId = action.courseId + ) + } + + 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 + ) + } + } + } + ) + } + } + } +} 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..fafa04f94 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -0,0 +1,570 @@ +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 +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.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 +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.LocalConfiguration +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 configuration = LocalConfiguration.current + 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 + ) { + 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 = 46.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 = 46.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 +) { + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + 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, round = 1) + val progress: Float = try { + downloadedSize.toFloat() / downloadCoursePreview.totalSize.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() + ) { + 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) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + 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, round = 1) + ) + ) + } + 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/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt new file mode 100644 index 000000000..e3f24b666 --- /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..bfa1037ef --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -0,0 +1,386 @@ +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 +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseStructure +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 +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 +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 +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, + private val analytics: DownloadsAnalytics, + private val discoveryNotifier: DiscoveryNotifier, + private val courseNotifier: CourseNotifier, + private val router: DownloadsRouter, + 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 = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow = _uiMessage.asSharedFlow() + + private val courseBlockIds = mutableMapOf>() + + val hasInternetConnection: Boolean get() = networkConnection.isOnline() + + private var downloadJobs = mutableMapOf() + + init { + fetchDownloads(refresh = false) + observeCourseDashboardUpdates() + observeDownloadingModels() + observeDownloadModelsStatus() + observeCourseStructureUpdates() + } + + private fun observeCourseDashboardUpdates() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { notifier -> + if (notifier is CourseDashboardUpdate) { + fetchDownloads(refresh = true) + } + } + } + } + + private fun observeCourseStructureUpdates() { + viewModelScope.launch { + courseNotifier.notifier.collect { notifier -> + when (notifier) { + is CourseStructureGot, is CourseStructureUpdated -> { + fetchDownloads(refresh = true) + } + } + } + } + } + + private fun observeDownloadingModels() { + viewModelScope.launch { + downloadingModelsFlow.collect { downloadModels -> + _uiState.update { state -> + state.copy(downloadModels = downloadModels) + } + } + } + } + + private fun observeDownloadModelsStatus() { + viewModelScope.launch { + downloadModelsStatusFlow.collect { statusMap -> + 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 { + 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 >= SIZE_MATCH_THRESHOLD + determineCourseState(blockStates, isSizeMatch) + } + if (currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && + computedState == DownloadedState.NOT_DOWNLOADED + ) { + DownloadedState.LOADING_COURSE_STRUCTURE + } else { + computedState + } + } + + _uiState.update { state -> + state.copy(courseDownloadState = updatedCourseStates) + } + } + } + } + + private fun determineCourseState( + blockStates: List, + isSizeMatch: Boolean + ): DownloadedState { + return when { + 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 + } + } + + private fun fetchDownloads(refresh: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + updateLoadingState(isLoading = !refresh, isRefreshing = refresh) + interactor.getDownloadCoursesPreview(refresh) + .onCompletion { + resetLoadingState() + } + .catch { e -> + emitErrorMessage(e) + } + .collect { downloadCoursePreviews -> + 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, + isLoading = false, + isRefreshing = false + ) + } + } + } + } + + private fun updateLoadingState(isLoading: Boolean, isRefreshing: Boolean) { + _uiState.update { state -> + state.copy(isLoading = isLoading, isRefreshing = isRefreshing) + } + } + + 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)) + ) + } + } + + fun refreshData() { + fetchDownloads(refresh = true) + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + downloadsRouter.navigateToSettings(fragmentManager) + } + + fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COURSE_CLICKED) + try { + showDownloadPopup(fragmentManager, courseId) + } catch (e: Exception) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + + fun cancelDownloading(courseId: String) { + logEvent(DownloadsAnalyticsEvent.CANCEL_DOWNLOAD_CLICKED) + viewModelScope.launch { + downloadJobs[courseId]?.cancel() + interactor.getDownloadModelsByCourseIds(courseId) + .filter { it.downloadedState.isWaitingOrDownloading } + .forEach { removeBlockDownloadModel(it.id) } + } + } + + fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) + viewModelScope.launch { + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + 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, + size = totalSize, + icon = Icons.Default.School + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_REMOVED) + } + ) + } + } + + private suspend fun initializeCourseBlocks( + courseId: String, + useCache: Boolean + ): CourseStructure { + val courseStructure = if (useCache) { + interactor.getCourseStructureFromCache(courseId) + } else { + interactor.getCourseStructure(courseId) + } + courseBlockIds[courseStructure.id] = courseStructure.blockData.map { it.id } + addBlocks(courseStructure.blockData) + return courseStructure + } + + private fun showDownloadPopup(fragmentManager: FragmentManager, courseId: String) { + 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) { + 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 = getCoursePreview(courseId) ?: return + router.navigateToCourseOutline( + fm = fm, + courseId = coursePreview.id, + courseTitle = coursePreview.name, + ) + } + + private fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = mapOf(DownloadsAnalyticsKey.NAME.key to event.biValue) + ) + } + + private fun resetLoadingState() { + _uiState.update { state -> + state.copy(isLoading = false, isRefreshing = false) + } + } + + private fun updateCourseState(courseId: String, state: DownloadedState) { + _uiState.update { currentState -> + currentState.copy( + courseDownloadState = currentState.courseDownloadState.toMutableMap().apply { + put(courseId, state) + } + ) + } + } + + 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 { + 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/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml new file mode 100644 index 000000000..5a0503db1 --- /dev/null +++ b/downloads/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Downloads + Download course + Remove course downloads + Cancel download + No Courses with Downloadable Content + You currently have no courses with downloadable content. + %1$s downloaded + %1$s available + Stop downloading course + Loading course structure… + \ No newline at end of file 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..e9476fbcb --- /dev/null +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -0,0 +1,394 @@ +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.ExperimentalCoroutinesApi +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.runTest +import kotlinx.coroutines.test.setMain +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.core.system.notifier.CourseNotifier +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 +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 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" + + private val downloadCoursePreview = + DownloadCoursePreview( + id = "course1", + name = "", + image = "", + totalSize = DownloadDialogManager.MAX_CELLULAR_SIZE.toLong() + ) + 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 + ) + + @OptIn(ExperimentalCoroutinesApi::class) + @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.getDownloadModelsByCourseIds(any()) } returns emptyList() + coEvery { downloadDao.getAllDataFlow() } returns flowOf( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) + ) + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `onSettingsClick should navigate to settings`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.onSettingsClick(fragmentManager) + verify(exactly = 1) { downloadsRouter.navigateToSettings(fragmentManager) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `downloadCourse should show download dialog`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + + coVerify(exactly = 1) { + downloadDialogManager.showPopup( + coursePreview = any(), + isBlocksDownloaded = any(), + fragmentManager = any(), + removeDownloadModels = any(), + saveDownloadModels = any(), + onDismissClick = any(), + onConfirmClick = any() + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @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, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + viewModel.cancelDownloading("course1") + advanceUntilIdle() + + coVerify { interactor.getDownloadModelsByCourseIds(any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `removeDownloads should show remove popup with correct parameters`() = runTest { + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns listOf(downloadModel) + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + 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()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @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, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + val deferred = async { viewModel.uiMessage.first() } + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) + assertFalse(viewModel.uiState.value.isRefreshing) + } +} 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'