From 654bcf06293cc73f842e7ee8e72fdfabc08f0788 Mon Sep 17 00:00:00 2001 From: PavloNetrebchuk <141041606+PavloNetrebchuk@users.noreply.github.com> Date: Thu, 30 May 2024 11:11:05 +0300 Subject: [PATCH] feat: [FC-0047] Improved Dashboard Level Navigation (#308) * feat: Created Learn screen. Added course/program navigation. Added endpoint for UserCourses screen. * feat: Added primary course card * feat: Added start/resume course button * feat: Added alignment items * feat: Fix future assignment date, add courses list, add onSearch and onCourse clicks * feat: Add feature flag for enabling new/old dashboard screen, add UserCoursesScreen onClick methods * feat: Create AllEnrolledCoursesFragment. Add endpoint parameters * feat: AllEnrolledCoursesFragment UI * feat: Minor code refactoring, show cached data if no internet connection * feat: UserCourses screen data caching * feat: Dashboard * refactor: Dashboard type flag change, start course button change * feat: Added programs fragment to LearnFragment viewPager * feat: Empty states and settings button * fix: Number of courses * fix: Minor UI changes * fix: Fixes according to designer feedback * fix: Fixes after demo * refactor: Move CourseContainerTab * fix: Fixes according to PR feedback * fix: Fixes according to PR feedback * feat: added a patch from Omer Habib * fix: Fixes according to PR feedback --- .../main/java/org/openedx/app/AppRouter.kt | 25 +- .../org/openedx/app/InDevelopmentFragment.kt | 56 -- .../main/java/org/openedx/app/MainFragment.kt | 42 +- .../java/org/openedx/app/MainViewModel.kt | 18 +- .../main/java/org/openedx/app/di/AppModule.kt | 3 + .../java/org/openedx/app/di/ScreenModule.kt | 18 +- app/src/main/res/color/bottom_nav_color.xml | 5 + app/src/main/res/drawable/app_ic_rows.xml | 44 +- app/src/main/res/layout/fragment_main.xml | 2 + app/src/main/res/menu/bottom_view_menu.xml | 24 +- app/src/main/res/values-uk/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../core/adapter/NavigationFragmentAdapter.kt | 4 +- .../java/org/openedx/core/config/Config.kt | 5 + .../openedx/core/config/DashboardConfig.kt | 16 + .../org/openedx/core/data/api/CourseApi.kt | 9 + .../core/data/model/CourseAssignments.kt | 30 + .../core/data/model/CourseDateBlock.kt | 41 +- .../core/data/model/CourseEnrollments.kt | 26 +- .../openedx/core/data/model/CourseStatus.kt | 30 + .../openedx/core/data/model/EnrolledCourse.kt | 20 +- .../org/openedx/core/data/model/Progress.kt | 15 +- .../room/discovery/EnrolledCourseEntity.kt | 101 +- .../core/domain/model/CourseAssignments.kt | 10 + .../core/domain/model/CourseDateBlock.kt | 5 +- .../core/domain/model/CourseEnrollments.kt | 7 + .../openedx/core/domain/model/CourseStatus.kt | 12 + .../core/domain/model/EnrolledCourse.kt | 3 + .../org/openedx/core/domain/model/Progress.kt | 7 +- .../org/openedx/core/module/DownloadWorker.kt | 9 +- .../openedx/core/module/TranscriptManager.kt | 18 +- .../core/system/notifier/CourseNotifier.kt | 4 +- .../core/system/notifier/CourseOpenBlock.kt | 3 + .../core/system/notifier/CourseRefresh.kt | 5 - .../core/system/notifier/RefreshDates.kt | 3 + .../system/notifier/RefreshDiscussions.kt | 3 + .../java/org/openedx/core/ui/ComposeCommon.kt | 54 +- .../org/openedx/core/ui/ComposeExtensions.kt | 11 + .../main/java/org/openedx/core/ui/TabItem.kt | 2 +- .../java/org/openedx/core/ui/theme/Type.kt | 8 + .../java/org/openedx/core/utils/FileUtil.kt | 21 +- .../java/org/openedx/core/utils/TimeUtils.kt | 4 +- .../main/res/drawable/core_ic_settings.xml | 20 - .../res/drawable/ic_core_chapter_icon.xml | 30 + core/src/main/res/values-night/colors.xml | 4 +- core/src/main/res/values-uk/strings.xml | 4 +- core/src/main/res/values/colors.xml | 4 +- core/src/main/res/values/strings.xml | 5 +- .../course/data/storage/CourseConverter.kt | 13 + .../container/CollapsingLayout.kt | 4 +- .../container/CourseContainerFragment.kt | 44 +- .../container}/CourseContainerTab.kt | 16 +- .../container/CourseContainerViewModel.kt | 17 +- .../presentation/dates/CourseDatesScreen.kt | 40 +- .../dates/CourseDatesViewModel.kt | 11 +- .../outline/CourseOutlineScreen.kt | 76 +- .../outline/CourseOutlineViewModel.kt | 58 +- .../section/CourseSectionFragment.kt | 11 +- .../course/presentation/ui/CourseUI.kt | 2 +- .../course/presentation/ui/CourseVideosUI.kt | 58 +- .../videos/CourseVideoViewModel.kt | 2 + .../res/drawable/ic_course_chapter_icon.xml | 31 - course/src/main/res/values/strings.xml | 6 + .../container/CourseContainerViewModelTest.kt | 7 + .../dates/CourseDatesViewModelTest.kt | 14 +- .../outline/CourseOutlineViewModelTest.kt | 11 + .../videos/CourseVideoViewModelTest.kt | 10 +- .../presentation/MyCoursesScreenTest.kt | 6 +- .../java/org/openedx/DashboardNavigator.kt | 17 + .../src/main/java/org/openedx/DashboardUI.kt | 49 + .../presentation/AllEnrolledCoursesAction.kt | 14 + .../AllEnrolledCoursesFragment.kt | 27 + .../presentation/AllEnrolledCoursesUIState.kt | 10 + .../presentation/AllEnrolledCoursesView.kt | 639 +++++++++++++ .../AllEnrolledCoursesViewModel.kt | 181 ++++ .../openedx/courses/presentation/CourseTab.kt | 5 + .../presentation/DashboardGalleryFragment.kt | 24 + .../DashboardGalleryScreenAction.kt | 13 + .../presentation/DashboardGalleryUIState.kt | 9 + .../presentation/DashboardGalleryView.kt | 863 ++++++++++++++++++ .../presentation/DashboardGalleryViewModel.kt | 130 +++ .../data/repository/DashboardRepository.kt | 32 +- .../dashboard/domain/CourseStatusFilter.kt | 18 + .../domain/interactor/DashboardInteractor.kt | 17 +- ...rdFragment.kt => DashboardListFragment.kt} | 23 +- ...ViewModel.kt => DashboardListViewModel.kt} | 3 +- .../dashboard/presentation/DashboardRouter.kt | 9 + .../main/java/org/openedx/learn/LearnType.kt | 9 + .../learn/presentation/LearnFragment.kt | 274 ++++++ .../learn/presentation/LearnViewModel.kt | 18 + .../main/res/drawable/dashboard_ic_book.xml | 44 + .../src/main/res/layout/fragment_learn.xml | 24 + dashboard/src/main/res/values/strings.xml | 20 +- .../presentation/DashboardViewModelTest.kt | 20 +- default_config/dev/config.yaml | 3 + default_config/prod/config.yaml | 3 + default_config/stage/config.yaml | 3 + .../presentation/program/ProgramFragment.kt | 36 +- .../topics/DiscussionTopicsViewModel.kt | 9 +- .../calendar/CalendarAccessDialogFragment.kt | 6 +- .../calendar/NewCalendarDialogFragment.kt | 4 +- 101 files changed, 3301 insertions(+), 490 deletions(-) delete mode 100644 app/src/main/java/org/openedx/app/InDevelopmentFragment.kt create mode 100644 app/src/main/res/color/bottom_nav_color.xml rename app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt => core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt (74%) create mode 100644 core/src/main/java/org/openedx/core/config/DashboardConfig.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt create mode 100644 core/src/main/java/org/openedx/core/data/model/CourseStatus.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt create mode 100644 core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt delete mode 100644 core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt delete mode 100644 core/src/main/res/drawable/core_ic_settings.xml create mode 100644 core/src/main/res/drawable/ic_core_chapter_icon.xml rename {core/src/main/java/org/openedx/core/presentation/course => course/src/main/java/org/openedx/course/presentation/container}/CourseContainerTab.kt (52%) delete mode 100644 course/src/main/res/drawable/ic_course_chapter_icon.xml create mode 100644 dashboard/src/main/java/org/openedx/DashboardNavigator.kt create mode 100644 dashboard/src/main/java/org/openedx/DashboardUI.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt create mode 100644 dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt create mode 100644 dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt rename dashboard/src/main/java/org/openedx/dashboard/presentation/{DashboardFragment.kt => DashboardListFragment.kt} (97%) rename dashboard/src/main/java/org/openedx/dashboard/presentation/{DashboardViewModel.kt => DashboardListViewModel.kt} (99%) create mode 100644 dashboard/src/main/java/org/openedx/learn/LearnType.kt create mode 100644 dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt create mode 100644 dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt create mode 100644 dashboard/src/main/res/drawable/dashboard_ic_book.xml create mode 100644 dashboard/src/main/res/layout/fragment_learn.xml diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index a68b550a2..17b47d11d 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -25,6 +25,7 @@ import org.openedx.course.presentation.unit.container.CourseUnitContainerFragmen import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment +import org.openedx.courses.presentation.AllEnrolledCoursesFragment import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment @@ -123,6 +124,14 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, UpgradeRequiredFragment()) } + override fun navigateToAllEnrolledCourses(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, AllEnrolledCoursesFragment()) + } + + override fun getProgramFragmentInstance(): Fragment { + return ProgramFragment(myPrograms = true, isNestedFragment = true) + } + override fun navigateToCourseInfo( fm: FragmentManager, courseId: String, @@ -130,6 +139,18 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ) { replaceFragmentWithBackStack(fm, CourseInfoFragment.newInstance(courseId, infoType)) } + + override fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + enrollmentMode: String + ) { + replaceFragmentWithBackStack( + fm, + CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + ) + } //endregion //region DashboardRouter @@ -139,10 +160,12 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di courseId: String, courseTitle: String, enrollmentMode: String, + openTab: String, + resumeBlockId: String ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) + CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode, openTab, resumeBlockId) ) } diff --git a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt deleted file mode 100644 index d8ca717d4..000000000 --- a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -package org.openedx.app - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.fragment.app.Fragment -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appTypography - -class InDevelopmentFragment : Fragment() { - - @OptIn(ExperimentalComposeUiApi::class) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ) = ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .background(MaterialTheme.appColors.secondary), - contentAlignment = Alignment.Center - ) { - Text( - modifier = Modifier.testTag("txt_in_development"), - text = "Will be available soon", - style = MaterialTheme.appTypography.headlineMedium - ) - } - } - } - } -} diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index a798c4a3f..fc4fb1b22 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -11,15 +11,13 @@ import androidx.viewpager2.widget.ViewPager2 import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.app.adapter.MainNavigationFragmentAdapter +import org.openedx.DashboardNavigator import org.openedx.app.databinding.FragmentMainBinding -import org.openedx.core.config.Config +import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding -import org.openedx.dashboard.presentation.DashboardFragment import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.discovery.presentation.DiscoveryRouter -import org.openedx.discovery.presentation.program.ProgramFragment import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { @@ -27,9 +25,8 @@ class MainFragment : Fragment(R.layout.fragment_main) { private val binding by viewBinding(FragmentMainBinding::bind) private val viewModel by viewModel() private val router by inject() - private val config by inject() - private lateinit var adapter: MainNavigationFragmentAdapter + private lateinit var adapter: NavigationFragmentAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -47,24 +44,19 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { - R.id.fragmentHome -> { - viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentLearn -> { + viewModel.logMyCoursesTabClickedEvent() binding.viewPager.setCurrentItem(0, false) } - R.id.fragmentDashboard -> { - viewModel.logMyCoursesTabClickedEvent() + R.id.fragmentDiscover -> { + viewModel.logDiscoveryTabClickedEvent() binding.viewPager.setCurrentItem(1, false) } - R.id.fragmentPrograms -> { - viewModel.logMyProgramsTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) - } - R.id.fragmentProfile -> { viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(3, false) + binding.viewPager.setCurrentItem(2, false) } } true @@ -79,7 +71,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { viewLifecycleOwner.lifecycleScope.launch { viewModel.navigateToDiscovery.collect { shouldNavigateToDiscovery -> if (shouldNavigateToDiscovery) { - binding.bottomNavView.selectedItemId = R.id.fragmentHome + binding.bottomNavView.selectedItemId = R.id.fragmentDiscover } } } @@ -88,7 +80,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> val infoType = getString(ARG_INFO_TYPE) - if (config.getDiscoveryConfig().isViewTypeWebView() && infoType != null) { + if (viewModel.isDiscoveryTypeWebView && infoType != null) { router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) } else { router.navigateToCourseDetail(parentFragmentManager, courseId) @@ -105,18 +97,12 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL binding.viewPager.offscreenPageLimit = 4 - val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView) - .getDiscoveryFragment() - val programFragment = if (viewModel.isProgramTypeWebView) { - ProgramFragment(true) - } else { - InDevelopmentFragment() - } + val discoveryFragment = DiscoveryNavigator(viewModel.isDiscoveryTypeWebView).getDiscoveryFragment() + val dashboardFragment = DashboardNavigator(viewModel.dashboardType).getDashboardFragment() - adapter = MainNavigationFragmentAdapter(this).apply { + adapter = NavigationFragmentAdapter(this).apply { + addFragment(dashboardFragment) addFragment(discoveryFragment) - addFragment(DashboardFragment()) - addFragment(programFragment) addFragment(ProfileFragment()) } binding.viewPager.adapter = adapter diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 6a30533ea..eed901039 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -30,16 +30,18 @@ class MainViewModel( get() = _navigateToDiscovery.asSharedFlow() val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() - - val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + val dashboardType get() = config.getDashboardConfig().getType() override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - notifier.notifier.onEach { - if (it is NavigationToDiscovery) { - _navigateToDiscovery.emit(true) + notifier.notifier + .onEach { + if (it is NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } } - }.distinctUntilChanged().launchIn(viewModelScope) + .distinctUntilChanged() + .launchIn(viewModelScope) } fun enableBottomBar(enable: Boolean) { @@ -54,10 +56,6 @@ class MainViewModel( logEvent(AppAnalyticsEvent.MY_COURSES) } - fun logMyProgramsTabClickedEvent() { - logEvent(AppAnalyticsEvent.MY_PROGRAMS) - } - fun logProfileTabClickedEvent() { logEvent(AppAnalyticsEvent.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 529f00ac0..a5ec76b37 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -48,6 +48,7 @@ import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.utils.FileUtil import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter @@ -181,4 +182,6 @@ val appModule = module { factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } factory { OAuthHelper(get(), get(), get()) } + + factory { FileUtil(get()) } } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 3c99dbc0f..cd3615e26 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -29,9 +29,11 @@ import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.settings.download.DownloadQueueViewModel +import org.openedx.courses.presentation.AllEnrolledCoursesViewModel +import org.openedx.courses.presentation.DashboardGalleryViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.presentation.DashboardViewModel +import org.openedx.dashboard.presentation.DashboardListViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -49,6 +51,7 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository import org.openedx.profile.domain.interactor.ProfileInteractor import org.openedx.profile.domain.model.Account @@ -115,9 +118,12 @@ val screenModule = module { } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } - factory { DashboardRepository(get(), get(), get()) } + factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardGalleryViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { LearnViewModel(get(), get()) } factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } @@ -194,10 +200,11 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> + viewModel { (courseId: String, courseTitle: String, enrollmentMode: String, resumeBlockId: String) -> CourseContainerViewModel( courseId, courseTitle, + resumeBlockId, enrollmentMode, get(), get(), @@ -226,6 +233,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> @@ -267,6 +275,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } @@ -306,6 +315,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String, handoutsType: String) -> diff --git a/app/src/main/res/color/bottom_nav_color.xml b/app/src/main/res/color/bottom_nav_color.xml new file mode 100644 index 000000000..4e2851e90 --- /dev/null +++ b/app/src/main/res/color/bottom_nav_color.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_rows.xml index 41b74e9b4..eabe550d3 100644 --- a/app/src/main/res/drawable/app_ic_rows.xml +++ b/app/src/main/res/drawable/app_ic_rows.xml @@ -1,38 +1,10 @@ - - - - - - - + android:width="20dp" + android:height="17dp" + android:viewportWidth="20" + android:viewportHeight="17"> + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 89cf2914a..9794b7bd7 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -19,6 +19,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/background" + app:itemIconTint="@color/bottom_nav_color" + app:itemTextColor="@color/bottom_nav_color" app:labelVisibilityMode="labeled" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml index 60ba4f78c..f97e849f7 100644 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ b/app/src/main/res/menu/bottom_view_menu.xml @@ -2,27 +2,21 @@ + android:icon="@drawable/app_ic_rows" + android:title="@string/app_navigation_learn" /> - - + android:icon="@drawable/app_ic_home" + android:title="@string/app_navigation_discovery" /> + android:icon="@drawable/app_ic_profile" + android:title="@string/app_navigation_profile" /> - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8e4178d90..17d58ded3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -5,7 +5,7 @@ Назад Всі курси - Мої курси + Мої курси Програми Профіль - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f24815f30..baa1c2a89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ Previous Discover - Dashboard + Learn Programs Profile - \ No newline at end of file + diff --git a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt similarity index 74% rename from app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt rename to core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt index ccbe6f715..273c53427 100644 --- a/app/src/main/java/org/openedx/app/adapter/MainNavigationFragmentAdapter.kt +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -1,9 +1,9 @@ -package org.openedx.app.adapter +package org.openedx.core.adapter import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter -class MainNavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { +class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { private val fragments = ArrayList() 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 4d71b8dc1..c2fb7c15d 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -95,6 +95,10 @@ class Config(context: Context) { return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) } + fun getDashboardConfig(): DashboardConfig { + return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -169,6 +173,7 @@ class Config(context: Context) { private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" + private const val DASHBOARD = "DASHBOARD" private const val BRANCH = "BRANCH" private const val COURSE_DROPDOWN_NAVIGATION_ENABLED = "COURSE_DROPDOWN_NAVIGATION_ENABLED" private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" diff --git a/core/src/main/java/org/openedx/core/config/DashboardConfig.kt b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt new file mode 100644 index 000000000..9aa081aff --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/DashboardConfig.kt @@ -0,0 +1,16 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class DashboardConfig( + @SerializedName("TYPE") + private val viewType: String = DashboardType.GALLERY.name, +) { + fun getType(): DashboardType { + return DashboardType.valueOf(viewType.uppercase()) + } + + enum class DashboardType { + LIST, GALLERY + } +} 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 4a19c383d..6d30a9044 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 @@ -67,4 +67,13 @@ interface CourseApi { @GET("/api/mobile/v1/course_info/{course_id}/updates") suspend fun getAnnouncements(@Path("course_id") courseId: String): List + + @GET("/api/mobile/v4/users/{username}/course_enrollments/") + suspend fun getUserCourses( + @Path("username") username: String, + @Query("page") page: Int = 1, + @Query("page_size") pageSize: Int = 20, + @Query("status") status: String? = null, + @Query("requested_fields") fields: List = emptyList() + ): CourseEnrollments } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt new file mode 100644 index 000000000..ed8de3a4e --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAssignments.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAssignmentsDb +import org.openedx.core.domain.model.CourseAssignments + +data class CourseAssignments( + @SerializedName("future_assignments") + val futureAssignments: List?, + @SerializedName("past_assignments") + val pastAssignments: List?, +) { + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToDomain() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToDomain() + } + ) + + fun mapToRoomEntity() = CourseAssignmentsDb( + futureAssignments = futureAssignments?.mapNotNull { + it.mapToRoomEntity() + }, + pastAssignments = pastAssignments?.mapNotNull { + it.mapToRoomEntity() + } + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt index 887112845..d29e7a7ea 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseDateBlock.kt @@ -1,8 +1,13 @@ package org.openedx.core.data.model +import android.os.Parcelable import com.google.gson.annotations.SerializedName -import java.util.* +import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseDateBlockDb +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.utils.TimeUtils +@Parcelize data class CourseDateBlock( @SerializedName("complete") val complete: Boolean = false, @@ -25,4 +30,36 @@ data class CourseDateBlock( // component blockId in-case of navigating inside the app for component available in mobile @SerializedName("first_component_block_id") val blockId: String = "", -) +) : Parcelable { + fun mapToDomain(): CourseDateBlock? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlock( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } + + fun mapToRoomEntity(): CourseDateBlockDb? { + TimeUtils.iso8601ToDate(date)?.let { + return CourseDateBlockDb( + complete = complete, + date = it, + assignmentType = assignmentType, + dateType = dateType, + description = description, + learnerHasAccess = learnerHasAccess, + link = link, + title = title, + blockId = blockId + ) + } ?: return null + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt index 89ecdcab4..ca28740fe 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -7,6 +7,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName import java.lang.reflect.Type +import org.openedx.core.domain.model.CourseEnrollments as DomainCourseEnrollments data class CourseEnrollments( @SerializedName("enrollments") @@ -14,17 +15,38 @@ data class CourseEnrollments( @SerializedName("config") val configs: AppConfig, + + @SerializedName("primary") + val primary: EnrolledCourse?, ) { + fun mapToDomain() = DomainCourseEnrollments( + enrollments = enrollments.mapToDomain(), + configs = configs.mapToDomain(), + primary = primary?.mapToDomain() + ) + class Deserializer : JsonDeserializer { override fun deserialize( json: JsonElement?, typeOfT: Type?, - context: JsonDeserializationContext? + context: JsonDeserializationContext?, ): CourseEnrollments { val enrollments = deserializeEnrollments(json) val appConfig = deserializeAppConfig(json) + val primaryCourse = deserializePrimaryCourse(json) - return CourseEnrollments(enrollments, appConfig) + return CourseEnrollments(enrollments, appConfig, primaryCourse) + } + + private fun deserializePrimaryCourse(json: JsonElement?): EnrolledCourse? { + return try { + Gson().fromJson( + (json as JsonObject).get("primary"), + EnrolledCourse::class.java + ) + } catch (ex: Exception) { + null + } } private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList { diff --git a/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt new file mode 100644 index 000000000..53cb028b4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseStatus.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseStatusDb +import org.openedx.core.domain.model.CourseStatus + +data class CourseStatus( + @SerializedName("last_visited_module_id") + val lastVisitedModuleId: String?, + @SerializedName("last_visited_module_path") + val lastVisitedModulePath: List?, + @SerializedName("last_visited_block_id") + val lastVisitedBlockId: String?, + @SerializedName("last_visited_unit_display_name") + val lastVisitedUnitDisplayName: String?, +) { + fun mapToDomain() = CourseStatus( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) + + fun mapToRoomEntity() = CourseStatusDb( + lastVisitedModuleId = lastVisitedModuleId ?: "", + lastVisitedModulePath = lastVisitedModulePath ?: emptyList(), + lastVisitedBlockId = lastVisitedBlockId ?: "", + lastVisitedUnitDisplayName = lastVisitedUnitDisplayName ?: "" + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt index 984794698..edf8bbce3 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt @@ -2,8 +2,10 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.Progress as ProgressDomain data class EnrolledCourse( @SerializedName("audit_access_expires") @@ -17,7 +19,13 @@ data class EnrolledCourse( @SerializedName("course") val course: EnrolledCourseData?, @SerializedName("certificate") - val certificate: Certificate? + val certificate: Certificate?, + @SerializedName("course_progress") + val progress: Progress?, + @SerializedName("course_status") + val courseStatus: CourseStatus?, + @SerializedName("course_assignments") + val courseAssignments: CourseAssignments? ) { fun mapToDomain(): EnrolledCourse { return EnrolledCourse( @@ -26,7 +34,10 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToDomain()!!, - certificate = certificate?.mapToDomain() + certificate = certificate?.mapToDomain(), + progress = progress?.mapToDomain() ?: ProgressDomain.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToDomain(), + courseAssignments = courseAssignments?.mapToDomain() ) } @@ -38,7 +49,10 @@ data class EnrolledCourse( mode = mode ?: "", isActive = isActive ?: false, course = course?.mapToRoomEntity()!!, - certificate = certificate?.mapToRoomEntity() + certificate = certificate?.mapToRoomEntity(), + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, + courseStatus = courseStatus?.mapToRoomEntity(), + courseAssignments = courseAssignments?.mapToRoomEntity() ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/Progress.kt b/core/src/main/java/org/openedx/core/data/model/Progress.kt index 057148ea9..d4813c14c 100644 --- a/core/src/main/java/org/openedx/core/data/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -2,22 +2,21 @@ package org.openedx.core.data.model import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.ProgressDb +import org.openedx.core.domain.model.Progress data class Progress( @SerializedName("assignments_completed") val assignmentsCompleted: Int?, @SerializedName("total_assignments_count") - val totalAssignmentsCount: Int? + val totalAssignmentsCount: Int?, ) { - fun mapToDomain(): org.openedx.core.domain.model.Progress { - return org.openedx.core.domain.model.Progress( - assignmentsCompleted = assignmentsCompleted ?: 0, - totalAssignmentsCount = totalAssignmentsCount ?: 0 - ) - } + fun mapToDomain() = Progress( + assignmentsCompleted = assignmentsCompleted ?: 0, + totalAssignmentsCount = totalAssignmentsCount ?: 0 + ) fun mapToRoomEntity() = ProgressDb( assignmentsCompleted = assignmentsCompleted ?: 0, totalAssignmentsCount = totalAssignmentsCount ?: 0 ) -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index 87cc68e49..00c0c37e9 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -4,6 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey +import org.openedx.core.data.model.DateType import org.openedx.core.data.model.room.MediaDb import org.openedx.core.domain.model.Certificate import org.openedx.core.domain.model.CourseSharingUtmParameters @@ -12,6 +13,7 @@ import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils +import java.util.Date @Entity(tableName = "course_enrolled_table") data class EnrolledCourseEntity( @@ -30,6 +32,12 @@ data class EnrolledCourseEntity( val course: EnrolledCourseDataDb, @Embedded val certificate: CertificateDb?, + @Embedded + val progress: ProgressDb, + @Embedded + val courseStatus: CourseStatusDb?, + @Embedded + val courseAssignments: CourseAssignmentsDb?, ) { fun mapToDomain(): EnrolledCourse { @@ -39,7 +47,10 @@ data class EnrolledCourseEntity( mode, isActive, course.mapToDomain(), - certificate?.mapToDomain() + certificate?.mapToDomain(), + progress.mapToDomain(), + courseStatus?.mapToDomain(), + courseAssignments?.mapToDomain() ) } } @@ -84,7 +95,7 @@ data class EnrolledCourseDataDb( @ColumnInfo("videoOutline") val videoOutline: String, @ColumnInfo("isSelfPaced") - val isSelfPaced: Boolean + val isSelfPaced: Boolean, ) { fun mapToDomain(): EnrolledCourseData { return EnrolledCourseData( @@ -124,7 +135,7 @@ data class CoursewareAccessDb( @ColumnInfo("additionalContextUserMessage") val additionalContextUserMessage: String, @ColumnInfo("userFragment") - val userFragment: String + val userFragment: String, ) { fun mapToDomain(): CoursewareAccess { @@ -140,6 +151,24 @@ data class CoursewareAccessDb( } +data class CertificateDb( + @ColumnInfo("certificateURL") + val certificateURL: String?, +) { + fun mapToDomain() = Certificate(certificateURL) +} + +data class CourseSharingUtmParametersDb( + @ColumnInfo("facebook") + val facebook: String, + @ColumnInfo("twitter") + val twitter: String, +) { + fun mapToDomain() = CourseSharingUtmParameters( + facebook, twitter + ) +} + data class ProgressDb( @ColumnInfo("assignments_completed") val assignmentsCompleted: Int, @@ -153,20 +182,62 @@ data class ProgressDb( fun mapToDomain() = Progress(assignmentsCompleted, totalAssignmentsCount) } -data class CertificateDb( - @ColumnInfo("certificateURL") - val certificateURL: String? +data class CourseStatusDb( + @ColumnInfo("lastVisitedModuleId") + val lastVisitedModuleId: String, + @ColumnInfo("lastVisitedModulePath") + val lastVisitedModulePath: List, + @ColumnInfo("lastVisitedBlockId") + val lastVisitedBlockId: String, + @ColumnInfo("lastVisitedUnitDisplayName") + val lastVisitedUnitDisplayName: String, ) { - fun mapToDomain() = Certificate(certificateURL) + fun mapToDomain() = CourseStatus( + lastVisitedModuleId, lastVisitedModulePath, lastVisitedBlockId, lastVisitedUnitDisplayName + ) } -data class CourseSharingUtmParametersDb( - @ColumnInfo("facebook") - val facebook: String, - @ColumnInfo("twitter") - val twitter: String +data class CourseAssignmentsDb( + @ColumnInfo("futureAssignments") + val futureAssignments: List?, + @ColumnInfo("pastAssignments") + val pastAssignments: List?, ) { - fun mapToDomain() = CourseSharingUtmParameters( - facebook, twitter + fun mapToDomain() = CourseAssignments( + futureAssignments = futureAssignments?.map { it.mapToDomain() }, + pastAssignments = pastAssignments?.map { it.mapToDomain() } + ) +} + +data class CourseDateBlockDb( + @ColumnInfo("title") + val title: String = "", + @ColumnInfo("description") + val description: String = "", + @ColumnInfo("link") + val link: String = "", + @ColumnInfo("blockId") + val blockId: String = "", + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean = false, + @ColumnInfo("complete") + val complete: Boolean = false, + @Embedded + val date: Date, + @ColumnInfo("dateType") + val dateType: DateType = DateType.NONE, + @ColumnInfo("assignmentType") + val assignmentType: String? = "", +) { + fun mapToDomain() = CourseDateBlock( + title = title, + description = description, + link = link, + blockId = blockId, + learnerHasAccess = learnerHasAccess, + complete = complete, + date = date, + dateType = dateType, + assignmentType = assignmentType ) -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt new file mode 100644 index 000000000..feb039fc7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAssignments.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseAssignments( + val futureAssignments: List?, + val pastAssignments: List? +): Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt index 7e91c59fa..394ebdd56 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDateBlock.kt @@ -1,10 +1,13 @@ package org.openedx.core.domain.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import org.openedx.core.data.model.DateType import org.openedx.core.utils.isTimeLessThan24Hours import org.openedx.core.utils.isToday import java.util.Date +@Parcelize data class CourseDateBlock( val title: String = "", val description: String = "", @@ -15,7 +18,7 @@ data class CourseDateBlock( val date: Date, val dateType: DateType = DateType.NONE, val assignmentType: String? = "", -) { +) : Parcelable { fun isCompleted(): Boolean { return complete || (dateType in setOf( DateType.COURSE_START_DATE, diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt new file mode 100644 index 000000000..6606902c2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollments.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class CourseEnrollments( + val enrollments: DashboardCourseList, + val configs: AppConfig, + val primary: EnrolledCourse?, +) diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt new file mode 100644 index 000000000..aef245f67 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStatus.kt @@ -0,0 +1,12 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseStatus( + val lastVisitedModuleId: String, + val lastVisitedModulePath: List, + val lastVisitedBlockId: String, + val lastVisitedUnitDisplayName: String, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt index 8e339b3f6..184fc3aa4 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt @@ -12,4 +12,7 @@ data class EnrolledCourse( val isActive: Boolean, val course: EnrolledCourseData, val certificate: Certificate?, + val progress: Progress, + val courseStatus: CourseStatus?, + val courseAssignments: CourseAssignments? ) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt index 8362a8133..12363051a 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -8,9 +8,14 @@ data class Progress( val assignmentsCompleted: Int, val totalAssignmentsCount: Int, ) : Parcelable { + fun getProgress(): Float = try { assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() } catch (_: ArithmeticException) { 0f } -} \ No newline at end of file + + companion object { + val DEFAULT_PROGRESS = Progress(0, 0) + } +} 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 9234ec023..736a1b1ce 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -23,11 +23,12 @@ import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.FileDownloader import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged +import org.openedx.core.utils.FileUtil import java.io.File class DownloadWorker( val context: Context, - parameters: WorkerParameters + parameters: WorkerParameters, ) : CoroutineWorker(context, parameters), CoroutineScope { private val notificationManager = @@ -41,11 +42,7 @@ class DownloadWorker( private var downloadEnqueue = listOf() - private val folder = File( - context.externalCacheDir.toString() + File.separator + - context.getString(R.string.app_name) - .replace(Regex("\\s"), "_") - ) + private val folder = FileUtil(context).getExternalAppDir() private var currentDownload: DownloadModel? = null private var lastUpdateTime = 0L diff --git a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt index 863586900..c08870a33 100644 --- a/core/src/main/java/org/openedx/core/module/TranscriptManager.kt +++ b/core/src/main/java/org/openedx/core/module/TranscriptManager.kt @@ -1,9 +1,12 @@ package org.openedx.core.module import android.content.Context -import org.openedx.core.module.download.AbstractDownloader -import org.openedx.core.utils.* import okhttp3.OkHttpClient +import org.openedx.core.module.download.AbstractDownloader +import org.openedx.core.utils.Directories +import org.openedx.core.utils.FileUtil +import org.openedx.core.utils.IOUtils +import org.openedx.core.utils.Sha1Util import subtitleFile.FormatSRT import subtitleFile.TimedTextObject import java.io.File @@ -14,7 +17,7 @@ import java.nio.charset.Charset import java.util.concurrent.TimeUnit class TranscriptManager( - val context: Context + val context: Context, ) { private val transcriptDownloader = object : AbstractDownloader() { @@ -28,7 +31,9 @@ class TranscriptManager( val transcriptDir = getTranscriptDir() ?: return false val hash = Sha1Util.SHA1(url) val file = File(transcriptDir, hash) - return file.exists() && System.currentTimeMillis() - file.lastModified() < TimeUnit.HOURS.toMillis(5) + return file.exists() && System.currentTimeMillis() - file.lastModified() < TimeUnit.HOURS.toMillis( + 5 + ) } fun get(url: String): String? { @@ -113,7 +118,7 @@ class TranscriptManager( } private fun getTranscriptDir(): File? { - val externalAppDir: File = FileUtil.getExternalAppDir(context) + val externalAppDir: File = FileUtil(context).getExternalAppDir() if (externalAppDir.exists()) { val videosDir = File(externalAppDir, Directories.VIDEOS.name) val transcriptDir = File(videosDir, Directories.SUBTITLES.name) @@ -122,5 +127,4 @@ class TranscriptManager( } return null } - -} \ No newline at end of file +} 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 f4908bdef..527a7ce51 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 @@ -18,5 +18,7 @@ class CourseNotifier { suspend fun send(event: CalendarSyncEvent) = channel.emit(event) suspend fun send(event: CourseDatesShifted) = channel.emit(event) suspend fun send(event: CourseLoading) = channel.emit(event) - suspend fun send(event: CourseRefresh) = channel.emit(event) + suspend fun send(event: CourseOpenBlock) = channel.emit(event) + suspend fun send(event: RefreshDates) = channel.emit(event) + suspend fun send(event: RefreshDiscussions) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt new file mode 100644 index 000000000..6704f1256 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseOpenBlock.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +data class CourseOpenBlock(val blockId: String) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt deleted file mode 100644 index c85fc595d..000000000 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseRefresh.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.openedx.core.system.notifier - -import org.openedx.core.presentation.course.CourseContainerTab - -data class CourseRefresh(val courseContainerTab: CourseContainerTab) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt new file mode 100644 index 000000000..779d1b924 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshDates.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshDates : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt new file mode 100644 index 000000000..5c51f605b --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshDiscussions.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshDiscussions : 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 1692e7a4d..6c57df741 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -48,6 +49,7 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Search import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -199,8 +201,8 @@ fun Toolbar( onClick = { onSettingsClick() } ) { Icon( - painter = painterResource(id = R.drawable.core_ic_settings), - tint = MaterialTheme.appColors.primary, + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, contentDescription = stringResource(id = R.string.core_accessibility_settings) ) } @@ -939,22 +941,23 @@ fun TextIcon( icon: ImageVector, color: Color, textStyle: TextStyle = MaterialTheme.appTypography.bodySmall, - iconModifier: Modifier = Modifier, + modifier: Modifier = Modifier, + iconModifier: Modifier? = null, onClick: (() -> Unit)? = null, ) { - val modifier = if (onClick == null) { - Modifier + val rowModifier = if (onClick == null) { + modifier } else { - Modifier.noRippleClickable { onClick.invoke() } + modifier.clickable { onClick.invoke() } } Row( - modifier = modifier, + modifier = rowModifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text(text = text, color = color, style = textStyle) Icon( - modifier = iconModifier.size((textStyle.fontSize.value + 4).dp), + modifier = iconModifier ?: Modifier.size((textStyle.fontSize.value + 4).dp), imageVector = icon, contentDescription = null, tint = color @@ -1213,17 +1216,22 @@ fun RoundTabsBar( modifier: Modifier = Modifier, items: List, pagerState: PagerState, + contentPadding: PaddingValues = PaddingValues(), + withPager: Boolean = false, rowState: LazyListState = rememberLazyListState(), - onPageChange: (Int) -> Unit + onTabClicked: (Int) -> Unit = { } ) { + // The pager state does not work without the pager and the tabs do not change. + if (!withPager) { + HorizontalPager(state = pagerState) { } + } + val scope = rememberCoroutineScope() - val windowSize = rememberWindowSize() - val horizontalPadding = if (!windowSize.isTablet) 12.dp else 98.dp LazyRow( modifier = modifier, state = rowState, horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 16.dp, horizontal = horizontalPadding), + contentPadding = contentPadding, ) { itemsIndexed(items) { index, item -> val isSelected = pagerState.currentPage == index @@ -1246,10 +1254,11 @@ fun RoundTabsBar( .clickable { scope.launch { pagerState.scrollToPage(index) - onPageChange(index) + rowState.animateScrollToItem(index) + onTabClicked(index) } } - .padding(horizontal = 12.dp), + .padding(horizontal = 16.dp), item = item, contentColor = contentColor ) @@ -1268,12 +1277,15 @@ private fun RoundTab( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - Icon( - painter = rememberVectorPainter(item.icon), - tint = contentColor, - contentDescription = null - ) - Spacer(modifier = Modifier.width(4.dp)) + val icon = item.icon + if (icon != null) { + Icon( + painter = rememberVectorPainter(icon), + tint = contentColor, + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + } Text( text = stringResource(item.labelResId), color = contentColor @@ -1374,7 +1386,7 @@ private fun RoundTabsBarPreview() { items = listOf(mockTab, mockTab, mockTab), rowState = rememberLazyListState(), pagerState = rememberPagerState(pageCount = { 3 }), - onPageChange = { } + onTabClicked = { } ) } } diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index 1659a0417..5165619b6 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -77,6 +78,16 @@ fun LazyListState.shouldLoadMore(rememberedIndex: MutableState, threshold: return false } +fun LazyGridState.shouldLoadMore(rememberedIndex: MutableState, threshold: Int): Boolean { + val firstVisibleIndex = this.firstVisibleItemIndex + if (rememberedIndex.value != firstVisibleIndex) { + rememberedIndex.value = firstVisibleIndex + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + return lastVisibleIndex >= layoutInfo.totalItemsCount - 1 - threshold + } + return false +} + fun Modifier.statusBarsInset(): Modifier = composed { val topInset = (LocalContext.current as? InsetHolder)?.topInset ?: 0 return@composed this diff --git a/core/src/main/java/org/openedx/core/ui/TabItem.kt b/core/src/main/java/org/openedx/core/ui/TabItem.kt index 65a88861e..d6952c010 100644 --- a/core/src/main/java/org/openedx/core/ui/TabItem.kt +++ b/core/src/main/java/org/openedx/core/ui/TabItem.kt @@ -6,5 +6,5 @@ import androidx.compose.ui.graphics.vector.ImageVector interface TabItem { @get:StringRes val labelResId: Int - val icon: ImageVector + val icon: ImageVector? } diff --git a/core/src/main/java/org/openedx/core/ui/theme/Type.kt b/core/src/main/java/org/openedx/core/ui/theme/Type.kt index 0160196f9..52d9adebb 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Type.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Type.kt @@ -17,6 +17,7 @@ data class AppTypography( val displayLarge: TextStyle, val displayMedium: TextStyle, val displaySmall: TextStyle, + val headlineBold: TextStyle, val headlineLarge: TextStyle, val headlineMedium: TextStyle, val headlineSmall: TextStyle, @@ -72,6 +73,13 @@ internal val LocalTypography = staticCompositionLocalOf { letterSpacing = 0.sp, fontFamily = fontFamily ), + headlineBold = TextStyle( + fontSize = 34.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.sp, + fontFamily = fontFamily + ), headlineMedium = TextStyle( fontSize = 28.sp, lineHeight = 36.sp, diff --git a/core/src/main/java/org/openedx/core/utils/FileUtil.kt b/core/src/main/java/org/openedx/core/utils/FileUtil.kt index 001d03f4f..2f5c2b2e5 100644 --- a/core/src/main/java/org/openedx/core/utils/FileUtil.kt +++ b/core/src/main/java/org/openedx/core/utils/FileUtil.kt @@ -1,11 +1,13 @@ package org.openedx.core.utils import android.content.Context +import com.google.gson.Gson +import com.google.gson.GsonBuilder import java.io.File -object FileUtil { +class FileUtil(val context: Context) { - fun getExternalAppDir(context: Context): File { + fun getExternalAppDir(): File { val dir = context.externalCacheDir.toString() + File.separator + context.getString(org.openedx.core.R.string.app_name).replace(Regex("\\s"), "_") val file = File(dir) @@ -13,7 +15,22 @@ object FileUtil { return file } + inline fun saveObjectToFile(obj: T, fileName: String = "${T::class.java.simpleName}.json") { + val gson: Gson = GsonBuilder().setPrettyPrinting().create() + val jsonString = gson.toJson(obj) + File(getExternalAppDir().path + fileName).writeText(jsonString) + } + inline fun getObjectFromFile(fileName: String = "${T::class.java.simpleName}.json"): T? { + val file = File(getExternalAppDir().path + fileName) + return if (file.exists()) { + val gson: Gson = GsonBuilder().setPrettyPrinting().create() + val jsonString = file.readText() + gson.fromJson(jsonString, T::class.java) + } else { + null + } + } } enum class Directories { diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index e23135e47..908e650a1 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -59,7 +59,7 @@ object TimeUtils { private fun dateToCourseDate(resourceManager: ResourceManager, date: Date?): String { return formatDate( - format = resourceManager.getString(R.string.core_date_format_MMMM_dd), date = date + format = resourceManager.getString(R.string.core_date_format_MMM_dd_yyyy), date = date ) } @@ -152,7 +152,7 @@ object TimeUtils { ) } else { resourceManager.getString( - R.string.core_label_ending, dateToCourseDate(resourceManager, end) + R.string.core_label_ends, dateToCourseDate(resourceManager, end) ) } } diff --git a/core/src/main/res/drawable/core_ic_settings.xml b/core/src/main/res/drawable/core_ic_settings.xml deleted file mode 100644 index a86316516..000000000 --- a/core/src/main/res/drawable/core_ic_settings.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/core/src/main/res/drawable/ic_core_chapter_icon.xml b/core/src/main/res/drawable/ic_core_chapter_icon.xml new file mode 100644 index 000000000..9ee00fed7 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_chapter_icon.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/core/src/main/res/values-night/colors.xml b/core/src/main/res/values-night/colors.xml index 5a7d9d3bd..d6f9f1a14 100644 --- a/core/src/main/res/values-night/colors.xml +++ b/core/src/main/res/values-night/colors.xml @@ -3,4 +3,6 @@ #FF19212F #5478F9 #19212F - \ No newline at end of file + #879FF5 + #8E9BAE + diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index f20cd28e1..2aab8871c 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -13,7 +13,7 @@ Виберіть значення Починається %1$s Закінчився %1$s - Закінчується %1$s + Закінчується %1$s Термін дії курсу закінчується %1$s Термін дії курсу закінчується %1$s Термін дії курсу минув %1$s @@ -31,7 +31,7 @@ Обліковий запис користувача не активовано. Будь ласка, спочатку активуйте свій обліковий запис. Надіслати електронний лист за допомогою ... Не встановлено жодного поштового клієнта - dd MMMM + dd MMMM, yyyy dd MMM yyyy HH:mm Оновлення додатку Ми рекомендуємо вам оновитись до останньої версії. Оновіться зараз, щоб отримати останні функції та виправлення. diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml index d6d7f456d..57a25d9ed 100644 --- a/core/src/main/res/values/colors.xml +++ b/core/src/main/res/values/colors.xml @@ -3,4 +3,6 @@ #FFFFFF #3C68FF #517BFE - \ No newline at end of file + #3C68FF + #97A5BB + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 7247f0fbe..d9f5972ac 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Select value Starting %1$s Ended %1$s - Ending %1$s + Ends %1$s Course access expires %1$s Course access expires on %1$s Course access expired %1$s @@ -46,7 +46,7 @@ OS version: Device model: Feedback - MMMM dd + MMM dd, yyyy dd MMM yyyy hh:mm aaa App Update We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes. @@ -170,7 +170,6 @@ - Home Videos Discussions diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index 91ac5a610..1865a3c34 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -4,6 +4,7 @@ import androidx.room.TypeConverter import com.google.gson.Gson import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.VideoInfoDb +import org.openedx.core.data.model.room.discovery.CourseDateBlockDb import org.openedx.core.extension.genericType class CourseConverter { @@ -57,4 +58,16 @@ class CourseConverter { return gson.toJson(map) } + @TypeConverter + fun fromListOfCourseDateBlockDb(value: List): String { + val json = Gson().toJson(value) + return json.toString() + } + + @TypeConverter + fun toListOfCourseDateBlockDb(value: String): List { + val type = genericType>() + return Gson().fromJson(value, type) + } + } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt index b5d73adaf..64ba858d8 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -64,7 +64,6 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.ui.RoundTabsBar import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize @@ -748,8 +747,7 @@ private fun CollapsingLayoutPreview() { RoundTabsBar( items = CourseContainerTab.entries, rowState = rememberLazyListState(), - pagerState = rememberPagerState(pageCount = { 5 }), - onPageChange = { } + pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) ) }, onBackClick = {}, diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index 3f13c506c..4df1fcf64 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -5,6 +5,7 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -53,7 +54,6 @@ import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.extension.takeIfNotEmpty -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.presentation.global.viewBinding import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialog import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType @@ -83,7 +83,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), requireArguments().getString(ARG_TITLE, ""), - requireArguments().getString(ARG_ENROLLMENT_MODE, "") + requireArguments().getString(ARG_ENROLLMENT_MODE, ""), + requireArguments().getString(ARG_RESUME_BLOCK, "") ) } @@ -260,16 +261,22 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { const val ARG_COURSE_ID = "courseId" const val ARG_TITLE = "title" const val ARG_ENROLLMENT_MODE = "enrollmentMode" + const val ARG_OPEN_TAB = "open_tab" + const val ARG_RESUME_BLOCK = "resume_block" fun newInstance( courseId: String, courseTitle: String, enrollmentMode: String, + openTab: String = CourseContainerTab.HOME.name, + resumeBlockId: String = "" ): CourseContainerFragment { val fragment = CourseContainerFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, ARG_TITLE to courseTitle, - ARG_ENROLLMENT_MODE to enrollmentMode + ARG_ENROLLMENT_MODE to enrollmentMode, + ARG_OPEN_TAB to openTab, + ARG_RESUME_BLOCK to resumeBlockId ) return fragment } @@ -300,9 +307,21 @@ fun CourseDashboard( val refreshing by viewModel.refreshing.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - val dataReady = viewModel.dataReady.observeAsState() + val openTab = bundle.getString(CourseContainerFragment.ARG_OPEN_TAB, CourseContainerTab.HOME.name) + val requiredTab = when (openTab.uppercase()) { + CourseContainerTab.HOME.name -> CourseContainerTab.HOME + CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS + CourseContainerTab.DATES.name -> CourseContainerTab.DATES + CourseContainerTab.DISCUSSIONS.name -> CourseContainerTab.DISCUSSIONS + CourseContainerTab.MORE.name -> CourseContainerTab.MORE + else -> CourseContainerTab.HOME + } - val pagerState = rememberPagerState(pageCount = { CourseContainerTab.entries.size }) + val pagerState = rememberPagerState( + initialPage = CourseContainerTab.entries.indexOf(requiredTab), + pageCount = { CourseContainerTab.entries.size } + ) + val dataReady = viewModel.dataReady.observeAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -347,9 +366,11 @@ fun CourseDashboard( if (isNavigationEnabled) { RoundTabsBar( items = CourseContainerTab.entries, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), rowState = tabState, pagerState = pagerState, - onPageChange = viewModel::courseContainerTabClickedEvent + withPager = true, + onTabClicked = viewModel::courseContainerTabClickedEvent ) } else { Spacer(modifier = Modifier.height(52.dp)) @@ -435,7 +456,7 @@ fun DashboardPager( CourseContainerTab.HOME -> { CourseOutlineScreen( windowSize = windowSize, - courseOutlineViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -443,7 +464,6 @@ fun DashboardPager( ) } ), - courseRouter = viewModel.courseRouter, fragmentManager = fragmentManager, onResetDatesClick = { viewModel.onRefresh(CourseContainerTab.DATES) @@ -454,7 +474,7 @@ fun DashboardPager( CourseContainerTab.VIDEOS -> { CourseVideosScreen( windowSize = windowSize, - courseVideoViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -462,14 +482,13 @@ fun DashboardPager( ) } ), - fragmentManager = fragmentManager, - courseRouter = viewModel.courseRouter, + fragmentManager = fragmentManager ) } CourseContainerTab.DATES -> { CourseDatesScreen( - courseDatesViewModel = koinViewModel( + viewModel = koinViewModel( parameters = { parametersOf( bundle.getString(CourseContainerFragment.ARG_COURSE_ID, ""), @@ -479,7 +498,6 @@ fun DashboardPager( } ), windowSize = windowSize, - courseRouter = viewModel.courseRouter, fragmentManager = fragmentManager, isFragmentResumed = isResumed, updateCourseStructure = { diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt similarity index 52% rename from core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt rename to course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt index 51d235c36..fbdbb60fc 100644 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseContainerTab.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerTab.kt @@ -1,4 +1,4 @@ -package org.openedx.core.presentation.course +package org.openedx.course.presentation.container import androidx.annotation.StringRes import androidx.compose.material.icons.Icons @@ -8,17 +8,17 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.ui.graphics.vector.ImageVector -import org.openedx.core.R import org.openedx.core.ui.TabItem +import org.openedx.course.R enum class CourseContainerTab( @StringRes override val labelResId: Int, - override val icon: ImageVector + override val icon: ImageVector, ) : TabItem { - HOME(R.string.core_course_container_nav_home, Icons.Default.Home), - VIDEOS(R.string.core_course_container_nav_videos, Icons.Rounded.PlayCircleFilled), - DATES(R.string.core_course_container_nav_dates, Icons.Outlined.CalendarMonth), - DISCUSSIONS(R.string.core_course_container_nav_discussions, Icons.AutoMirrored.Filled.Chat), - MORE(R.string.core_course_container_nav_more, Icons.AutoMirrored.Filled.TextSnippet) + 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), + 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 86d20bc3c..4e233e3d7 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 @@ -25,7 +25,6 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.CalendarManager @@ -37,8 +36,10 @@ import org.openedx.core.system.notifier.CourseCompletionSet 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.CourseRefresh +import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.RefreshDates +import org.openedx.core.system.notifier.RefreshDiscussions import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.data.storage.CoursePreferences @@ -56,6 +57,7 @@ import org.openedx.core.R as CoreR class CourseContainerViewModel( val courseId: String, var courseName: String, + private var resumeBlockId: String, private val enrollmentMode: String, private val config: Config, private val interactor: CourseInteractor, @@ -67,7 +69,7 @@ class CourseContainerViewModel( private val coursePreferences: CoursePreferences, private val courseAnalytics: CourseAnalytics, private val imageProcessor: ImageProcessor, - val courseRouter: CourseRouter + val courseRouter: CourseRouter, ) : BaseViewModel() { private val _dataReady = MutableLiveData() @@ -179,6 +181,10 @@ class CourseContainerViewModel( } isReady } + if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { + delay(500L) + courseNotifier.send(CourseOpenBlock(resumeBlockId)) + } } catch (e: Exception) { if (e.isInternetError() || e is NoCachedDataException) { _errorMessage.value = @@ -221,13 +227,13 @@ class CourseContainerViewModel( CourseContainerTab.DATES -> { viewModelScope.launch { - courseNotifier.send(CourseRefresh(courseContainerTab)) + courseNotifier.send(RefreshDates) } } CourseContainerTab.DISCUSSIONS -> { viewModelScope.launch { - courseNotifier.send(CourseRefresh(courseContainerTab)) + courseNotifier.send(RefreshDiscussions) } } @@ -265,7 +271,6 @@ class CourseContainerViewModel( } } - fun setCalendarSyncDialogType(dialogType: CalendarSyncDialogType) { val currentState = _calendarSyncUIState.value if (currentState.dialogType != dialogType) { diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt index 6e875d263..7381402b2 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesScreen.kt @@ -86,7 +86,6 @@ import org.openedx.core.ui.windowSizeValue import org.openedx.core.utils.TimeUtils import org.openedx.core.utils.clearTime import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import java.util.concurrent.atomic.AtomicReference @@ -95,50 +94,49 @@ import org.openedx.core.R as CoreR @Composable fun CourseDatesScreen( windowSize: WindowSize, - courseDatesViewModel: CourseDatesViewModel, - courseRouter: CourseRouter, + viewModel: CourseDatesViewModel, fragmentManager: FragmentManager, isFragmentResumed: Boolean, updateCourseStructure: () -> Unit ) { - val uiState by courseDatesViewModel.uiState.observeAsState(DatesUIState.Loading) - val uiMessage by courseDatesViewModel.uiMessage.collectAsState(null) - val calendarSyncUIState by courseDatesViewModel.calendarSyncUIState.collectAsState() + val uiState by viewModel.uiState.observeAsState(DatesUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val calendarSyncUIState by viewModel.calendarSyncUIState.collectAsState() val context = LocalContext.current CourseDatesUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - isSelfPaced = courseDatesViewModel.isSelfPaced, + isSelfPaced = viewModel.isSelfPaced, calendarSyncUIState = calendarSyncUIState, onItemClick = { block -> if (block.blockId.isNotEmpty()) { - courseDatesViewModel.getVerticalBlock(block.blockId) + viewModel.getVerticalBlock(block.blockId) ?.let { verticalBlock -> - courseDatesViewModel.logCourseComponentTapped(true, block) - if (courseDatesViewModel.isCourseExpandableSectionsEnabled) { - courseRouter.navigateToCourseContainer( + viewModel.logCourseComponentTapped(true, block) + if (viewModel.isCourseExpandableSectionsEnabled) { + viewModel.courseRouter.navigateToCourseContainer( fm = fragmentManager, - courseId = courseDatesViewModel.courseId, + courseId = viewModel.courseId, unitId = verticalBlock.id, componentId = "", mode = CourseViewMode.FULL ) } else { - courseDatesViewModel.getSequentialBlock(verticalBlock.id) + viewModel.getSequentialBlock(verticalBlock.id) ?.let { sequentialBlock -> - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, subSectionId = sequentialBlock.id, - courseId = courseDatesViewModel.courseId, + courseId = viewModel.courseId, unitId = verticalBlock.id, mode = CourseViewMode.FULL ) } } } ?: { - courseDatesViewModel.logCourseComponentTapped(false, block) + viewModel.logCourseComponentTapped(false, block) ActionDialogFragment.newInstance( title = context.getString(CoreR.string.core_leaving_the_app), message = context.getString( @@ -157,20 +155,20 @@ fun CourseDatesScreen( }, onPLSBannerViewed = { if (isFragmentResumed) { - courseDatesViewModel.logPlsBannerViewed() + viewModel.logPlsBannerViewed() } }, onSyncDates = { - courseDatesViewModel.logPlsShiftButtonClicked() - courseDatesViewModel.resetCourseDatesBanner { - courseDatesViewModel.logPlsShiftDates(it) + viewModel.logPlsShiftButtonClicked() + viewModel.resetCourseDatesBanner { + viewModel.logPlsShiftDates(it) if (it) { updateCourseStructure() } } }, onCalendarSyncSwitch = { isChecked -> - courseDatesViewModel.handleCalendarSyncState(isChecked) + viewModel.handleCalendarSyncState(isChecked) }, ) } diff --git a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt index c72728fea..247108ff9 100644 --- a/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/dates/CourseDatesViewModel.kt @@ -26,18 +26,18 @@ import org.openedx.core.extension.isInternetError import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.CalendarManager -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent 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.CourseRefresh +import org.openedx.core.system.notifier.RefreshDates import org.openedx.course.domain.interactor.CourseInteractor 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.core.R as CoreR class CourseDatesViewModel( @@ -51,6 +51,7 @@ class CourseDatesViewModel( private val corePreferences: CorePreferences, private val courseAnalytics: CourseAnalytics, private val config: Config, + val courseRouter: CourseRouter ) : BaseViewModel() { var isSelfPaced = true @@ -86,10 +87,8 @@ class CourseDatesViewModel( _calendarSyncUIState.update { it.copy(isSynced = event.isSynced) } } - is CourseRefresh -> { - if (event.courseContainerTab == CourseContainerTab.DATES) { - loadingCourseDatesInternal() - } + is RefreshDates -> { + loadingCourseDatesInternal() } } } diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index c050ff447..464fa163b 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -63,8 +64,8 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.FileUtil import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CourseDatesBanner import org.openedx.course.presentation.ui.CourseDatesBannerTablet import org.openedx.course.presentation.ui.CourseSection @@ -72,105 +73,98 @@ import org.openedx.course.presentation.ui.CourseExpandableChapterCard import org.openedx.course.presentation.ui.CourseMessage import org.openedx.course.presentation.ui.CourseSectionCard import org.openedx.course.presentation.ui.CourseSubSectionItem -import java.io.File import java.util.Date import org.openedx.core.R as CoreR @Composable fun CourseOutlineScreen( windowSize: WindowSize, - courseOutlineViewModel: CourseOutlineViewModel, - courseRouter: CourseRouter, + viewModel: CourseOutlineViewModel, fragmentManager: FragmentManager, onResetDatesClick: () -> Unit ) { - val uiState by courseOutlineViewModel.uiState.collectAsState() - val uiMessage by courseOutlineViewModel.uiMessage.collectAsState(null) + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + val resumeBlockId by viewModel.resumeBlockId.collectAsState("") val context = LocalContext.current + LaunchedEffect(resumeBlockId) { + if (resumeBlockId.isNotEmpty()) { + viewModel.openBlock(fragmentManager, resumeBlockId) + } + } + CourseOutlineUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, onExpandClick = { block -> - if (courseOutlineViewModel.switchCourseSections(block.id)) { - courseOutlineViewModel.sequentialClickedEvent( + if (viewModel.switchCourseSections(block.id)) { + viewModel.sequentialClickedEvent( block.blockId, block.displayName ) } }, onSubSectionClick = { subSectionBlock -> - if (courseOutlineViewModel.isCourseNestedListEnabled) { - courseOutlineViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseOutlineViewModel.logUnitDetailViewedEvent( + if (viewModel.isCourseNestedListEnabled) { + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.logUnitDetailViewedEvent( unit.blockId, unit.displayName ) - courseRouter.navigateToCourseContainer( + viewModel.courseRouter.navigateToCourseContainer( fragmentManager, - courseId = courseOutlineViewModel.courseId, + courseId = viewModel.courseId, unitId = unit.id, mode = CourseViewMode.FULL ) } } else { - courseOutlineViewModel.sequentialClickedEvent( + viewModel.sequentialClickedEvent( subSectionBlock.blockId, subSectionBlock.displayName ) - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, + courseId = viewModel.courseId, subSectionId = subSectionBlock.id, mode = CourseViewMode.FULL ) } }, onResumeClick = { componentId -> - courseOutlineViewModel.resumeSectionBlock?.let { subSection -> - courseOutlineViewModel.resumeCourseTappedEvent(subSection.id) - courseOutlineViewModel.resumeVerticalBlock?.let { unit -> - courseRouter.navigateToCourseContainer( - fm = fragmentManager, - courseId = courseOutlineViewModel.courseId, - unitId = unit.id, - componentId = componentId, - mode = CourseViewMode.FULL - ) - } - } + viewModel.openBlock( + fragmentManager, + componentId + ) }, onDownloadClick = { blocksIds -> blocksIds.forEach { blockId -> - if (courseOutlineViewModel.isBlockDownloading(blockId)) { - courseRouter.navigateToDownloadQueue( + if (viewModel.isBlockDownloading(blockId)) { + viewModel.courseRouter.navigateToDownloadQueue( fm = fragmentManager, - courseOutlineViewModel.getDownloadableChildren(blockId) + viewModel.getDownloadableChildren(blockId) ?: arrayListOf() ) - } else if (courseOutlineViewModel.isBlockDownloaded(blockId)) { - courseOutlineViewModel.removeDownloadModels(blockId) + } else if (viewModel.isBlockDownloaded(blockId)) { + viewModel.removeDownloadModels(blockId) } else { - courseOutlineViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(CoreR.string.app_name) - .replace(Regex("\\s"), "_"), blockId + viewModel.saveDownloadModels( + FileUtil.getExternalAppDir(context).path, blockId ) } } }, onResetDatesClick = { - courseOutlineViewModel.resetCourseDatesBanner( + viewModel.resetCourseDatesBanner( onResetDates = { onResetDatesClick() } ) }, onCertificateClick = { - courseOutlineViewModel.viewCertificateTappedEvent() + viewModel.viewCertificateTappedEvent() it.takeIfNotEmpty() ?.let { url -> AndroidUriHandler(context).openUri(url) } } 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 39bae146f..25436c3a6 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 @@ -1,5 +1,6 @@ package org.openedx.course.presentation.outline +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +26,7 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection @@ -32,11 +34,13 @@ import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEven 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.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor 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.R as courseR class CourseOutlineViewModel( @@ -49,6 +53,7 @@ class CourseOutlineViewModel( private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, private val analytics: CourseAnalytics, + val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -69,10 +74,14 @@ class CourseOutlineViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - var resumeSectionBlock: Block? = null - private set - var resumeVerticalBlock: Block? = null - private set + private val _resumeBlockId = MutableSharedFlow() + val resumeBlockId: SharedFlow + get() = _resumeBlockId.asSharedFlow() + + private var resumeSectionBlock: Block? = null + private var resumeVerticalBlock: Block? = null + + private val isCourseExpandableSectionsEnabled get() = config.isCourseDropdownNavigationEnabled() private val courseSubSections = mutableMapOf>() private val subSectionsDownloadsCount = mutableMapOf() @@ -87,6 +96,10 @@ class CourseOutlineViewModel( updateCourseData() } } + + is CourseOpenBlock -> { + _resumeBlockId.emit(event.blockId) + } } } } @@ -270,6 +283,41 @@ class CourseOutlineViewModel( } } + fun openBlock(fragmentManager: FragmentManager, blockId: String) { + viewModelScope.launch { + val courseStructure = interactor.getCourseStructure(courseId, false) + val blocks = courseStructure.blockData + getResumeBlock(blocks, blockId) + resumeBlock(fragmentManager, blockId) + } + } + + private fun resumeBlock(fragmentManager: FragmentManager, blockId: String) { + resumeSectionBlock?.let { subSection -> + resumeCourseTappedEvent(subSection.id) + resumeVerticalBlock?.let { unit -> + if (isCourseExpandableSectionsEnabled) { + courseRouter.navigateToCourseContainer( + fm = fragmentManager, + courseId = courseId, + unitId = unit.id, + componentId = blockId, + mode = CourseViewMode.FULL + ) + } else { + courseRouter.navigateToCourseSubsections( + fragmentManager, + courseId = courseId, + subSectionId = subSection.id, + mode = CourseViewMode.FULL, + unitId = unit.id, + componentId = blockId + ) + } + } + } + } + fun viewCertificateTappedEvent() { analytics.logEvent( CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, @@ -280,7 +328,7 @@ class CourseOutlineViewModel( ) } - fun resumeCourseTappedEvent(blockId: String) { + private fun resumeCourseTappedEvent(blockId: String) { val currentState = uiState.value if (currentState is CourseOutlineUIState.CourseData) { analytics.logEvent( diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index 2524832bd..0c83b264b 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -79,11 +79,14 @@ import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.FileUtil import org.openedx.course.R import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.ui.CardArrow import java.io.File import java.util.Date +import org.openedx.core.R as CoreR class CourseSectionFragment : Fragment() { @@ -135,11 +138,7 @@ class CourseSectionFragment : Fragment() { viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( - requireContext().externalCacheDir.toString() + - File.separator + - requireContext() - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), it.id + FileUtil(context).getExternalAppDir().path, it.id ) } } @@ -311,7 +310,7 @@ private fun CourseSubsectionItem( ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon + CoreR.drawable.ic_core_chapter_icon ) val completedIconColor = if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface 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 aae470213..fc3be04d4 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 @@ -130,7 +130,7 @@ fun CourseSectionCard( ) { val completedIconPainter = if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( - R.drawable.ic_course_chapter_icon + coreR.drawable.ic_core_chapter_icon ) val completedIconColor = if (block.isCompleted()) MaterialTheme.appColors.primary else MaterialTheme.appColors.onSurface 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 4acbfe070..12a11cd14 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 @@ -76,51 +76,49 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.FileUtil import org.openedx.course.R -import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.videos.CourseVideoViewModel import org.openedx.course.presentation.videos.CourseVideosUIState -import java.io.File import java.util.Date @Composable fun CourseVideosScreen( windowSize: WindowSize, - courseVideoViewModel: CourseVideoViewModel, - fragmentManager: FragmentManager, - courseRouter: CourseRouter + viewModel: CourseVideoViewModel, + fragmentManager: FragmentManager ) { - val uiState by courseVideoViewModel.uiState.collectAsState(CourseVideosUIState.Loading) - val uiMessage by courseVideoViewModel.uiMessage.collectAsState(null) - val videoSettings by courseVideoViewModel.videoSettings.collectAsState() + val uiState by viewModel.uiState.collectAsState(CourseVideosUIState.Loading) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val videoSettings by viewModel.videoSettings.collectAsState() val context = LocalContext.current CourseVideosUI( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, - courseTitle = courseVideoViewModel.courseTitle, + courseTitle = viewModel.courseTitle, videoSettings = videoSettings, onItemClick = { block -> - courseRouter.navigateToCourseSubsections( + viewModel.courseRouter.navigateToCourseSubsections( fm = fragmentManager, - courseId = courseVideoViewModel.courseId, + courseId = viewModel.courseId, subSectionId = block.id, mode = CourseViewMode.VIDEOS ) }, onExpandClick = { block -> - courseVideoViewModel.switchCourseSections(block.id) + viewModel.switchCourseSections(block.id) }, onSubSectionClick = { subSectionBlock -> - courseVideoViewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> - courseVideoViewModel.sequentialClickedEvent( + viewModel.courseSubSectionUnit[subSectionBlock.id]?.let { unit -> + viewModel.sequentialClickedEvent( unit.blockId, unit.displayName ) - courseRouter.navigateToCourseContainer( + viewModel.courseRouter.navigateToCourseContainer( fm = fragmentManager, - courseId = courseVideoViewModel.courseId, + courseId = viewModel.courseId, unitId = unit.id, mode = CourseViewMode.VIDEOS ) @@ -138,39 +136,31 @@ fun CourseVideosScreen( courseVideoViewModel.removeDownloadModels(blockId) } else { courseVideoViewModel.saveDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_"), blockId + FileUtil(context).getExternalAppDir().path, blockId ) } } }, onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> - courseVideoViewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) + viewModel.logBulkDownloadToggleEvent(!isAllBlocksDownloadedOrDownloading) if (isAllBlocksDownloadedOrDownloading) { - courseVideoViewModel.removeAllDownloadModels() + viewModel.removeAllDownloadModels() } else { - courseVideoViewModel.saveAllDownloadModels( - context.externalCacheDir.toString() + - File.separator + - context - .getString(org.openedx.core.R.string.app_name) - .replace(Regex("\\s"), "_") + viewModel.saveAllDownloadModels( + FileUtil(context).getExternalAppDir().path ) } }, onDownloadQueueClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseRouter.navigateToDownloadQueue(fm = fragmentManager) + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.courseRouter.navigateToDownloadQueue(fm = fragmentManager) } }, onVideoDownloadQualityClick = { - if (courseVideoViewModel.hasDownloadModelsInQueue()) { - courseVideoViewModel.onChangingVideoQualityWhileDownloading() + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.onChangingVideoQualityWhileDownloading() } else { - courseRouter.navigateToVideoQuality( + viewModel.courseRouter.navigateToVideoQuality( fragmentManager, VideoQualityType.Download ) 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 19940fb2c..067e20fea 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 @@ -28,6 +28,7 @@ import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseRouter class CourseVideoViewModel( val courseId: String, @@ -40,6 +41,7 @@ class CourseVideoViewModel( private val courseNotifier: CourseNotifier, private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, + val courseRouter: CourseRouter, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController diff --git a/course/src/main/res/drawable/ic_course_chapter_icon.xml b/course/src/main/res/drawable/ic_course_chapter_icon.xml deleted file mode 100644 index eaf899ce2..000000000 --- a/course/src/main/res/drawable/ic_course_chapter_icon.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 337a63038..adbd7bbb6 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -41,6 +41,12 @@ Course dates are not currently available. + Home + Videos + Discussions + More + Dates + Video player Remove course section diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index b32457e4b..938d850d2 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -156,6 +156,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -189,6 +190,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -222,6 +224,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -254,6 +257,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -289,6 +293,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -319,6 +324,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, @@ -349,6 +355,7 @@ class CourseContainerViewModelTest { "", "", "", + "", config, interactor, calendarManager, diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index ac46234d4..11ffb4932 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -44,6 +44,7 @@ import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseRouter import java.net.UnknownHostException import java.util.Date @@ -61,6 +62,7 @@ class CourseDatesViewModelTest { private val corePreferences = mockk() private val analytics = mockk() private val config = mockk() + private val courseRouter = mockk() private val openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -171,7 +173,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } throws UnknownHostException() val message = async { @@ -199,7 +202,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } throws Exception() val message = async { @@ -227,7 +231,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult val message = async { @@ -255,7 +260,8 @@ class CourseDatesViewModelTest { resourceManager, corePreferences, analytics, - config + config, + courseRouter ) coEvery { interactor.getCourseDates(any()) } returns CourseDatesResult( datesSection = linkedMapOf(), 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 d99138499..d3c26a4ca 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 @@ -57,6 +57,7 @@ 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 java.net.UnknownHostException import java.util.Date @@ -78,6 +79,7 @@ class CourseOutlineViewModelTest { private val workerController = mockk() private val analytics = mockk() private val coreAnalytics = mockk() + private val courseRouter = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -247,6 +249,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController, @@ -281,6 +284,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -325,6 +329,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -372,6 +377,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -418,6 +424,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -452,6 +459,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -500,6 +508,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -543,6 +552,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -578,6 +588,7 @@ class CourseOutlineViewModelTest { networkConnection, preferencesManager, analytics, + courseRouter, coreAnalytics, downloadDao, workerController 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 aa403c893..25774f555 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 @@ -54,6 +54,7 @@ import org.openedx.core.system.notifier.VideoNotifier 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 java.util.Date @OptIn(ExperimentalCoroutinesApi::class) @@ -74,6 +75,7 @@ class CourseVideoViewModelTest { private val networkConnection = mockk() private val downloadDao = mockk() private val workerController = mockk() + private val courseRouter = mockk() private val cantDownload = "You can download content only from Wi-fi" @@ -211,9 +213,10 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, - workerController + workerController, ) viewModel.getVideos() @@ -242,6 +245,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -281,6 +285,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -322,6 +327,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -358,6 +364,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController @@ -398,6 +405,7 @@ class CourseVideoViewModelTest { courseNotifier, videoNotifier, analytics, + courseRouter, coreAnalytics, downloadDao, workerController diff --git a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt index dbf15acd4..f3b6a5aee 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -68,7 +68,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenLoading() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), @@ -101,7 +101,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenLoaded() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), @@ -127,7 +127,7 @@ class MyCoursesScreenTest { @Test fun dashboardScreenRefreshing() { composeTestRule.setContent { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), diff --git a/dashboard/src/main/java/org/openedx/DashboardNavigator.kt b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt new file mode 100644 index 000000000..9e5f4c900 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/DashboardNavigator.kt @@ -0,0 +1,17 @@ +package org.openedx + +import androidx.fragment.app.Fragment +import org.openedx.core.config.DashboardConfig +import org.openedx.dashboard.presentation.DashboardListFragment +import org.openedx.learn.presentation.LearnFragment + +class DashboardNavigator( + private val dashboardType: DashboardConfig.DashboardType, +) { + fun getDashboardFragment(): Fragment { + return when (dashboardType) { + DashboardConfig.DashboardType.GALLERY -> LearnFragment() + else -> DashboardListFragment() + } + } +} diff --git a/dashboard/src/main/java/org/openedx/DashboardUI.kt b/dashboard/src/main/java/org/openedx/DashboardUI.kt new file mode 100644 index 000000000..13a3f42d1 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/DashboardUI.kt @@ -0,0 +1,49 @@ +package org.openedx + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun Lock(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize() + ) { + Icon( + modifier = Modifier + .size(32.dp) + .padding(top = 8.dp, end = 8.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(0.5f), + shape = CircleShape + ) + .padding(4.dp) + .align(Alignment.TopEnd), + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + +@Preview +@Composable +private fun LockPreview() { + OpenEdXTheme { + Lock() + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt new file mode 100644 index 000000000..7655fd6a2 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesAction.kt @@ -0,0 +1,14 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.dashboard.domain.CourseStatusFilter + +interface AllEnrolledCoursesAction { + object Reload : AllEnrolledCoursesAction + object SwipeRefresh : AllEnrolledCoursesAction + object EndOfPage : AllEnrolledCoursesAction + object Back : AllEnrolledCoursesAction + object Search : AllEnrolledCoursesAction + data class OpenCourse(val enrolledCourse: EnrolledCourse) : AllEnrolledCoursesAction + data class FilterChange(val courseStatusFilter: CourseStatusFilter?) : AllEnrolledCoursesAction +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt new file mode 100644 index 000000000..e59a73fde --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesFragment.kt @@ -0,0 +1,27 @@ +package org.openedx.courses.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.openedx.core.ui.theme.OpenEdXTheme + +class AllEnrolledCoursesFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + AllEnrolledCoursesView( + fragmentManager = requireActivity().supportFragmentManager + ) + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt new file mode 100644 index 000000000..2d7efb51b --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesUIState.kt @@ -0,0 +1,10 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse + +data class AllEnrolledCoursesUIState( + val courses: List? = null, + val refreshing: Boolean = false, + val canLoadMore: Boolean = false, + val showProgress: Boolean = false, +) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt new file mode 100644 index 000000000..3392ed7bd --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -0,0 +1,639 @@ +package org.openedx.courses.presentation + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +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.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +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.rememberLazyListState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +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.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +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 +import androidx.fragment.app.FragmentManager +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.koin.androidx.compose.koinViewModel +import org.openedx.Lock +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.RoundTabsBar +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.shouldLoadMore +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.core.utils.TimeUtils +import org.openedx.dashboard.domain.CourseStatusFilter +import java.util.Date + +@Composable +fun AllEnrolledCoursesView( + fragmentManager: FragmentManager +) { + val viewModel: AllEnrolledCoursesViewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + + AllEnrolledCoursesView( + apiHostUrl = viewModel.apiHostUrl, + state = uiState, + uiMessage = uiMessage, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + AllEnrolledCoursesAction.Reload -> { + viewModel.getCourses() + } + + AllEnrolledCoursesAction.SwipeRefresh -> { + viewModel.updateCourses() + } + + AllEnrolledCoursesAction.EndOfPage -> { + viewModel.fetchMore() + } + + AllEnrolledCoursesAction.Back -> { + fragmentManager.popBackStack() + } + + AllEnrolledCoursesAction.Search -> { + viewModel.navigateToCourseSearch(fragmentManager) + } + + is AllEnrolledCoursesAction.OpenCourse -> { + with(action.enrolledCourse) { + viewModel.navigateToCourseOutline( + fragmentManager, + course.id, + course.name, + mode + ) + } + } + + is AllEnrolledCoursesAction.FilterChange -> { + viewModel.getCourses(action.courseStatusFilter) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +private fun AllEnrolledCoursesView( + apiHostUrl: String, + state: AllEnrolledCoursesUIState, + uiMessage: UIMessage?, + hasInternetConnection: Boolean, + onAction: (AllEnrolledCoursesAction) -> Unit +) { + val windowSize = rememberWindowSize() + val layoutDirection = LocalLayoutDirection.current + val scaffoldState = rememberScaffoldState() + val scrollState = rememberLazyGridState() + val columns = if (windowSize.isTablet) 3 else 2 + val pullRefreshState = rememberPullRefreshState( + refreshing = state.refreshing, + onRefresh = { onAction(AllEnrolledCoursesAction.SwipeRefresh) } + ) + val tabPagerState = rememberPagerState(pageCount = { + CourseStatusFilter.entries.size + }) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + val firstVisibleIndex = remember { + mutableIntStateOf(scrollState.firstVisibleItemIndex) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize() + .semantics { + testTagsAsResourceId = true + }, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + val contentPaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues( + top = 16.dp, + bottom = 40.dp, + ), + compact = PaddingValues(horizontal = 16.dp, vertical = 16.dp) + ) + ) + } + + val roundTapBarPaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(vertical = 6.dp), + compact = PaddingValues(horizontal = 16.dp, vertical = 6.dp) + ) + ) + } + + + val emptyStatePaddings by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding( + top = 32.dp, + bottom = 40.dp + ), + compact = Modifier.padding(horizontal = 24.dp, vertical = 24.dp) + ) + ) + } + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.TopCenter + ) { + Column( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape() + .then(contentWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BackBtn( + modifier = Modifier.align(Alignment.Start), + tint = MaterialTheme.appColors.textDark + ) { + onAction(AllEnrolledCoursesAction.Back) + } + + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .pullRefresh(pullRefreshState), + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Header( + modifier = Modifier + .padding( + start = contentPaddings.calculateStartPadding(layoutDirection), + end = contentPaddings.calculateEndPadding(layoutDirection) + ), + onSearchClick = { + onAction(AllEnrolledCoursesAction.Search) + } + ) + RoundTabsBar( + modifier = Modifier.align(Alignment.Start), + items = CourseStatusFilter.entries, + contentPadding = roundTapBarPaddings, + rowState = rememberLazyListState(), + pagerState = tabPagerState, + onTabClicked = { + val newFilter = CourseStatusFilter.entries[it] + onAction(AllEnrolledCoursesAction.FilterChange(newFilter)) + } + ) + when { + state.showProgress -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + !state.courses.isNullOrEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPaddings), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LazyVerticalGrid( + modifier = Modifier + .fillMaxHeight(), + state = scrollState, + columns = GridCells.Fixed(columns), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + content = { + items(state.courses) { course -> + CourseItem( + course = course, + apiHostUrl = apiHostUrl, + onClick = { + onAction(AllEnrolledCoursesAction.OpenCourse(it)) + } + ) + } + item { + if (state.canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.appColors.primary + ) + } + } + } + } + ) + } + if (scrollState.shouldLoadMore(firstVisibleIndex, 4)) { + onAction(AllEnrolledCoursesAction.EndOfPage) + } + } + } + + state.courses?.isEmpty() == true -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .then(emptyStatePaddings) + ) { + EmptyState( + currentCourseStatus = CourseStatusFilter.entries[tabPagerState.currentPage] + ) + } + } + } + } + } + PullRefreshIndicator( + state.refreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(AllEnrolledCoursesAction.Reload) + } + ) + } + } + } + } + } + } +} + +@Composable +fun CourseItem( + modifier: Modifier = Modifier, + course: EnrolledCourse, + apiHostUrl: String, + onClick: (EnrolledCourse) -> Unit, +) { + Card( + modifier = modifier + .width(170.dp) + .height(180.dp) + .clickable { + onClick(course) + }, + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Box { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(apiHostUrl + course.course.courseImage) + .error(R.drawable.core_no_image_course) + .placeholder(R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(90.dp) + ) + val progress: Float = try { + course.progress.assignmentsCompleted.toFloat() / course.progress.totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 4.dp), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2, + text = stringResource( + org.openedx.dashboard.R.string.dashboard_course_date, + TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + course.auditAccessExpires, + course.course.start, + course.course.end, + course.course.startType, + course.course.startDisplay + ) + ) + ) + Text( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp), + text = course.course.name, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + } + if (!course.course.coursewareAccess?.errorCode.isNullOrEmpty()) { + Lock() + } + } + } +} + +@Composable +fun Header( + modifier: Modifier = Modifier, + onSearchClick: () -> Unit +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier.align(Alignment.CenterStart), + text = stringResource(id = org.openedx.dashboard.R.string.dashboard_all_courses), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 12.dp), + onClick = { + onSearchClick() + } + ) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +fun EmptyState( + currentCourseStatus: CourseStatusFilter +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = org.openedx.dashboard.R.drawable.dashboard_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 = org.openedx.dashboard.R.string.dashboard_no_status_courses, + stringResource(currentCourseStatus.labelResId) + ), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + course = mockCourseEnrolled, + apiHostUrl = "", + onClick = {} + ) + } +} + +@Preview +@Composable +private fun EmptyStatePreview() { + OpenEdXTheme { + EmptyState( + currentCourseStatus = CourseStatusFilter.COMPLETE + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun AllEnrolledCoursesPreview() { + OpenEdXTheme { + AllEnrolledCoursesView( + apiHostUrl = "http://localhost:8000", + state = AllEnrolledCoursesUIState( + courses = listOf( + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled, + mockCourseEnrolled + ) + ), + uiMessage = null, + hasInternetConnection = true, + onAction = {} + ) + } +} + +private val mockCourseAssignments = CourseAssignments(null, emptyList()) +private val mockCourseEnrolled = EnrolledCourse( + auditAccessExpires = Date(), + created = "created", + certificate = Certificate(""), + mode = "mode", + isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", ""), + courseAssignments = mockCourseAssignments, + course = EnrolledCourseData( + id = "id", + name = "name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + dynamicUpgradeDeadline = "", + subscriptionId = "", + coursewareAccess = CoursewareAccess( + false, + "204", + "", + "", + "", + "" + ), + media = null, + courseImage = "", + courseAbout = "", + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + videoOutline = "", + isSelfPaced = false + ) +) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt new file mode 100644 index 000000000..6f3f96ebf --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -0,0 +1,181 @@ +package org.openedx.courses.presentation + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.dashboard.domain.CourseStatusFilter +import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dashboard.presentation.DashboardRouter + +class AllEnrolledCoursesViewModel( + private val config: Config, + private val networkConnection: NetworkConnection, + private val interactor: DashboardInteractor, + private val resourceManager: ResourceManager, + private val discoveryNotifier: DiscoveryNotifier, + private val analytics: DashboardAnalytics, + private val dashboardRouter: DashboardRouter +) : BaseViewModel() { + + val apiHostUrl get() = config.getApiHostURL() + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + private val coursesList = mutableListOf() + private var page = 1 + private var isLoading = false + + private val _uiState = MutableStateFlow(AllEnrolledCoursesUIState()) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val currentFilter: MutableStateFlow = MutableStateFlow(CourseStatusFilter.ALL) + + private var job: Job? = null + + init { + collectDiscoveryNotifier() + getCourses(currentFilter.value) + } + + fun getCourses(courseStatusFilter: CourseStatusFilter? = null) { + _uiState.update { it.copy(showProgress = true) } + coursesList.clear() + internalLoadingCourses(courseStatusFilter ?: currentFilter.value) + } + + fun updateCourses() { + viewModelScope.launch { + try { + _uiState.update { it.copy(refreshing = true) } + isLoading = true + page = 1 + val response = interactor.getAllUserCourses(page, currentFilter.value) + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _uiState.update { it.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + } + coursesList.clear() + coursesList.addAll(response.courses) + _uiState.update { it.copy(courses = coursesList) } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } + _uiState.update { it.copy(refreshing = false, showProgress = false) } + isLoading = false + } + } + + private fun internalLoadingCourses(courseStatusFilter: CourseStatusFilter? = null) { + if (courseStatusFilter != null) { + page = 1 + currentFilter.value = courseStatusFilter + } + job?.cancel() + job = viewModelScope.launch { + try { + isLoading = true + val response = if (networkConnection.isOnline() || page > 1) { + interactor.getAllUserCourses(page, currentFilter.value) + } else { + null + } + if (response != null) { + if (response.pagination.next.isNotEmpty() && page != response.pagination.numPages) { + _uiState.update { it.copy(canLoadMore = true) } + page++ + } else { + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + } + coursesList.addAll(response.courses) + } else { + val cachedList = interactor.getEnrolledCoursesFromCache() + _uiState.update { it.copy(canLoadMore = false) } + page = -1 + coursesList.addAll(cachedList) + } + _uiState.update { it.copy(courses = coursesList) } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } + _uiState.update { it.copy(refreshing = false, showProgress = false) } + isLoading = false + } + } + + fun fetchMore() { + if (!isLoading && page != -1) { + internalLoadingCourses() + } + } + + private fun dashboardCourseClickedEvent(courseId: String, courseName: String) { + analytics.dashboardCourseClickedEvent(courseId, courseName) + } + + private fun collectDiscoveryNotifier() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { + if (it is CourseDashboardUpdate) { + updateCourses() + } + } + } + } + + fun navigateToCourseSearch(fragmentManager: FragmentManager) { + dashboardRouter.navigateToCourseSearch( + fragmentManager, "" + ) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + courseId: String, + courseName: String, + mode: String + ) { + dashboardCourseClickedEvent(courseId, courseName) + dashboardRouter.navigateToCourseOutline( + fragmentManager, + courseId, + courseName, + mode + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt new file mode 100644 index 000000000..f0da7c186 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/CourseTab.kt @@ -0,0 +1,5 @@ +package org.openedx.courses.presentation + +enum class CourseTab { + HOME, VIDEOS, DATES, DISCUSSIONS, MORE +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt new file mode 100644 index 000000000..b0309785c --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryFragment.kt @@ -0,0 +1,24 @@ +package org.openedx.courses.presentation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.openedx.core.ui.theme.OpenEdXTheme + +class DashboardGalleryFragment : Fragment() { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + DashboardGalleryView(fragmentManager = requireActivity().supportFragmentManager) + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt new file mode 100644 index 000000000..f612a5289 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryScreenAction.kt @@ -0,0 +1,13 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.EnrolledCourse + +interface DashboardGalleryScreenAction { + object SwipeRefresh : DashboardGalleryScreenAction + object ViewAll : DashboardGalleryScreenAction + object Reload : DashboardGalleryScreenAction + object NavigateToDiscovery : DashboardGalleryScreenAction + data class OpenBlock(val enrolledCourse: EnrolledCourse, val blockId: String) : DashboardGalleryScreenAction + data class OpenCourse(val enrolledCourse: EnrolledCourse) : DashboardGalleryScreenAction + data class NavigateToDates(val enrolledCourse: EnrolledCourse) : DashboardGalleryScreenAction +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt new file mode 100644 index 000000000..c4049f463 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryUIState.kt @@ -0,0 +1,9 @@ +package org.openedx.courses.presentation + +import org.openedx.core.domain.model.CourseEnrollments + +sealed class DashboardGalleryUIState { + data class Courses(val userCourses: CourseEnrollments) : DashboardGalleryUIState() + data object Empty : DashboardGalleryUIState() + data object Loading : DashboardGalleryUIState() +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt new file mode 100644 index 000000000..c4ea029b9 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -0,0 +1,863 @@ +package org.openedx.courses.presentation + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.koin.androidx.compose.koinViewModel +import org.openedx.Lock +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.CourseDatesCalendarSync +import org.openedx.core.domain.model.CourseEnrollments +import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DashboardCourseList +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Pagination +import org.openedx.core.domain.model.Progress +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils +import org.openedx.dashboard.R +import java.util.Date +import org.openedx.core.R as CoreR + +@Composable +fun DashboardGalleryView( + fragmentManager: FragmentManager, +) { + val viewModel: DashboardGalleryViewModel = koinViewModel() + val updating by viewModel.updating.collectAsState(false) + val uiMessage by viewModel.uiMessage.collectAsState(null) + val uiState by viewModel.uiState.collectAsState(DashboardGalleryUIState.Loading) + + DashboardGalleryView( + uiMessage = uiMessage, + uiState = uiState, + updating = updating, + apiHostUrl = viewModel.apiHostUrl, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DashboardGalleryScreenAction.SwipeRefresh -> { + viewModel.updateCourses() + } + + DashboardGalleryScreenAction.ViewAll -> { + viewModel.navigateToAllEnrolledCourses(fragmentManager) + } + + DashboardGalleryScreenAction.Reload -> { + viewModel.getCourses() + } + + DashboardGalleryScreenAction.NavigateToDiscovery -> { + viewModel.navigateToDiscovery() + } + + is DashboardGalleryScreenAction.OpenCourse -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse + ) + } + + is DashboardGalleryScreenAction.NavigateToDates -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse, + openDates = true + ) + } + + is DashboardGalleryScreenAction.OpenBlock -> { + viewModel.navigateToCourseOutline( + fragmentManager = fragmentManager, + enrolledCourse = action.enrolledCourse, + resumeBlockId = action.blockId + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun DashboardGalleryView( + uiMessage: UIMessage?, + uiState: DashboardGalleryUIState, + updating: Boolean, + apiHostUrl: String, + onAction: (DashboardGalleryScreenAction) -> Unit, + hasInternetConnection: Boolean +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = rememberPullRefreshState( + refreshing = updating, + onRefresh = { onAction(DashboardGalleryScreenAction.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + color = MaterialTheme.appColors.background + ) { + Box( + Modifier.fillMaxSize() + ) { + Box( + Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState()), + ) { + when (uiState) { + is DashboardGalleryUIState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.appColors.primary + ) + } + + is DashboardGalleryUIState.Courses -> { + UserCourses( + modifier = Modifier.fillMaxSize(), + userCourses = uiState.userCourses, + apiHostUrl = apiHostUrl, + openCourse = { + onAction(DashboardGalleryScreenAction.OpenCourse(it)) + }, + onViewAllClick = { + onAction(DashboardGalleryScreenAction.ViewAll) + }, + navigateToDates = { + onAction(DashboardGalleryScreenAction.NavigateToDates(it)) + }, + resumeBlockId = { course, blockId -> + onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) + } + ) + } + + is DashboardGalleryUIState.Empty -> { + NoCoursesInfo( + modifier = Modifier + .align(Alignment.Center) + ) + FindACourseButton( + modifier = Modifier + .align(Alignment.BottomCenter), + findACourseClick = { + onAction(DashboardGalleryScreenAction.NavigateToDiscovery) + } + ) + } + } + + PullRefreshIndicator( + updating, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + } + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DashboardGalleryScreenAction.SwipeRefresh) + } + ) + } + } + } + } +} + +@Composable +private fun UserCourses( + modifier: Modifier = Modifier, + userCourses: CourseEnrollments, + apiHostUrl: String, + openCourse: (EnrolledCourse) -> Unit, + navigateToDates: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, +) { + Column( + modifier = modifier + .padding(vertical = 12.dp) + ) { + val primaryCourse = userCourses.primary + if (primaryCourse != null) { + PrimaryCourseCard( + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse + ) + } + if (userCourses.enrollments.courses.isNotEmpty()) { + SecondaryCourses( + courses = userCourses.enrollments.courses, + apiHostUrl = apiHostUrl, + onCourseClick = openCourse, + onViewAllClick = onViewAllClick + ) + } + } +} + +@Composable +private fun SecondaryCourses( + courses: List, + apiHostUrl: String, + onCourseClick: (EnrolledCourse) -> Unit, + onViewAllClick: () -> Unit +) { + val windowSize = rememberWindowSize() + val itemsCount = if (windowSize.isTablet) 7 else 5 + val rows = if (windowSize.isTablet) 2 else 1 + val height = if (windowSize.isTablet) 322.dp else 152.dp + val items = courses.take(itemsCount) + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextIcon( + modifier = Modifier.padding(horizontal = 18.dp), + text = stringResource(R.string.dashboard_view_all_with_count, courses.size + 1), + textStyle = MaterialTheme.appTypography.titleSmall, + icon = Icons.Default.ChevronRight, + color = MaterialTheme.appColors.textDark, + iconModifier = Modifier.size(22.dp), + onClick = onViewAllClick + ) + LazyHorizontalGrid( + modifier = Modifier + .fillMaxSize() + .height(height), + rows = GridCells.Fixed(rows), + contentPadding = PaddingValues(horizontal = 18.dp), + content = { + items(items) { + CourseListItem( + course = it, + apiHostUrl = apiHostUrl, + onCourseClick = onCourseClick + ) + } + item { + ViewAllItem( + onViewAllClick = onViewAllClick + ) + } + } + ) + } +} + +@Composable +private fun ViewAllItem( + onViewAllClick: () -> Unit +) { + Card( + modifier = Modifier + .width(140.dp) + .height(152.dp) + .padding(4.dp) + .clickable( + onClickLabel = stringResource(id = R.string.dashboard_view_all), + onClick = { + onViewAllClick() + } + ), + backgroundColor = MaterialTheme.appColors.cardViewBackground, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + modifier = Modifier.size(48.dp), + painter = painterResource(id = R.drawable.dashboard_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.dashboard_view_all), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark + ) + } + } +} + +@Composable +private fun CourseListItem( + course: EnrolledCourse, + apiHostUrl: String, + onCourseClick: (EnrolledCourse) -> Unit, +) { + Card( + modifier = Modifier + .width(140.dp) + .height(152.dp) + .padding(4.dp) + .clickable { + onCourseClick(course) + }, + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Box { + Column { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(apiHostUrl + course.course.courseImage) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(90.dp) + ) + Text( + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 8.dp), + text = course.course.name, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + minLines = 2 + ) + } + if (!course.course.coursewareAccess?.errorCode.isNullOrEmpty()) { + Lock() + } + } + } +} + +@Composable +private fun AssignmentItem( + modifier: Modifier = Modifier, + painter: Painter, + title: String?, + info: String +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 62.dp) + .padding(vertical = 12.dp, horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painter, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val infoTextStyle = if (title.isNullOrEmpty()) { + MaterialTheme.appTypography.titleSmall + } else { + MaterialTheme.appTypography.labelSmall + } + Text( + text = info, + color = MaterialTheme.appColors.textDark, + style = infoTextStyle + ) + if (!title.isNullOrEmpty()) { + Text( + text = title, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseCard( + primaryCourse: EnrolledCourse, + apiHostUrl: String, + navigateToDates: (EnrolledCourse) -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + openCourse: (EnrolledCourse) -> Unit, +) { + val context = LocalContext.current + Card( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .padding(2.dp), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp + ) { + Column( + modifier = Modifier + .clickable { + openCourse(primaryCourse) + } + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(apiHostUrl + primaryCourse.course.courseImage) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(140.dp) + ) + val progress: Float = try { + primaryCourse.progress.assignmentsCompleted.toFloat() / primaryCourse.progress.totalAssignmentsCount.toFloat() + } catch (_: ArithmeticException) { + 0f + } + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = progress, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) + PrimaryCourseTitle( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 16.dp), + primaryCourse = primaryCourse + ) + val pastAssignments = primaryCourse.courseAssignments?.pastAssignments + if (!pastAssignments.isNullOrEmpty()) { + val nearestAssignment = pastAssignments.maxBy { it.date } + val title = if (pastAssignments.size == 1) nearestAssignment.title else null + Divider() + AssignmentItem( + modifier = Modifier.clickable { + if (pastAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) + } else { + navigateToDates(primaryCourse) + } + }, + painter = rememberVectorPainter(Icons.Default.Warning), + title = title, + info = stringResource(R.string.dashboard_past_due_assignment, pastAssignments.size) + ) + } + val futureAssignments = primaryCourse.courseAssignments?.futureAssignments + if (!futureAssignments.isNullOrEmpty()) { + val nearestAssignment = futureAssignments.minBy { it.date } + val title = if (futureAssignments.size == 1) nearestAssignment.title else null + Divider() + AssignmentItem( + modifier = Modifier.clickable { + if (futureAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) + } else { + navigateToDates(primaryCourse) + } + }, + painter = painterResource(id = CoreR.drawable.ic_core_chapter_icon), + title = title, + info = stringResource( + R.string.dashboard_assignment_due_in_days, + nearestAssignment.assignmentType ?: "", + TimeUtils.getCourseFormattedDate(context, nearestAssignment.date) + ) + ) + } + ResumeButton( + primaryCourse = primaryCourse, + onClick = { + if (primaryCourse.courseStatus == null) { + openCourse(primaryCourse) + } else { + resumeBlockId(primaryCourse, primaryCourse.courseStatus?.lastVisitedBlockId ?: "") + } + } + ) + } + } +} + +@Composable +private fun ResumeButton( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + onClick: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .heightIn(min = 60.dp) + .background(MaterialTheme.appColors.primary) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (primaryCourse.courseStatus == null) { + Icon( + imageVector = Icons.Default.School, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.dashboard_start_course), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleSmall + ) + } else { + Icon( + imageVector = Icons.Default.School, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.dashboard_resume_course), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelSmall + ) + Text( + text = primaryCourse.courseStatus?.lastVisitedUnitDisplayName ?: "", + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.titleSmall + ) + } + } + Icon( + modifier = Modifier.size(16.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + tint = MaterialTheme.appColors.primaryButtonText, + contentDescription = null + ) + } +} + +@Composable +private fun PrimaryCourseTitle( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = primaryCourse.course.org, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = primaryCourse.course.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + maxLines = 3 + ) + Text( + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textFieldHint, + text = stringResource( + R.string.dashboard_course_date, + TimeUtils.getCourseFormattedDate( + LocalContext.current, + Date(), + primaryCourse.auditAccessExpires, + primaryCourse.course.start, + primaryCourse.course.end, + primaryCourse.course.startType, + primaryCourse.course.startDisplay + ) + ) + ) + } +} + +@Composable +private fun FindACourseButton( + modifier: Modifier = Modifier, + findACourseClick: () -> Unit +) { + OpenEdXButton( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 20.dp), + onClick = { + findACourseClick() + } + ) { + Text( + color = MaterialTheme.appColors.primaryButtonText, + text = stringResource(id = R.string.dashboard_find_a_course) + ) + } +} + +@Composable +private fun NoCoursesInfo( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = R.drawable.dashboard_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.dashboard_all_courses_empty_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.dashboard_all_courses_empty_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +private val mockCourseDateBlock = CourseDateBlock( + title = "Homework 1: ABCD", + description = "After this date, course content will be archived", + date = TimeUtils.iso8601ToDate("2023-10-20T15:08:07Z")!!, + assignmentType = "Homework" +) +private val mockCourseAssignments = + CourseAssignments(listOf(mockCourseDateBlock), listOf(mockCourseDateBlock, mockCourseDateBlock)) +private val mockCourse = EnrolledCourse( + auditAccessExpires = Date(), + created = "created", + certificate = Certificate(""), + mode = "mode", + isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", "Unit name"), + courseAssignments = mockCourseAssignments, + course = EnrolledCourseData( + id = "id", + name = "Looooooooooooooooooooong Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + dynamicUpgradeDeadline = "", + subscriptionId = "", + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "", + ), + media = null, + courseImage = "", + courseAbout = "", + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseUpdates = "", + courseHandouts = "", + discussionUrl = "", + videoOutline = "", + isSelfPaced = false + ) +) +private val mockPagination = Pagination(10, "", 4, "1") +private val mockDashboardCourseList = DashboardCourseList( + pagination = mockPagination, + courses = listOf(mockCourse, mockCourse, mockCourse, mockCourse, mockCourse, mockCourse) +) + +private val mockUserCourses = CourseEnrollments( + enrollments = mockDashboardCourseList, + configs = AppConfig(CourseDatesCalendarSync(true, true, true, true)), + primary = mockCourse +) + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ViewAllItemPreview() { + OpenEdXTheme { + ViewAllItem( + onViewAllClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun DashboardGalleryViewPreview() { + OpenEdXTheme { + DashboardGalleryView( + uiState = DashboardGalleryUIState.Courses(mockUserCourses), + apiHostUrl = "", + uiMessage = null, + updating = false, + hasInternetConnection = false, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun NoCoursesInfoPreview() { + OpenEdXTheme { + NoCoursesInfo() + } +} diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt new file mode 100644 index 000000000..6ff7ba3fd --- /dev/null +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -0,0 +1,130 @@ +package org.openedx.courses.presentation + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.config.Config +import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.extension.isInternetError +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.core.utils.FileUtil +import org.openedx.dashboard.domain.interactor.DashboardInteractor +import org.openedx.dashboard.presentation.DashboardRouter + +class DashboardGalleryViewModel( + private val config: Config, + private val interactor: DashboardInteractor, + private val resourceManager: ResourceManager, + private val discoveryNotifier: DiscoveryNotifier, + private val networkConnection: NetworkConnection, + private val fileUtil: FileUtil, + private val dashboardRouter: DashboardRouter, +) : BaseViewModel() { + + val apiHostUrl get() = config.getApiHostURL() + + private val _uiState = + MutableStateFlow(DashboardGalleryUIState.Loading) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + private val _updating = MutableStateFlow(false) + val updating: StateFlow + get() = _updating.asStateFlow() + + val hasInternetConnection: Boolean + get() = networkConnection.isOnline() + + init { + collectDiscoveryNotifier() + getCourses() + } + + fun getCourses() { + viewModelScope.launch { + try { + if (networkConnection.isOnline()) { + val response = interactor.getMainUserCourses() + if (response.primary == null && response.enrollments.courses.isEmpty()) { + _uiState.value = DashboardGalleryUIState.Empty + } else { + _uiState.value = DashboardGalleryUIState.Courses(response) + } + } else { + val courseEnrollments = fileUtil.getObjectFromFile() + if (courseEnrollments == null) { + _uiState.value = DashboardGalleryUIState.Empty + } else { + _uiState.value = + DashboardGalleryUIState.Courses(courseEnrollments.mapToDomain()) + } + } + } catch (e: Exception) { + if (e.isInternetError()) { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_no_connection))) + } else { + _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error))) + } + } finally { + _updating.value = false + } + } + } + + fun updateCourses() { + _updating.value = true + getCourses() + } + + fun navigateToDiscovery() { + viewModelScope.launch { discoveryNotifier.send(NavigationToDiscovery()) } + } + + fun navigateToAllEnrolledCourses(fragmentManager: FragmentManager) { + dashboardRouter.navigateToAllEnrolledCourses(fragmentManager) + } + + fun navigateToCourseOutline( + fragmentManager: FragmentManager, + enrolledCourse: EnrolledCourse, + openDates: Boolean = false, + resumeBlockId: String = "", + ) { + dashboardRouter.navigateToCourseOutline( + fm = fragmentManager, + courseId = enrolledCourse.course.id, + courseTitle = enrolledCourse.course.name, + enrollmentMode = enrolledCourse.mode, + openTab = if (openDates) CourseTab.DATES.name else CourseTab.HOME.name, + resumeBlockId = resumeBlockId + ) + } + + private fun collectDiscoveryNotifier() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { + if (it is CourseDashboardUpdate) { + updateCourses() + } + } + } + } +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt index c85390fa1..22637f48c 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/data/repository/DashboardRepository.kt @@ -2,14 +2,18 @@ package org.openedx.dashboard.data.repository import org.openedx.core.data.api.CourseApi import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseEnrollments import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.utils.FileUtil import org.openedx.dashboard.data.DashboardDao +import org.openedx.dashboard.domain.CourseStatusFilter class DashboardRepository( private val api: CourseApi, private val dao: DashboardDao, - private val preferencesManager: CorePreferences + private val preferencesManager: CorePreferences, + private val fileUtil: FileUtil, ) { suspend fun getEnrolledCourses(page: Int): DashboardCourseList { @@ -30,4 +34,30 @@ class DashboardRepository( val list = dao.readAllData() return list.map { it.mapToDomain() } } + + suspend fun getMainUserCourses(): CourseEnrollments { + val result = api.getUserCourses( + username = preferencesManager.user?.username ?: "", + ) + preferencesManager.appConfig = result.configs.mapToDomain() + + fileUtil.saveObjectToFile(result) + return result.mapToDomain() + } + + suspend fun getAllUserCourses(page: Int, status: CourseStatusFilter?): DashboardCourseList { + val user = preferencesManager.user + val result = api.getUserCourses( + username = user?.username ?: "", + page = page, + status = status?.key, + fields = listOf("course_progress") + ) + preferencesManager.appConfig = result.configs.mapToDomain() + + dao.clearCachedData() + dao.insertEnrolledCourseEntity(*result.enrollments.results.map { it.mapToRoomEntity() } + .toTypedArray()) + return result.enrollments.mapToDomain() + } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt b/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt new file mode 100644 index 000000000..79a19b89d --- /dev/null +++ b/dashboard/src/main/java/org/openedx/dashboard/domain/CourseStatusFilter.kt @@ -0,0 +1,18 @@ +package org.openedx.dashboard.domain + +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.vector.ImageVector +import org.openedx.core.ui.TabItem +import org.openedx.dashboard.R + +enum class CourseStatusFilter( + val key: String, + @StringRes + override val labelResId: Int, + override val icon: ImageVector? = null, +) : TabItem { + ALL("all", R.string.dashboard_course_filter_all), + IN_PROGRESS("in_progress", R.string.dashboard_course_filter_in_progress), + COMPLETE("completed", R.string.dashboard_course_filter_completed), + EXPIRED("expired", R.string.dashboard_course_filter_expired) +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt b/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt index a29c2cc7e..ae2e94d93 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/domain/interactor/DashboardInteractor.kt @@ -2,9 +2,10 @@ package org.openedx.dashboard.domain.interactor import org.openedx.core.domain.model.DashboardCourseList import org.openedx.dashboard.data.repository.DashboardRepository +import org.openedx.dashboard.domain.CourseStatusFilter class DashboardInteractor( - private val repository: DashboardRepository + private val repository: DashboardRepository, ) { suspend fun getEnrolledCourses(page: Int): DashboardCourseList { @@ -12,4 +13,16 @@ class DashboardInteractor( } suspend fun getEnrolledCoursesFromCache() = repository.getEnrolledCoursesFromCache() -} \ No newline at end of file + + suspend fun getMainUserCourses() = repository.getMainUserCourses() + + suspend fun getAllUserCourses( + page: Int = 1, + status: CourseStatusFilter? = null, + ): DashboardCourseList { + return repository.getAllUserCourses( + page, + status + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt similarity index 97% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index f6bc5c56a..597958e51 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -73,10 +73,13 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.core.AppUpdateState import org.openedx.core.UIMessage import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseSharingUtmParameters +import org.openedx.core.domain.model.CourseStatus import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.Progress import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage @@ -98,9 +101,9 @@ import org.openedx.dashboard.R import java.util.Date import org.openedx.core.R as CoreR -class DashboardFragment : Fragment() { +class DashboardListFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel() private val router by inject() override fun onCreate(savedInstanceState: Bundle?) { @@ -123,7 +126,7 @@ class DashboardFragment : Fragment() { val canLoadMore by viewModel.canLoadMore.observeAsState(false) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() - MyCoursesScreen( + DashboardListView( windowSize = windowSize, viewModel.apiHostUrl, uiState!!, @@ -166,7 +169,7 @@ class DashboardFragment : Fragment() { @OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class) @Composable -internal fun MyCoursesScreen( +internal fun DashboardListView( windowSize: WindowSize, apiHostUrl: String, state: DashboardUIState, @@ -551,9 +554,9 @@ private fun CourseItemPreview() { @Preview(uiMode = UI_MODE_NIGHT_NO) @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable -private fun MyCoursesScreenDay() { +private fun DashboardListViewPreview() { OpenEdXTheme { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( @@ -583,9 +586,9 @@ private fun MyCoursesScreenDay() { @Preview(uiMode = UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) @Preview(uiMode = UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) @Composable -private fun MyCoursesScreenTabletPreview() { +private fun DashboardListViewTabletPreview() { OpenEdXTheme { - MyCoursesScreen( + DashboardListView( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( @@ -612,12 +615,16 @@ private fun MyCoursesScreenTabletPreview() { } } +private val mockCourseAssignments = CourseAssignments(null, emptyList()) private val mockCourseEnrolled = EnrolledCourse( auditAccessExpires = Date(), created = "created", certificate = Certificate(""), mode = "mode", isActive = true, + progress = Progress.DEFAULT_PROGRESS, + courseStatus = CourseStatus("", emptyList(), "", ""), + courseAssignments = mockCourseAssignments, course = EnrolledCourseData( id = "id", name = "name", diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt similarity index 99% rename from dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt rename to dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index 0ec06a2c3..812e52f2e 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -20,8 +20,7 @@ import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor - -class DashboardViewModel( +class DashboardListViewModel( private val config: Config, private val networkConnection: NetworkConnection, private val interactor: DashboardInteractor, diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt index b0b0740d3..4d9b5cdbc 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardRouter.kt @@ -1,5 +1,6 @@ package org.openedx.dashboard.presentation +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager interface DashboardRouter { @@ -9,7 +10,15 @@ interface DashboardRouter { courseId: String, courseTitle: String, enrollmentMode: String, + openTab: String = "", + resumeBlockId: String = "" ) fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseSearch(fm: FragmentManager, querySearch: String) + + fun navigateToAllEnrolledCourses(fm: FragmentManager) + + fun getProgramFragmentInstance(): Fragment } diff --git a/dashboard/src/main/java/org/openedx/learn/LearnType.kt b/dashboard/src/main/java/org/openedx/learn/LearnType.kt new file mode 100644 index 000000000..08100ef35 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/LearnType.kt @@ -0,0 +1,9 @@ +package org.openedx.learn + +import androidx.annotation.StringRes +import org.openedx.dashboard.R + +enum class LearnType(@StringRes val title: Int) { + COURSES(R.string.dashboard_courses), + PROGRAMS(R.string.dashboard_programs) +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt new file mode 100644 index 000000000..b2de66cd4 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -0,0 +1,274 @@ +package org.openedx.learn.presentation + +import android.os.Bundle +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 +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.viewpager2.widget.ViewPager2 +import org.koin.android.ext.android.inject +import org.koin.androidx.compose.koinViewModel +import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.crop +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.courses.presentation.DashboardGalleryFragment +import org.openedx.dashboard.R +import org.openedx.dashboard.databinding.FragmentLearnBinding +import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.learn.LearnType +import org.openedx.core.R as CoreR + +class LearnFragment : Fragment(R.layout.fragment_learn) { + + private val binding by viewBinding(FragmentLearnBinding::bind) + private val router by inject() + private lateinit var adapter: NavigationFragmentAdapter + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.header.setContent { + OpenEdXTheme { + Header( + fragmentManager = requireParentFragment().parentFragmentManager, + viewPager = binding.viewPager + ) + } + } + initViewPager() + } + + private fun initViewPager() { + binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL + binding.viewPager.offscreenPageLimit = 2 + + adapter = NavigationFragmentAdapter(this).apply { + addFragment(DashboardGalleryFragment()) + addFragment(router.getProgramFragmentInstance()) + } + binding.viewPager.adapter = adapter + binding.viewPager.setUserInputEnabled(false) + } +} + +@Composable +private fun Header( + fragmentManager: FragmentManager, + viewPager: ViewPager2 +) { + val viewModel: LearnViewModel = koinViewModel() + val windowSize = rememberWindowSize() + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + Column( + modifier = Modifier + .background(MaterialTheme.appColors.background) + .statusBarsInset() + .displayCutoutForLandscape() + .then(contentWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Title( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = { + viewModel.onSettingsClick(fragmentManager) + } + ) + + if (viewModel.isProgramTypeWebView) { + LearnDropdownMenu( + modifier = Modifier + .align(Alignment.Start) + .padding(horizontal = 16.dp), + viewPager = viewPager + ) + } + } +} + +@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, + viewPager: ViewPager2 +) { + var expanded by remember { mutableStateOf(false) } + var currentValue by remember { mutableStateOf(LearnType.COURSES) } + val iconRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "" + ) + + LaunchedEffect(currentValue) { + viewPager.setCurrentItem( + when (currentValue) { + LearnType.COURSES -> 0 + LearnType.PROGRAMS -> 1 + }, false + ) + } + + Column( + modifier = modifier + ) { + Row( + modifier = Modifier + .clickable { + expanded = true + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = currentValue.title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleSmall + ) + Icon( + modifier = Modifier.rotate(iconRotation), + imageVector = Icons.Default.ExpandMore, + tint = MaterialTheme.appColors.textDark, + contentDescription = null + ) + } + + MaterialTheme( + colors = MaterialTheme.colors.copy(surface = MaterialTheme.appColors.background), + shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) + ) { + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .widthIn(min = 182.dp), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + for (learnType in LearnType.entries) { + val background: Color + val textColor: Color + if (currentValue == learnType) { + background = MaterialTheme.appColors.primary + textColor = MaterialTheme.appColors.primaryButtonText + } else { + background = Color.Transparent + textColor = MaterialTheme.appColors.textDark + } + DropdownMenuItem( + modifier = Modifier + .background(background), + onClick = { + currentValue = learnType + expanded = false + } + ) { + Text( + text = stringResource(id = learnType.title), + style = MaterialTheme.appTypography.titleSmall, + color = textColor + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun HeaderPreview() { + OpenEdXTheme { + Title( + label = stringResource(id = R.string.dashboard_learn), + onSettingsClick = {} + ) + } +} + +@Preview +@Composable +private fun LearnDropdownMenuPreview() { + OpenEdXTheme { + val context = LocalContext.current + LearnDropdownMenu( + viewPager = ViewPager2(context) + ) + } +} diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt new file mode 100644 index 000000000..d2300f652 --- /dev/null +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnViewModel.kt @@ -0,0 +1,18 @@ +package org.openedx.learn.presentation + +import androidx.fragment.app.FragmentManager +import org.openedx.core.BaseViewModel +import org.openedx.core.config.Config +import org.openedx.dashboard.presentation.DashboardRouter + +class LearnViewModel( + private val config: Config, + private val dashboardRouter: DashboardRouter +) : BaseViewModel() { + + val isProgramTypeWebView get() = config.getProgramConfig().isViewTypeWebView() + + fun onSettingsClick(fragmentManager: FragmentManager) { + dashboardRouter.navigateToSettings(fragmentManager) + } +} diff --git a/dashboard/src/main/res/drawable/dashboard_ic_book.xml b/dashboard/src/main/res/drawable/dashboard_ic_book.xml new file mode 100644 index 000000000..dd802ee92 --- /dev/null +++ b/dashboard/src/main/res/drawable/dashboard_ic_book.xml @@ -0,0 +1,44 @@ + + + + + + + + + + diff --git a/dashboard/src/main/res/layout/fragment_learn.xml b/dashboard/src/main/res/layout/fragment_learn.xml new file mode 100644 index 000000000..c6556b364 --- /dev/null +++ b/dashboard/src/main/res/layout/fragment_learn.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 583851adc..4ca0c4fce 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -1,7 +1,25 @@ - + Dashboard Courses Welcome back. Let\'s keep learning. You are not enrolled in any courses yet. + Learn + Programs + Course %1$s + Start Course + Resume Course + %1$d Past Due Assignments + View All Courses (%1$d) + View All + %1$s Due in %2$s + All + In Progress + Completed + Expired + All Courses + No Courses + You are not currently enrolled in any courses, would you like to explore the course catalog? + Find a Course + No %1$s Courses diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt index 6fdfdec22..6ca20a255 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt @@ -77,7 +77,7 @@ class DashboardViewModelTest { @Test fun `getCourses no internet connection`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -101,7 +101,7 @@ class DashboardViewModelTest { @Test fun `getCourses unknown error`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -125,7 +125,7 @@ class DashboardViewModelTest { @Test fun `getCourses from network`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -149,7 +149,7 @@ class DashboardViewModelTest { @Test fun `getCourses from network with next page`() = runTest { - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -183,7 +183,7 @@ class DashboardViewModelTest { fun `getCourses from cache`() = runTest { every { networkConnection.isOnline() } returns false coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -207,7 +207,7 @@ class DashboardViewModelTest { fun `updateCourses no internet error`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -235,7 +235,7 @@ class DashboardViewModelTest { fun `updateCourses unknown exception`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -263,7 +263,7 @@ class DashboardViewModelTest { fun `updateCourses success`() = runTest { every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -296,7 +296,7 @@ class DashboardViewModelTest { "" ) ) - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, @@ -321,7 +321,7 @@ class DashboardViewModelTest { @Test fun `CourseDashboardUpdate notifier test`() = runTest { coEvery { discoveryNotifier.notifier } returns flow { emit(CourseDashboardUpdate()) } - val viewModel = DashboardViewModel( + val viewModel = DashboardListViewModel( config, networkConnection, interactor, diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 95885ba8a..08fab937f 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -28,6 +28,9 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +DASHBOARD: + TYPE: 'gallery' + FIREBASE: ENABLED: false ANALYTICS_SOURCE: '' # segment | none diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 95885ba8a..08fab937f 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -28,6 +28,9 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +DASHBOARD: + TYPE: 'gallery' + FIREBASE: ENABLED: false ANALYTICS_SOURCE: '' # segment | none diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 95885ba8a..08fab937f 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -28,6 +28,9 @@ PROGRAM: PROGRAM_URL: '' PROGRAM_DETAIL_URL_TEMPLATE: '' +DASHBOARD: + TYPE: 'gallery' + FIREBASE: ENABLED: false ANALYTICS_SOURCE: '' # segment | none diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index ee3e04a3b..3b74dbc42 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt @@ -68,7 +68,10 @@ import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.core.R as coreR import org.openedx.discovery.presentation.catalog.WebViewLink.Authority as linkAuthority -class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { +class ProgramFragment( + private val myPrograms: Boolean = false, + private val isNestedFragment: Boolean = false +) : Fragment() { private val viewModel by viewModel() @@ -127,6 +130,7 @@ class ProgramFragment(private val myPrograms: Boolean = false) : Fragment() { cookieManager = viewModel.cookieManager, canShowBackBtn = arguments?.getString(ARG_PATH_ID, "") ?.isNotEmpty() == true, + isNestedFragment = isNestedFragment, uriScheme = viewModel.uriScheme, hasInternetConnection = hasInternetConnection, checkInternetConnection = { @@ -224,6 +228,7 @@ private fun ProgramInfoScreen( cookieManager: AppCookieManager, uriScheme: String, canShowBackBtn: Boolean, + isNestedFragment: Boolean, hasInternetConnection: Boolean, checkInternetConnection: () -> Unit, onWebPageLoaded: () -> Unit, @@ -250,7 +255,7 @@ private fun ProgramInfoScreen( .fillMaxSize() .semantics { testTagsAsResourceId = true }, backgroundColor = MaterialTheme.appColors.background - ) { + ) { paddingValues -> val modifierScreenWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( @@ -264,21 +269,29 @@ private fun ProgramInfoScreen( ) } + val statusBarPadding = if (isNestedFragment) { + Modifier + } else { + Modifier.statusBarsInset() + } + Column( modifier = Modifier .fillMaxSize() - .padding(it) - .statusBarsInset() + .padding(paddingValues) + .then(statusBarPadding) .displayCutoutForLandscape(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Toolbar( - label = stringResource(id = R.string.discovery_programs), - canShowBackBtn = canShowBackBtn, - canShowSettingsIcon = !canShowBackBtn, - onBackClick = onBackClick, - onSettingsClick = onSettingsClick - ) + if (!isNestedFragment) { + Toolbar( + label = stringResource(id = R.string.discovery_programs), + canShowBackBtn = canShowBackBtn, + canShowSettingsIcon = !canShowBackBtn, + onBackClick = onBackClick, + onSettingsClick = onSettingsClick + ) + } Surface { Box( @@ -349,6 +362,7 @@ fun MyProgramsPreview() { cookieManager = koinViewModel().cookieManager, uriScheme = "", canShowBackBtn = false, + isNestedFragment = false, hasInternetConnection = false, checkInternetConnection = {}, onBackClick = {}, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt index 46552edc9..456eb79c2 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModel.kt @@ -11,11 +11,10 @@ import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.extension.isInternetError -import org.openedx.core.presentation.course.CourseContainerTab import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.core.system.notifier.CourseRefresh +import org.openedx.core.system.notifier.RefreshDiscussions import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter @@ -81,11 +80,7 @@ class DiscussionTopicsViewModel( viewModelScope.launch { courseNotifier.notifier.collect { event -> when (event) { - is CourseRefresh -> { - if (event.courseContainerTab == CourseContainerTab.DISCUSSIONS) { - getCourseTopic() - } - } + is RefreshDiscussions -> getCourseTopic() } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt index a9094c67d..8d49fb8ec 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/CalendarAccessDialogFragment.kt @@ -128,7 +128,7 @@ private fun CalendarAccessDialog( TextIcon( text = stringResource(id = R.string.profile_grant_access_calendar), icon = Icons.AutoMirrored.Filled.OpenInNew, - color = MaterialTheme.appColors.buttonText, + color = MaterialTheme.appColors.primaryButtonText, textStyle = MaterialTheme.appTypography.labelLarge, iconModifier = Modifier.padding(start = 4.dp) ) @@ -138,8 +138,8 @@ private fun CalendarAccessDialog( modifier = Modifier.fillMaxWidth(), text = stringResource(id = CoreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, onClick = { onCancelClick() } diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt index bfd453f5c..8e55b885b 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -172,8 +172,8 @@ private fun NewCalendarDialog( modifier = Modifier.fillMaxWidth(), text = stringResource(id = CoreR.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, - borderColor = MaterialTheme.appColors.buttonBackground, - textColor = MaterialTheme.appColors.buttonBackground, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, onClick = { onCancelClick() }